mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
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:
@@ -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`).
|
||||
|
||||
@@ -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
358
launcher/gameart.py
Normal 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
329
launcher/steam.py
Normal 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.'
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user