diff --git a/server.py b/server.py index b611c09..221db90 100644 --- a/server.py +++ b/server.py @@ -110,6 +110,11 @@ try: # the 2D paper-doll tier (PIL + numpy); 3D still except Exception as _e: DOLL = None print(f'2D doll tier off ({_e.__class__.__name__}: {_e}) — pip install pillow numpy to enable') +try: # registry edits into consumers that load from hardcoded lists + import registry as REG +except Exception as _e: + REG = None + print(f'registry edits off ({_e.__class__.__name__}: {_e})') JOBS = {} # id → {status, note, out, log} MODEL_EXT = ('.glb', '.gltf', '.fbx', '.obj') @@ -806,11 +811,22 @@ class H(BaseHTTPRequestHandler): t = EXPORT_TARGETS[tgt] if not t['ready']: # Verified against each consumer's real loader: these ingest by a HARDCODED array - # or a manifest, so dropping a file does nothing at all. Saying so beats a - # green tick over a no-op. - return self.j({'ok': False, 'ready': False, 'target': tgt, - 'why': t['why'], 'registry': t.get('registry'), - 'note': 'not a file drop — the consumer needs a registry edit first'}, 409) + # or a manifest, so dropping a file does nothing until its NAME is in that list. + # We can make that edit — but it is source surgery on a live game, so it dry-runs + # unless explicitly told to write. + if not REG: + return self.j({'ok': False, 'ready': False, 'target': tgt, 'why': t['why'], + 'registry': t.get('registry')}, 409) + nm = body.get('name') or os.path.basename(p).rsplit('.', 1)[0] + try: + res = REG.add(tgt, nm, do_write=bool(body.get('write'))) + except Exception as e: + return self.j({'error': str(e)[:300]}, 500) + return self.j({'ok': True, 'ready': False, 'registry_edit': res, + 'why': t['why'], + 'note': ('written — back up files are alongside each source' + if body.get('write') else + 'DRY RUN — re-send with write:true to actually edit the source')}) if not allowed(p) or not os.path.isfile(p): return self.j({'error': 'path outside library'}, 403) if is_nsfw(p): diff --git a/tools/registry.py b/tools/registry.py new file mode 100644 index 0000000..df407c6 --- /dev/null +++ b/tools/registry.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Registry edits — add an asset's name to a consumer's hardcoded list. + +Most GODVERSE consumers do NOT scan a directory. They load from a literal array in source +(djsim's PED_NAMES, thriftgod's HERO_FLOOR) or from a JSON manifest, so dropping a file in the +right folder achieves nothing until its name appears in that list. This module makes that edit. + +It is deliberately paranoid, because the targets are live shipping games: + · dry-run by default — you see the exact new line before anything is written + · idempotent — an entry already present is a no-op, never a duplicate + · backs up to .bak- before writing + · refuses if the anchor pattern does not match EXACTLY once, rather than guessing which + of several arrays was meant + · works on local files and, over ssh, on remote ones (djsim lives on ultra) + + registry.py show + registry.py add [--write] +""" +import json, os, re, shlex, subprocess, sys, time + +HOME = os.path.expanduser('~') + +# Every entry below was read out of the consumer's real loader, not its docs. `array` is the +# literal declaration to append into; `both` flags a consumer with TWO live copies of the same +# list, where updating one and not the other is a silent half-fix. +TARGETS = { + 'djsim-ped': { + 'host': 'johnking@100.91.239.7', + 'files': [ + {'path': 'Documents/90sDJsim/web/world/crowd/rigs.js', + 'anchor': r'(const\s+PED_NAMES\s*=\s*\{\s*normal:\s*\[)', + 'note': 'the array that actually owns the street crowd'}, + {'path': 'Documents/90sDJsim/web/world/index.html', + 'anchor': r'(const\s+PED_NAMES\s*=\s*\{\s*normal:\s*\[)', + 'note': 'second live copy — djsim keeps two'}, + ], + 'quote': "'", 'both': True, + }, + 'thriftgod-prop': { + 'host': None, + 'files': [ + {'path': 'Documents/thriftgod/web/index.html', + 'anchor': r"(const\s+HERO_FLOOR\s*=\s*\[)", + 'note': 'floor props; HERO_COUNTER is the counter-top equivalent'}, + ], + 'quote': "'", 'both': False, + }, + 'procity-ped': { + 'host': None, + 'files': [ + # citizens/, not crowd/ — procity and 90sDJsim diverged here despite sharing the + # loadPedFleet shape, and the declaration is `export const` + {'path': 'Documents/PROCITY/web/js/citizens/rigs.js', + 'anchor': r'(export\s+const\s+PED_NAMES\s*=\s*\{\s*normal:\s*\[)', + 'note': 'loadPedFleet fills fixed slots BY INDEX — appending is safe, reordering is not'}, + ], + 'quote': "'", 'both': False, + }, + 'not-tonight': { + 'host': None, 'json': True, + 'files': [{'path': 'Documents/not-tonight/public/props/manifest.json', + 'note': 'Phaser preloads only what this manifest names'}], + }, +} + + +def _close_bracket(src, open_idx): + """Index of the ']' closing the '[' at open_idx, skipping brackets inside strings.""" + depth, i, n = 0, open_idx, len(src) + quote = None + while i < n: + c = src[i] + if quote: + if c == '\\': + i += 2 + continue + if c == quote: + quote = None + elif c in '"\'': + quote = c + elif c == '[': + depth += 1 + elif c == ']': + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + +def _run(host, argv): + if host: + return subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host, + ' '.join(shlex.quote(a) for a in argv)], + capture_output=True, text=True, timeout=120) + return subprocess.run(argv, capture_output=True, text=True, timeout=120) + + +def read(host, path): + r = _run(host, ['cat', path if host else os.path.join(HOME, path)]) + if r.returncode != 0: + raise RuntimeError(f'cannot read {path}: {r.stderr.strip()[:120]}') + return r.stdout + + +def write(host, path, text): + stamp = time.strftime('%Y%m%d-%H%M%S') + full = path if host else os.path.join(HOME, path) + b = _run(host, ['cp', full, f'{full}.bak-{stamp}']) + if b.returncode != 0: + raise RuntimeError(f'backup failed for {path}: {b.stderr.strip()[:120]}') + if host: + p = subprocess.run(['ssh', '-o', 'BatchMode=yes', host, f'cat > {shlex.quote(full)}'], + input=text, capture_output=True, text=True, timeout=120) + if p.returncode != 0: + raise RuntimeError(p.stderr.strip()[:160]) + else: + open(full, 'w').write(text) + return f'{full}.bak-{stamp}' + + +def add(target, name, do_write=False): + if target not in TARGETS: + raise SystemExit(f'unknown target {target!r} (want {list(TARGETS)})') + t = TARGETS[target] + results = [] + for f in t['files']: + src = read(t['host'], f['path']) + if t.get('json'): + data = json.loads(src) + items = data if isinstance(data, list) else data.get('props', data.get('items', [])) + if name in items: + results.append({'file': f['path'], 'status': 'already present'}) + continue + items.append(name) + new = json.dumps(data, indent=2) + else: + q = t['quote'] + if re.search(re.escape(q + name + q), src): + results.append({'file': f['path'], 'status': 'already present'}) + continue + hits = list(re.finditer(f['anchor'], src)) + if len(hits) != 1: + # Guessing which array was meant is how you corrupt a shipping game. + results.append({'file': f['path'], 'status': f'ANCHOR MATCHED {len(hits)}x — refusing'}) + continue + close = _close_bracket(src, hits[0].end() - 1) + if close < 0: + results.append({'file': f['path'], 'status': 'unbalanced array — refusing'}) + continue + # APPEND, never prepend. procity's loadPedFleet fills fixed slots by PED_NAMES index, + # so inserting at the front silently reassigns every existing character. + new = src[:close] + f', {q}{name}{q}' + src[close:] + results.append({'file': f['path'], 'status': 'would add' if not do_write else 'added', + 'note': f.get('note', ''), + 'backup': write(t['host'], f['path'], new) if do_write else None}) + return {'target': target, 'name': name, 'host': t['host'] or 'local', + 'wrote': do_write, 'both_copies': t.get('both', False), 'results': results} + + +if __name__ == '__main__': + cmd = sys.argv[1] if len(sys.argv) > 1 else 'show' + if cmd == 'show': + print(json.dumps({k: {'host': v['host'] or 'local', + 'files': [f['path'] for f in v['files']]} + for k, v in TARGETS.items()}, indent=1)) + elif cmd == 'add': + print(json.dumps(add(sys.argv[2], sys.argv[3], '--write' in sys.argv), indent=1))