From 7c5fc53224284db252cfb764e7b57f553f441f13 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 24 Jul 2026 21:55:04 +1000 Subject: [PATCH] Registry writer: make the four non-file-drop consumers one-click, and keep 3GOD as a depot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most GODVERSE consumers do not scan a directory — they load from a literal array in source (djsim/procity PED_NAMES, thriftgod HERO_FLOOR) or a JSON manifest, so a dropped file does nothing until its NAME is in that list. tools/registry.py makes that edit, and the export hub now offers it instead of only reporting the file:line. Paranoid by construction, because the targets are live shipping games: · dry-run by default; write:true is required to touch source · idempotent — an existing entry is a no-op, verified against a real djsim ped · backs up to .bak- before every write · refuses if the anchor matches anything other than exactly once, rather than guessing which of several arrays was meant · handles djsim keeping TWO live copies of PED_NAMES (rigs.js owns the street, index.html has a second) — updating one and not the other is a silent half-fix · works locally and over ssh, since djsim lives on ultra Bug I introduced and caught while testing: the first implementation inserted after the opening bracket, i.e. PREPENDED. procity's loadPedFleet fills fixed slots BY PED_NAMES INDEX, so that silently reassigns every existing character — and my own note on that target said "appending is safe, reordering is not" three lines above the code that did it. Now scans to the matching close bracket (string-aware) and appends. Verified: leading entries byte-identical, new name at the tail, then reverted from backup with git clean. Also corrected procity's path — its rigs.js is under web/js/citizens/, not crowd/, and declares `export const`; the two games diverged despite sharing the loadPedFleet shape. On 3GOD: NOT retired. It looked like a spent planning experiment, but thriftgod fetches its props from it at runtime in production (index.html:1176 DEPOT = 'https://digalot.fyi/3god') and procity does the same — 773 live assets. It is two things bolted together: a dumb asset CDN (/a, /dl, /t, /api/list) which stays, and an authoring UI (upload/tag/thumb/fetch/meta) which wardrobegod now supersedes. Absorb the bench, keep the depot, publish into it. Co-Authored-By: Claude Opus 4.8 --- server.py | 26 +++++-- tools/registry.py | 168 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 tools/registry.py 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))