scan.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. #!/usr/bin/env python3
  2. """Scans the workshop library and writes a feature inventory.
  3. scan.py [ids...] (no ids = whole library)
  4. Writes features.json (per item: info + feature counter) and FEATURES.md (feature -> items index,
  5. for picking regression targets) into $LWE_LIBRARY_DIR (default ~/.local/share/lwe-library).
  6. Reads scene.pkg in memory, nothing unpacked.
  7. """
  8. import collections, json, os, re, struct, sys
  9. W = os.environ.get('LWE_WORKSHOP_DIR', '/workspace/SteamLibrary/steamapps/workshop/content/431960')
  10. ASSETS = os.environ.get('LWE_ASSETS_DIR', '/workspace/SteamLibrary/steamapps/common/wallpaper_engine/assets')
  11. HERE = os.environ.get('LWE_LIBRARY_DIR', os.path.expanduser('~/.local/share/lwe-library'))
  12. def parse_json(data):
  13. if isinstance(data, bytes):
  14. data = data.decode('utf-8-sig', errors='replace')
  15. data = re.sub(r',(\s*[}\]])', r'\1', data)
  16. try:
  17. return json.loads(data)
  18. except Exception:
  19. return None
  20. class Source:
  21. """Files of one item: scene.pkg entries, then loose files, then its dependency, then assets."""
  22. def __init__(self, root, dep_root=None):
  23. self.roots = [root] + ([dep_root] if dep_root else []) + [ASSETS]
  24. self.pkgs = []
  25. for r in self.roots[:-1]:
  26. for name in ('scene.pkg', 'gifscene.pkg'):
  27. p = os.path.join(r, name)
  28. if os.path.exists(p):
  29. self.pkgs.append(self._index(p))
  30. @staticmethod
  31. def _index(path):
  32. with open(path, 'rb') as f:
  33. def u32():
  34. return struct.unpack('<I', f.read(4))[0]
  35. f.read(u32())
  36. entries = {}
  37. for _ in range(u32()):
  38. name = f.read(u32()).decode('utf-8', errors='replace').replace('\\', '/')
  39. entries[name] = (u32(), u32())
  40. return path, f.tell(), entries
  41. def names(self):
  42. out = set()
  43. for _, _, e in self.pkgs:
  44. out.update(e)
  45. for r in self.roots[:1]:
  46. for dp, _, fs in os.walk(r):
  47. for f in fs:
  48. out.add(os.path.relpath(os.path.join(dp, f), r))
  49. return out
  50. def read(self, rel, limit=None):
  51. if not isinstance(rel, str):
  52. return None
  53. rel = rel.replace('\\', '/')
  54. for path, base, entries in self.pkgs:
  55. if rel in entries:
  56. off, ln = entries[rel]
  57. with open(path, 'rb') as f:
  58. f.seek(base + off)
  59. return f.read(ln if limit is None else min(ln, limit))
  60. for r in self.roots:
  61. p = os.path.join(r, rel)
  62. if os.path.isfile(p):
  63. with open(p, 'rb') as f:
  64. return f.read(limit if limit else -1)
  65. return None
  66. def json(self, rel):
  67. d = self.read(rel)
  68. return parse_json(d) if d is not None else None
  69. def val(v):
  70. return v.get('value') if isinstance(v, dict) else v
  71. def truthy(v):
  72. v = val(v)
  73. return v not in (None, False, 0, '', '0')
  74. def scan_scene(src, out, info):
  75. name = info.get('file') if info.get('type') == 'scene' and info.get('file') else 'scene.json'
  76. if name != 'scene.json':
  77. out['scenefile:' + name] += 1
  78. sc = src.json(name)
  79. if sc is None:
  80. out['ERROR:no-' + name] += 1
  81. return
  82. info['version'] = sc.get('version')
  83. g = sc.get('general', {})
  84. for k in ('bloom', 'hdr', 'cameraparallax', 'camerashake', 'fog', 'fogheight',
  85. 'reflection', 'lightconfig'):
  86. if truthy(g.get(k)):
  87. out['general:' + k] += 1
  88. if g.get('orthogonalprojection') is None:
  89. out['3d-perspective'] += 1
  90. else:
  91. op = g['orthogonalprojection']
  92. if isinstance(op, dict) and op.get('auto'):
  93. out['ortho-auto'] += 1
  94. elif isinstance(op, dict):
  95. info['size'] = f"{op.get('width')}x{op.get('height')}"
  96. if sc.get('camera', {}).get('paths'):
  97. out['camera-paths'] += 1
  98. objs = sc.get('objects', [])
  99. info['objs'] = len(objs)
  100. ids = {o.get('id') for o in objs}
  101. for o in objs:
  102. blob = json.dumps(o, ensure_ascii=False)
  103. if o.get('visible') is False or (isinstance(o.get('visible'), dict) and o['visible'].get('value') is False):
  104. out['invisible-obj'] += 1
  105. if isinstance(o.get('visible'), dict) and o['visible'].get('user'):
  106. out['user-visible-toggle'] += 1
  107. if o.get('parent') is not None and o.get('parent') in ids:
  108. out['parented'] += 1
  109. if 'image' in o:
  110. img = o['image'] or ''
  111. model = src.json(img) if img else None
  112. if img.startswith('models/util/solidlayer'):
  113. out['solidlayer'] += 1
  114. elif 'fullscreenlayer' in img:
  115. out['fullscreenlayer'] += 1
  116. elif 'composelayer' in img:
  117. out['composelayer'] += 1
  118. elif model and model.get('puppet'):
  119. out['puppet'] += 1
  120. else:
  121. out['image'] += 1
  122. if model and model.get('autosize'):
  123. out['autosize'] += 1
  124. if model and model.get('material'):
  125. mat = src.json(model['material'])
  126. if mat:
  127. for p in mat.get('passes', []):
  128. for t in p.get('textures', []) or []:
  129. if t and isinstance(t, str) and t.startswith('_rt_'):
  130. out['material-rt-texture'] += 1
  131. if p.get('usertextures'):
  132. out['usertexture'] += 1
  133. if truthy(o.get('perspective')):
  134. out['image-3d-perspective'] += 1
  135. if 'model' in o:
  136. out['model3d'] += 1
  137. for k in ('text', 'particle', 'sound', 'light'):
  138. if k in o:
  139. out[k if k != 'light' else 'light:' + str(o.get('light'))] += 1
  140. if 'camera' in o or o.get('type') == 'camera':
  141. out['camera-object'] += 1
  142. if 'particle' in o:
  143. p = src.json(o['particle']) or {}
  144. if p.get('children'):
  145. out['particle-children'] += 1
  146. if 'rope' in json.dumps(p.get('renderers', [])):
  147. out['particle-rope'] += 1
  148. for sect in ('operators', 'initializers', 'emitters'):
  149. for x in p.get(sect, []) or []:
  150. n = x.get('name')
  151. if n in ('boids', 'collisionplane', 'collisionsphere', 'collisionbox', 'collisionquad', 'remapvalue',
  152. 'maintaindistancetocontrolpoint', 'maintaindistancebetweencontrolpoints', 'controlpointattract',
  153. 'vortex', 'vortex_v2', 'turbulence', 'audioprocessing', 'hsvcolorrandom', 'colorlist',
  154. 'capvelocity', 'reducemovementnearcontrolpoint', 'inheritvaluefromevent', 'mapsequencebetweencontrolpoints',
  155. 'mapsequencearoundcontrolpoint', 'boxrandom', 'sphererandom'):
  156. out['pcomp:' + n] += 1
  157. if o.get('instanceoverride'):
  158. out['particle-instanceoverride'] += 1
  159. if o.get('copybackground') is False:
  160. out['copybackground=false'] += 1
  161. if o.get('passthrough'):
  162. out['passthrough'] += 1
  163. if o.get('solid') is not None or o.get('disablepropagation'):
  164. out['cursor-solid/propagation'] += 1
  165. pd = o.get('parallaxDepth')
  166. if isinstance(pd, str) and pd.strip() not in ('0 0', '1 1', '0.00000 0.00000', '1.00000 1.00000'):
  167. out['parallaxDepth'] += 1
  168. if val(o.get('colorBlendMode')) not in (None, 0):
  169. out['colorBlendMode'] += 1
  170. n = blob.count('"script"')
  171. if n:
  172. out['scripts'] += n
  173. if 'createLayer' in blob:
  174. out['script:createLayer'] += 1
  175. if 'getVideoTexture' in blob:
  176. out['script:videoTexture'] += 1
  177. if 'registerAudioBuffers' in blob:
  178. out['script:audio'] += 1
  179. if 'cursor' in blob.lower():
  180. out['script:cursor'] += 1
  181. if 'mediaPlayback' in blob or 'mediaThumbnail' in blob or 'mediaProperties' in blob:
  182. out['script:media'] += 1
  183. if '"animation"' in blob:
  184. out['prop-animation'] += 1
  185. if 'animationlayers' in blob:
  186. out['puppet-animationlayers'] += 1
  187. if '"user"' in blob:
  188. out['user-bound-values'] += 1
  189. for e in o.get('effects', []) or []:
  190. f = e.get('file', '')
  191. m = re.match(r'effects/(workshop/\d+/)?([^/]+)/', f)
  192. name = m.group(2) if m else f
  193. if m and m.group(1):
  194. out['workshop-effect'] += 1
  195. name = 'ws:' + name
  196. elif src.read(f, 1) is not None and not os.path.exists(os.path.join(ASSETS, f)):
  197. name += '*'
  198. out['fx:' + name] += 1
  199. if isinstance(e.get('visible'), dict) and e['visible'].get('user'):
  200. out['user-effect-toggle'] += 1
  201. names = src.names()
  202. shaders = [n for n in names if n.startswith('shaders/') and n.endswith(('.frag', '.vert'))]
  203. if shaders:
  204. out['custom-shaders'] = len(shaders)
  205. for d in ('videos', 'sounds', 'fonts', 'scripts'):
  206. n = len([x for x in names if x.startswith(d + '/')])
  207. if n:
  208. out['dir:' + d] = n
  209. vt = gif = 0
  210. for n in names:
  211. if n.startswith('materials/') and n.endswith('.tex'):
  212. head = src.read(n, 4096) or b''
  213. if b'ftyp' in head:
  214. vt += 1
  215. elif head[:9] == b'TEXV0005\x00' and b'TEXS' in head[:200]:
  216. gif += 1
  217. if vt:
  218. out['video-tex'] = vt
  219. def scan_web(root, out):
  220. for dp, _, fs in os.walk(root):
  221. for f in fs:
  222. if f.endswith(('.js', '.html', '.htm')):
  223. try:
  224. t = open(os.path.join(dp, f), encoding='utf-8', errors='replace').read()
  225. except OSError:
  226. continue
  227. for k in ('wallpaperRegisterAudioListener', 'wallpaperPropertyListener', 'wallpaperRegisterMediaPropertiesListener',
  228. 'wallpaperRegisterMediaThumbnailListener', 'wallpaperRegisterMediaPlaybackListener', 'webgl', 'WebGL',
  229. 'THREE.', 'PIXI', 'createjs', 'AudioContext', 'requestAnimationFrame', '<video', 'localStorage'):
  230. if k in t:
  231. out['web:' + k.strip('<.').lower()] += 1
  232. out.update({k: 1 for k in list(out) if k.startswith('web:')})
  233. def scan(id_):
  234. root = os.path.join(W, id_)
  235. proj = parse_json(open(os.path.join(root, 'project.json'), 'rb').read()) or {}
  236. info = {'title': proj.get('title'), 'type': (proj.get('type') or '').lower(), 'dep': proj.get('dependency'),
  237. 'file': proj.get('file'), 'rating': proj.get('contentrating')}
  238. if not info['type'] and proj.get('dependency'):
  239. info['type'] = 'preset'
  240. out = collections.Counter()
  241. dep_root = os.path.join(W, proj['dependency']) if proj.get('dependency') else None
  242. if dep_root and not os.path.isdir(dep_root):
  243. out['ERROR:missing-dependency'] += 1
  244. dep_root = None
  245. if info['type'] == 'scene' or (info['type'] == 'preset' and dep_root):
  246. if info['type'] == 'preset':
  247. out['preset'] += 1
  248. dp = parse_json(open(os.path.join(dep_root, 'project.json'), 'rb').read()) or {}
  249. info['dep_type'] = (dp.get('type') or '').lower()
  250. if info['type'] == 'scene' or info.get('dep_type') == 'scene':
  251. scan_scene(Source(root, dep_root), out, info)
  252. elif info['type'] == 'web':
  253. scan_web(root, out)
  254. elif info['type'] == 'video':
  255. f = proj.get('file') or ''
  256. out['video:' + os.path.splitext(f)[1].lower()] += 1
  257. props = (proj.get('general') or {}).get('properties') or {}
  258. info['props'] = len(props)
  259. for p in props.values():
  260. if isinstance(p, dict) and p.get('type') in ('usershortcut', 'file', 'directory', 'scenetexture', 'textinput', 'combo'):
  261. out['prop:' + p['type']] += 1
  262. if (proj.get('general') or {}).get('supportsaudioprocessing'):
  263. out['supportsaudioprocessing'] += 1
  264. if proj.get('preset'):
  265. out['has-preset-values'] += 1
  266. try:
  267. info['size_mb'] = round(sum(os.path.getsize(os.path.join(dp, f)) for dp, _, fs in os.walk(root) for f in fs) / 1e6, 1)
  268. info['mtime'] = int(max(os.path.getmtime(os.path.join(root, f)) for f in os.listdir(root)))
  269. except (OSError, ValueError):
  270. pass
  271. return info, out
  272. def main():
  273. os.makedirs(HERE, exist_ok=True)
  274. path = os.path.join(HERE, 'features.json')
  275. db = json.load(open(path)) if os.path.exists(path) else {}
  276. ids = sys.argv[1:] or sorted(x for x in os.listdir(W) if os.path.exists(os.path.join(W, x, 'project.json')))
  277. if not sys.argv[1:]:
  278. for gone in set(db) - set(ids):
  279. print('removed from library:', gone, db[gone]['info'].get('title'))
  280. del db[gone]
  281. for id_ in ids:
  282. try:
  283. info, feats = scan(id_)
  284. except Exception as e:
  285. info, feats = {'title': '?', 'type': '?'}, collections.Counter({'ERROR:scan ' + type(e).__name__: 1})
  286. if id_ not in db:
  287. print('new:', id_, info.get('title'))
  288. db[id_] = {'info': info, 'features': dict(sorted(feats.items()))}
  289. json.dump(db, open(path, 'w'), indent=1, ensure_ascii=False, sort_keys=True)
  290. index = collections.defaultdict(list)
  291. for id_, d in db.items():
  292. for k in d['features']:
  293. index[re.sub(r'\*$', '', k)].append(id_)
  294. types = collections.Counter(d['info'].get('type') for d in db.values())
  295. with open(os.path.join(HERE, 'FEATURES.md'), 'w') as f:
  296. f.write(f'# Library features (generated by scan.py, {len(db)} items: '
  297. + ', '.join(f'{v} {k}' for k, v in types.most_common()) + ')\n\n')
  298. f.write('## Feature -> items (rarest first; `fx:name*` in features.json = effect shipped by the wallpaper)\n\n')
  299. for k, v in sorted(index.items(), key=lambda kv: (len(kv[1]), kv[0])):
  300. f.write(f'- **{k}** ({len(v)}): {" ".join(sorted(v))}\n')
  301. f.write('\n## Items\n\n')
  302. for id_, d in sorted(db.items()):
  303. i = d['info']
  304. extra = ' '.join(f'{k}={i[k]}' for k in ('version', 'objs', 'size', 'dep') if i.get(k) is not None)
  305. f.write(f'- {id_} [{i.get("type")}] {i.get("title")} ({extra})\n '
  306. + ', '.join(f'{k}{"x" + str(v) if v > 1 else ""}' for k, v in d['features'].items()) + '\n')
  307. if __name__ == '__main__':
  308. main()