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 <noreply@anthropic.com>
This commit is contained in:
epicmeme546
2026-11-10 13:49:06 -05:00
parent da0d0be700
commit a7b8810d37
6 changed files with 860 additions and 3 deletions

View File

@@ -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`).

View File

@@ -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,

358
launcher/gameart.py Normal file
View File

@@ -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('<H', b, at)[0]
def _u32(b, at): return struct.unpack_from('<I', b, at)[0]
def _u64(b, at): return struct.unpack_from('<Q', b, at)[0]
def bntx_textures(b):
"""Each texture of a BNTX file: name, format, size, block height, data offset and size."""
out = []
count, arr = _u32(b, 0x24), _u64(b, 0x28)
for i in range(count):
p = _u64(b, arr + 8 * i)
if b[p:p + 4] != b'BRTI':
continue
no = _u64(b, p + 0x60)
name = b[no + 2:no + 2 + _u16(b, no)].decode('utf-8', 'replace')
out.append({'name': name, 'fmt': _u32(b, p + 0x1c), 'w': _u32(b, p + 0x24), 'h': _u32(b, p + 0x28),
'th': _u32(b, p + 0x34), 'size': _u32(b, p + 0x50), 'data': _u64(b, _u64(b, p + 0x70))})
return out
def deswizzle(t, b, row, rows):
"""The first level as tightly packed rows of `row` bytes (runtime/tex_deswizzle.c tex_bl_copy)."""
th = t['th']
gobs, unit, block = (row + 63) // 64, 8 << th, 512 << th
need = gobs * ((rows + unit - 1) // unit) * block
src = bytearray(max(need, t['size']) + 16)
src[:t['size']] = b[t['data']:t['data'] + t['size']]
bl = np.frombuffer(bytes(src), dtype='V16')
cols = (row + 15) // 16
y = np.arange(rows)[:, None]
x = (np.arange(cols) * 16)[None, :]
at = (y // unit) * gobs * block + (x // 64) * block + ((y % unit) // 8) * 512 + \
(x % 64 // 32) * 256 + (y % 8 // 4) * 128 + (x % 32 // 16) * 64 + (y % 4) * 16
flat = np.frombuffer(bl[at // 16].tobytes(), dtype=np.uint8).reshape(rows, cols * 16)
return flat[:, :row]
def bc4(lin, wb, hb, w, h):
"""White with the mask as alpha (runtime/menu_assets.c bc4_decode)."""
blocks = lin.reshape(hb, wb, 8).astype(np.uint32)
a, b = blocks[..., 0], blocks[..., 1]
pal = np.zeros((hb, wb, 8), dtype=np.uint32)
pal[..., 0], pal[..., 1] = a, b
big = a > 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('<HH4sHHIIII', 0, 32, b'AV01', w, h, 1000, 1, len(sel), 0))
for t, _key, d in sel:
f.write(struct.pack('<IQ', len(d), max(t, 0)) + d)
r = subprocess.run([str(dav1d), '-q', '-i', str(ivf), '-o', str(y4m)], capture_output=True, text=True,
cwd=str(dav1d.parent), creationflags=NO_WINDOW)
if r.returncode or not y4m.is_file():
raise ArtError(f'the title movie did not decode: {(r.stderr or r.stdout).strip()[-300:]}')
b = y4m.read_bytes()
at = b.rfind(b'FRAME\n')
if at < 0 or len(b) < at + 6 + w * h * 3 // 2:
raise ArtError('the title movie did not decode to a picture')
at += 6
y = np.frombuffer(b, np.uint8, w * h, at).reshape(h, w).astype(np.float32)
cw, ch = (w + 1) // 2, (h + 1) // 2
u = np.frombuffer(b, np.uint8, cw * ch, at + w * h).reshape(ch, cw).astype(np.float32)
v = np.frombuffer(b, np.uint8, cw * ch, at + w * h + cw * ch).reshape(ch, cw).astype(np.float32)
u = (u.repeat(2, 0).repeat(2, 1)[:h, :w] - 128) * (255 / 224)
v = (v.repeat(2, 0).repeat(2, 1)[:h, :w] - 128) * (255 / 224)
y = (y - 16) * (255 / 219)
rgb = np.stack([y + 1.5748 * v, y - 0.1873 * u - 0.4681 * v, y + 1.8556 * u], -1) # BT.709
return np.clip(rgb + 0.5, 0, 255).astype(np.uint8)
# ---------------------------------------------------------------- pictures
def resize(img, w, h):
"""img (HxWxC uint8 or float) at w x h: whole-pixel box averaging first when it shrinks by 2 or more, then
bilinear. Returns float32."""
a = img.astype(np.float32)
fy, fx = a.shape[0] // h, a.shape[1] // w
if fy >= 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)}

329
launcher/steam.py Normal file
View File

@@ -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 <Steam>/userdata/<account id>/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: <id>p.png the cover, <id>.png the wide tile, <id>_hero.png the
background, <id>_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('<I', a[2])[0] == sid):
entry = it[2]
break
if entry is None:
used = {it[1] for it in root[2]}
k = 0
while str(k) in used:
k += 1
entry = []
root[2].append([T_MAP, str(k), entry])
for key, t, v in (('IsHidden', T_INT, 0), ('AllowDesktopConfig', T_INT, 1), ('AllowOverlay', T_INT, 1),
('OpenVR', T_INT, 0), ('Devkit', T_INT, 0), ('DevkitGameID', T_STR, ''),
('DevkitOverrideAppID', T_INT, 0), ('LastPlayTime', T_INT, 0), ('FlatpakAppID', T_STR, '')):
entry.append([t, key, struct.pack('<I', v) if t == T_INT else v])
entry.append([T_MAP, 'tags', []])
_set(entry, T_INT, 'appid', struct.pack('<I', sid))
_set(entry, T_STR, 'AppName', name)
_set(entry, T_STR, 'Exe', exe)
_set(entry, T_STR, 'StartDir', start_dir)
_set(entry, T_STR, 'icon', icon)
_set(entry, T_STR, 'LaunchOptions', options)
# Steam's order: appid and the strings first, then the switches and tags
order = ['appid', 'AppName', 'Exe', 'StartDir', 'icon', 'ShortcutPath', 'LaunchOptions']
entry.sort(key=lambda it: order.index(it[1]) if it[1] in order else len(order) + (it[1] == 'tags'))
if _get(entry, 'ShortcutPath') is None:
entry.insert(5, [T_STR, 'ShortcutPath', ''])
return vdf_dump(items), sid
def has_shortcut(vdf_bytes, name=NAME):
try:
root = _get(vdf_load(vdf_bytes), 'shortcuts') if vdf_bytes else None
except SteamError:
return False
return bool(root and root[0] == T_MAP and any(
it[0] == T_MAP and (_get(it[2], 'AppName') or [0, 0, None])[2] == name for it in root[2]))
# ---------------------------------------------------------------- Steam on this PC
def find_steam():
"""(Steam's folder, steam.exe) from the registry, else the usual folder; None when Steam is not installed."""
if os.name != 'nt':
return None
folder = exe = None
try:
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r'Software\Valve\Steam') as k:
for name in ('SteamPath', 'SteamExe'):
try:
v = winreg.QueryValueEx(k, name)[0]
except OSError:
continue
if name == 'SteamPath':
folder = Path(v)
else:
exe = Path(v)
except OSError:
pass
if folder is None:
folder = Path(os.environ.get('ProgramFiles(x86)', r'C:\Program Files (x86)')) / 'Steam'
exe = exe or folder / 'steam.exe'
if not (folder / 'userdata').is_dir() and not exe.is_file():
return None
return folder, exe
def accounts(folder):
"""The config folders of the accounts that signed in to Steam on this PC (config/loginusers.vdf), else of every
account folder under userdata."""
ud = Path(folder) / 'userdata'
if not ud.is_dir():
return []
dirs = {p.name: p / 'config' for p in ud.iterdir() if p.is_dir() and p.name.isdigit() and p.name != '0'}
signed = []
try:
text = (Path(folder) / 'config' / 'loginusers.vdf').read_text(encoding='utf-8', errors='replace')
import re
signed = [str(int(i) - STEAM64_BASE) for i in re.findall(r'"(7656119\d{10})"', text)]
except (OSError, ValueError):
pass
picked = [dirs[a] for a in signed if a in dirs] or list(dirs.values())
return sorted(set(picked))
def added(folder=None):
"""True when an account of this PC has our shortcut."""
found = (folder, None) if folder else find_steam()
if not found:
return False
for cfg in accounts(found[0]):
try:
if has_shortcut((cfg / 'shortcuts.vdf').read_bytes()):
return True
except OSError:
pass
return False
def steam_running():
if os.name != 'nt':
return False
try:
r = subprocess.run(['tasklist', '/FI', 'IMAGENAME eq steam.exe', '/NH', '/FO', 'CSV'], capture_output=True,
text=True, creationflags=NO_WINDOW, timeout=15)
except (OSError, subprocess.TimeoutExpired):
return False
return '"steam.exe"' in r.stdout.lower()
def close_steam(exe, timeout=45.0):
"""Ask Steam to close and wait for it; False when it is still running after timeout seconds."""
subprocess.Popen([str(exe), '-shutdown'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, creationflags=NO_WINDOW, close_fds=True)
end = time.time() + timeout
while time.time() < end:
time.sleep(1)
if not steam_running():
time.sleep(2) # it writes its files on the way out
return True
return False
def write_atomic(path, data):
tmp = path.with_name(path.name + '.nx2navi-tmp')
tmp.write_bytes(data)
os.replace(tmp, path)
def add(layout, game_dir, dump='', log=print, restart=True):
"""Add the game to Steam (or update the shortcut): the pictures from the player's game, the shortcut in every
account's shortcuts.vdf, Steam closed first and started again. Returns the sentence the launcher shows."""
from . import gameart
from .ui import art
found = find_steam()
if not found:
raise SteamError('Steam is not installed on this PC.')
folder, exe_steam = found
cfgs = accounts(folder)
if not cfgs:
raise SteamError('Steam has no account on this PC yet: sign in to Steam once, then try again.')
game = Path(game_dir) / 'ocarina.exe'
if not game.is_file():
raise SteamError('The game is not built yet.')
try:
pics = gameart.make(layout, game_dir, dump)
except gameart.ArtError as e:
raise SteamError(f'The pictures could not be made: {e}.') from None
script = layout.data / PLAY_SCRIPT
write_atomic(script, PLAY_TEXT.encode('utf-8'))
python = Path(layout.python) / 'pythonw.exe'
if not python.is_file(): # a checkout runs under its own interpreter
import sys
python = Path(sys.executable).with_name('pythonw.exe')
exe, start = f'"{python}"', f'"{layout.root}"'
options = f'"{script}"'
was_running = steam_running()
if was_running:
log('steam: asking Steam to close')
if not close_steam(exe_steam):
raise SteamError('Steam did not close. Close Steam yourself, then select Add to Steam again.')
sid = appid(exe, NAME)
try:
for cfg in cfgs:
grid = cfg / 'grid'
grid.mkdir(parents=True, exist_ok=True)
icon = grid / f'{sid}_icon.png'
for name, file in (('icon', icon.name), ('portrait', f'{sid}p.png'), ('wide', f'{sid}.png'),
('hero', f'{sid}_hero.png'), ('logo', f'{sid}_logo.png')):
write_atomic(grid / file, art.png_bytes(pics[name]))
vdf = cfg / 'shortcuts.vdf'
try:
old = vdf.read_bytes()
except FileNotFoundError:
old = b''
new, got = add_shortcut(old, exe, start, options, str(icon))
if old and not (cfg / 'shortcuts.vdf.nx2navi-backup').exists():
shutil.copyfile(vdf, cfg / 'shortcuts.vdf.nx2navi-backup')
write_atomic(vdf, new)
log(f'steam: shortcut {got} in {vdf}')
finally: # Steam comes back also when a file could not be written
if was_running and restart:
subprocess.Popen([str(exe_steam)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, creationflags=0x00000008, close_fds=True) # detached
if was_running and restart:
return f'Added to Steam as {NAME}. Steam is starting again with it in the library.'
return f'Added to Steam as {NAME}. It is in the library the next time Steam starts.'

View File

@@ -9,7 +9,7 @@ flat cells and the orange choices are canvas items. Screens:
first_run the dump, the install folder, three choices and Begin Installation
home Play, Updates, the game folder, Saves, Rebuild the Game, Customize Launcher, Settings
customize the colour scheme, the player's own background picture, animations and sounds
customize the colour scheme, the player's own background picture, animations and sounds; Add to Steam
saves Export Save (a zip for a bug report or another player), Import a Save (such a zip), a save folder
job the steps of an install, update or rebuild, with their progress, and Cancel; after a failure Try Again
and Copy Report
@@ -35,7 +35,7 @@ import tkinter.font as tkfont
from pathlib import Path
from tkinter import filedialog
from .. import NAME, build, feed, gameini, gamefiles, jobs, saves, versions, winsys
from .. import NAME, build, feed, gameini, gamefiles, jobs, saves, steam, versions, winsys
from ..context import Context
from . import art, picture, sound
@@ -170,6 +170,8 @@ class App:
self.look_message = '' # why the last background picture was not taken, in Customize Launcher's descriptions
self.save_message = '' # the last import's result, shown in the import rows' descriptions
self.export_message = '' # the last Export Save result, shown in its description
self.steam_message = '' # the last Add to Steam result, shown in its description
self.steam_busy = False # Add to Steam is making the pictures or writing Steam's files
self.dumps = None
self.dump_search = None
self.uninstall_game = False
@@ -951,11 +953,75 @@ class App:
left=lambda: sounds(-1), right=lambda: sounds(1),
desc=('The game\'s select and back sounds. Low by default.',)),
Row('divider'),
]
if os.name == 'nt':
rows += [self.steam_row(), Row('divider')]
rows += [
Row('action', 'Back', on=lambda: self.show('home', self.home_row('Customize Launcher')), height=40,
desc=('Back to the main menu.',)),
]
return rows
def steam_row(self):
"""Add to Steam: the game in the Steam library, started from there without the launcher (launcher/steam.py)."""
found = steam.find_steam()
desc = ('Add Ocarina of Time to your Steam library as The Legend of Zelda Ocarina of Time, with Navi as its '
'icon and the title screen\'s moonlit field and logo as its pictures, all taken from your game. '
'Playing it from Steam starts the game straight away.',
'Steam closes while the shortcut is written and starts again with it. Select the row again after an '
'update of the game to refresh it.')
if self.steam_message:
desc = (self.steam_message,) + desc
if self.steam_busy:
value, style = 'Adding…', 'dim'
elif not found:
value, style = 'Steam not found', 'dim'
else:
value, style = ('Added' if steam.added(found[0]) else ''), 'arrow'
return Row('value', 'Add to Steam', value, style, on=self.add_to_steam, desc=desc)
def add_to_steam(self):
import threading
if self.steam_busy:
return
gd = self.settings.get('game_dir')
if not steam.find_steam():
self.steam_message = 'Steam is not installed on this PC.'
self.sounds.play('back')
self.show('customize', self.focus)
return
if not gd or not (Path(gd) / 'ocarina.exe').is_file():
self.steam_message = 'The game is not built yet.'
self.sounds.play('back')
self.show('customize', self.focus)
return
self.steam_busy = True
self.steam_message = 'Making the pictures from your game and adding it to Steam…'
self.sounds.play('select')
self.show('customize', self.focus)
dump = self.settings.get('dump')
def work():
try:
text, ok = steam.add(self.layout, gd, dump, self.ctx.log), True
except steam.SteamError as e:
text, ok = str(e), False
except Exception as e: # a Steam folder in a state nobody expected: say so instead of dying
import traceback
self.ctx.log('steam: ' + traceback.format_exc())
text, ok = f'Adding to Steam failed: {e}', False
self.ctx.log(f'steam: {text}')
self.root.after(0, lambda: self.steam_done(text, ok))
threading.Thread(target=work, daemon=True).start()
def steam_done(self, text, ok):
self.steam_busy = False
self.steam_message = text
if not ok:
self.sounds.play('back')
if self.screen == 'customize':
self.show('customize', self.focus)
def choose_background(self):
start = self.settings.get('background')
start = str(Path(start).parent) if start and Path(start).parent.is_dir() else \
@@ -1982,6 +2048,8 @@ def parse_args(argv):
ap.add_argument('--root', help='the launcher folder (tests)')
ap.add_argument('--selftest', action='store_true', help='draw every screen and close')
ap.add_argument('--shots', help='with --selftest: save a PNG of each screen into this folder')
ap.add_argument('--play', action='store_true',
help='start the game without the window and wait for it (the Steam shortcut, launcher/steam.py)')
a, _unknown = ap.parse_known_args(argv)
return a
@@ -2011,12 +2079,40 @@ def single_instance(layout, wait):
time.sleep(0.25)
def play_only(ctx):
"""--play, from the Steam shortcut (launcher/steam.py): the game starts from its folder with its console hidden, as
Play starts it, and this process waits until it and the copies its own Restart starts have ended, so Steam shows
the game as running all that time. None when there is no built game: the launcher's window opens instead."""
gd = ctx.settings.get('game_dir')
exe = Path(gd) / 'ocarina.exe' if gd else None
if not exe or not exe.is_file():
return None
if saves.game_running(gd):
ctx.log('play from Steam: the game is already running')
return 0
try:
proc = winsys.start_game(exe)
except OSError as e:
ctx.log(f'play from Steam: {exe}: {e}')
return None
ctx.log(f'play from Steam: {exe}')
time.sleep(2)
while proc.poll() is None or saves.game_running(gd):
time.sleep(1)
ctx.log(f'play from Steam: the game ended (code {proc.returncode})')
return 0
def main(argv=None):
a = parse_args(sys.argv[1:] if argv is None else argv)
winsys.dpi_aware()
winsys.app_id()
from ..paths import Layout
ctx = Context(Layout(root=a.root) if a.root else None)
if a.play and not a.selftest:
code = play_only(ctx)
if code is not None:
return code
lock = None
if not a.selftest:
lock = single_instance(ctx.layout, 20 if (a.continue_update or a.rolled_back) else 2)

View File

@@ -18,6 +18,7 @@ Run: python tests/launcher_test.py (Python 3.14 for the zstd tests; the window
a source checkout's build/xlate/), the prompts step with a stand-in prompt tool, the object cache of the compile
step with the system's C compiler, and its windres call under folders with spaces (a script in windres's place)
- jobs: an install driven by a fake make_game.py, with and without the prompts row
- Add to Steam: the binary shortcuts file read and written back unchanged, a shortcut added and updated in place
- the look: the colour schemes, the background picture's fitting and kept copy, the sound effects (low by default,
only the game's own select and back sounds, decoded from a made-up .bwav)
- the window: --selftest draws every screen (skipped without a display)
@@ -2839,6 +2840,60 @@ def have_display():
@unittest.skipUnless(have_display(), 'needs tkinter, numpy and a display')
class SteamTest(Tmp):
"""launcher/steam.py on made-up Steam files: the binary shortcuts file read and written back unchanged, our
shortcut added beside another and updated in place, its id as Steam computes it, the accounts that signed in."""
def other(self):
from launcher import steam
exe = '"C:\\Games\\Other\\other.exe"'
entry = [[steam.T_INT, 'appid', struct.pack('<I', steam.appid(exe, 'Other'))], [steam.T_STR, 'AppName', 'Other'],
[steam.T_STR, 'Exe', exe], [steam.T_STR, 'StartDir', '"C:\\Games\\Other\\"'],
[steam.T_STR, 'icon', ''], [steam.T_INT, 'LastPlayTime', struct.pack('<I', 1234)],
[steam.T_STR, 'sortas', ''], [steam.T_MAP, 'tags', [[steam.T_STR, '0', 'Favorite']]]]
return steam.vdf_dump([[steam.T_MAP, 'shortcuts', [[steam.T_MAP, '0', entry]]]])
def test_round_trip_add_and_update(self):
from launcher import steam
b = self.other()
self.assertEqual(b[-4:], b'\x08\x08\x08\x08') # tags, the shortcut, shortcuts, the file
self.assertEqual(steam.vdf_dump(steam.vdf_load(b)), b)
self.assertFalse(steam.has_shortcut(b))
new, sid = steam.add_shortcut(b, '"C:\\NX2navi\\python\\pythonw.exe"', '"C:\\NX2navi"',
'"C:\\NX2navi\\data\\steam_play.pyw"', 'C:\\grid\\x_icon.png')
self.assertEqual(sid, steam.appid('"C:\\NX2navi\\python\\pythonw.exe"', steam.NAME))
self.assertTrue(sid & 0x80000000)
self.assertTrue(new.startswith(b[:-2])) # the other shortcut stays byte for byte
self.assertTrue(steam.has_shortcut(new))
items = steam.vdf_load(new)[0][2]
self.assertEqual([k for _, k, _ in items], ['0', '1'])
ours = {k: v for _, k, v in items[1][2]}
self.assertEqual(ours['AppName'], 'The Legend of Zelda Ocarina of Time')
self.assertEqual(struct.unpack('<I', ours['appid'])[0], sid)
self.assertEqual(ours['LaunchOptions'], '"C:\\NX2navi\\data\\steam_play.pyw"')
self.assertEqual(ours['AllowOverlay'], struct.pack('<I', 1))
again, _ = steam.add_shortcut(new, '"D:\\NX2navi\\python\\pythonw.exe"', '"D:\\NX2navi"', '', 'x')
items = steam.vdf_load(again)[0][2]
self.assertEqual(len(items), 2) # moved launcher: the same shortcut, updated
self.assertEqual({k: v for _, k, v in items[1][2]}['StartDir'], '"D:\\NX2navi"')
with self.assertRaises(steam.SteamError):
steam.vdf_load(b[:-5])
def test_new_file_and_accounts(self):
from launcher import steam
new, _ = steam.add_shortcut(b'', '"x.exe"', '"."', '', '')
self.assertTrue(steam.has_shortcut(new))
root = self.tmp / 'Steam'
for a in ('0', '1046310423', '22'):
(root / 'userdata' / a / 'config').mkdir(parents=True)
(root / 'config').mkdir()
(root / 'config' / 'loginusers.vdf').write_text('"users"\n{\n\t"76561199006576151"\n\t{\n\t}\n}\n')
self.assertEqual(steam.accounts(root), [root / 'userdata' / '1046310423' / 'config'])
(root / 'config' / 'loginusers.vdf').unlink()
self.assertEqual(steam.accounts(root), sorted([root / 'userdata' / '1046310423' / 'config',
root / 'userdata' / '22' / 'config']))
class WhilePlayingTest(Tmp):
def test_the_games_restart_does_not_bring_the_launcher_back(self):
try: