Files
CDJ900ModsWeb/tools/make_builder_data.py
T
Thiemo TizianiandClaude Opus 5.5 3e017e2a8e Site 1.2.0: rgb_waveform is now Advanced waveforms
Renamed everywhere (builder, pages, data); the built update is byte for byte
the same. Upcoming: UTILITY > WAVEFORM (BLUE / RGB / 3BAND) in testing. The
generator builds the released state only (patch(..., public=True)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:07:27 +02:00

291 lines
13 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-or-later
"""Writes builder/data.js, the data the web builder (builder.html) applies.
usage: make_builder_data.py [--repo ../CDJ900NXSVirtual]
Our mods (beatjump, cuecolours, hotcuekeys, ledsaver, keynotation, credit) are made by the emulator
checkout's mods/patch_cdj900nxs.py for every combination the builder offers;
their bytes do not depend on which of arnoldasr's mods are chosen (checked
below). For each combination the bytes our mods change in the application
go into data.js, except what is the player's own firmware:
copy the UTILITY item table (hotcuekeys, ledsaver and keynotation extend a
copy of it)
is copied from the user's own application in the browser;
box the HOT KEYS box is the QUANTIZE label's image recoloured with
HOT KEYS written on it; the browser makes it from the user's image.
arnoldasr's mods (credited on the site), as patch_cdj900nxs.py splits them
up, are in data.js the same way: per combination of extended_bpm, phasemeter
and advanced_waveforms the bytes they change, except the asset blob at 0x08FC2D00,
which starts with a copy of the firmware's resource table: the browser makes
it from the user's application (the port of patch_cdj900nxs.assets()).
The SHA-256 of the finished application for every combination is in data.js
too; the browser checks its result against it.
Before data.js is written, the browser's way of applying the data is redone
here in Python for every combination of arnoldasr's mods and ours and
compared byte for byte with patch_cdj900nxs.py's result.
"""
import argparse
import base64
import hashlib
import itertools
import json
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
WEB = os.path.dirname(HERE)
OURS = ('beatjump', 'cuecolours', 'hotcuekeys', 'ledsaver', 'keynotation', 'credit')
NEEDS = {'hotcuekeys': 'cuecolours', 'beatjump': 'hotcuekeys'}
UT_TABLE_STOCK = 0x08C6C8DC
UT_TABLE_LEN = 17 * 52
BASE = 0x08000000
ASSETS = None
def sha(b):
return hashlib.sha256(b).hexdigest()
def valid(combo):
return all(NEEDS[m] in combo for m in combo if m in NEEDS)
def runs(base, final):
"""[(address, bytes)] where final differs from base (short equal gaps joined)."""
n = len(final)
base = base + bytes(n - len(base))
out, i = [], 0
while i < n:
if base[i] != final[i]:
j = i
while j < n and (base[j] != final[j] or (j + 16 < n and base[j:j + 16] != final[j:j + 16])):
j += 1
out.append((BASE + i, final[i:j]))
i = j
else:
i += 1
return out
def spans(idx):
"""[(lo, hi)] of the runs of consecutive numbers in idx (sorted)."""
out = []
for i in idx:
if out and out[-1][1] == i:
out[-1] = (out[-1][0], i + 1)
else:
out.append((i, i + 1))
return out
def ops_for(stock, final, boxes):
"""The browser's operations for one combination: data, copy, box."""
table = stock[UT_TABLE_STOCK - BASE:UT_TABLE_STOCK - BASE + UT_TABLE_LEN]
out = [dict(box, op='box') for box in boxes] # whole, outside the runs
masked = bytearray(final)
padded = stock + bytes(len(final) - len(stock))
for box in boxes:
off = box['addr'] - BASE
masked[off:off + box['len']] = padded[off:off + box['len']]
for addr, data in runs(stock, bytes(masked)):
# (start, end, ops) inside this run: a copy of the table, a few of its
# items changed (beatjump renames SCREEN SAVER), is the copy and then
# our bytes over it
cuts = []
k = data.find(table[:52])
while k >= 0:
seg = data[k:k + UT_TABLE_LEN]
diff = [i for i in range(len(seg)) if seg[i] != table[i]] if len(seg) == UT_TABLE_LEN else None
if diff is not None and len(diff) <= 2 * 52:
ops = [{'op': 'copy', 'addr': addr + k, 'src': UT_TABLE_STOCK, 'len': UT_TABLE_LEN}]
for lo, hi in spans(diff):
ops.append({'op': 'data', 'addr': addr + k + lo, 'data': seg[lo:hi]})
cuts.append((k, k + UT_TABLE_LEN, ops))
k = data.find(table[:52], k + UT_TABLE_LEN)
else:
k = data.find(table[:52], k + 1)
cuts.sort(key=lambda c: c[0])
pos = 0
for lo, hi, ops in cuts:
if lo > pos:
out.append({'op': 'data', 'addr': addr + pos, 'data': data[pos:lo]})
out.extend(ops)
pos = hi
if pos < len(data):
out.append({'op': 'data', 'addr': addr + pos, 'data': data[pos:]})
return out
def apply_ops(app, stock, ops, hk, size):
"""What builder.js does with the operations (redone here to check them):
the application grown with zeros to `size` first."""
out = bytearray(app) + bytes(max(0, size - len(app)))
for o in ops:
off = o['addr'] - BASE
if o['op'] == 'data':
out[off:off + len(o['data'])] = o['data']
elif o['op'] == 'copy':
out[off:off + o['len']] = stock[o['src'] - BASE:o['src'] - BASE + o['len']]
elif o['op'] == 'assets':
out[off:off + o['len']] = ASSETS(stock)[0][:o['len']]
else:
_, _, px = hk.box_pixels(stock, o['res'], o['bg'], o['ink'], o['text'])
out[off:off + o['len']] = px
return bytes(out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--repo', default=os.path.join(os.path.dirname(WEB), 'CDJ900NXSVirtual'))
args = ap.parse_args()
sys.path.insert(0, os.path.join(args.repo, 'mods'))
sys.path.insert(0, os.path.join(args.repo, 'mods', 'cdj900nxs'))
import patch_cdj900nxs as p
import hotcuekeys as hk
import cuecolours
with open(os.path.join(args.repo, 'extract', 'cdj900nxs', 'main_unpacked.bin'), 'rb') as f:
stock = f.read()[:p.APP_SIZE]
if sha(stock) != p.APP_SHA256:
raise SystemExit('not the 1.31 application')
global ASSETS
ASSETS = p.assets
_, labels = hk.routine(hk.DECODER, cuecolours.HOT_TABLE)
w, h, on = hk.box_pixels(stock, hk.RES_QUANTIZE, 0xF800, 0x0000)
boxes = [{'addr': labels['PIX_ON'], 'len': len(on), 'res': hk.RES_QUANTIZE, 'bg': 0xF800, 'ink': 0x0000,
'text': 'HOT KEYS'},
{'addr': labels['PIX_OFF'], 'len': len(on), 'res': hk.RES_QUANTIZE_OFF, 'bg': 0x0000, 'ink': 0x4208,
'text': 'HOT KEYS'},
{'addr': hk.PIX_LOAD, 'len': len(on), 'res': hk.RES_QUANTIZE, 'bg': 0xFC60, 'ink': 0x0000,
'text': 'LOADING'}]
combos = [c for n in range(1, len(OURS) + 1) for c in itertools.combinations(OURS, n) if valid(c)]
variants, chunks = {}, {}
def encode(ops):
enc = []
for o in ops:
if o['op'] == 'data':
key = sha(o['data'])[:16]
chunks[key] = base64.b64encode(o['data']).decode()
enc.append(['data', o['addr'], key])
elif o['op'] == 'copy':
enc.append(['copy', o['addr'], o['src'], o['len']])
elif o['op'] == 'assets':
enc.append(['assets', o['addr'], o['len']])
else:
enc.append(['box', o['addr'], o['res'], o['bg'], o['ink'], o['text']])
return enc
# arnoldasr's mods, split up: per combination their bytes, but the asset
# blob (it starts with a copy of the firmware's resource table) made by
# the browser from the user's own application
upstream, up_apps = {}, {(): stock}
for k in range(1, len(p.UPSTREAM) + 1):
for up in itertools.combinations(p.UPSTREAM, k):
app = p.patch(stock, list(up), public=True)
blob_len = len(app) - (p.DATA_BASE - BASE)
masked = bytearray(app)
masked[p.DATA_BASE - BASE:] = bytes(blob_len)
ops = ops_for(stock, bytes(masked), [])
ops.append({'op': 'assets', 'addr': p.DATA_BASE, 'len': blob_len})
if apply_ops(stock, stock, ops, hk, len(app)) != app:
raise SystemExit('%s: the operations do not give the patcher result' % ','.join(up))
upstream[','.join(up)] = {'size': len(app), 'ops': encode(ops)}
up_apps[up] = app
print('%-40s %3d operations' % (','.join(up), len(ops)))
for combo in combos:
final = p.patch(stock, list(combo), public=True)
ops = ops_for(stock, final, boxes if 'hotcuekeys' in combo else [])
if apply_ops(stock, stock, ops, hk, len(final)) != final:
raise SystemExit('%s: the operations do not give the patcher\'s result' % ','.join(combo))
variants[','.join(combo)] = {'size': len(final), 'ops': encode(ops)}
print('%-40s %3d operations' % (','.join(combo), len(ops)))
# the browser applies ours on top of whatever of arnoldasr's was applied:
# check every pair, and note the result's SHA-256 for the browser to check
expect = {}
for k in range(0, len(p.UPSTREAM) + 1):
for up in itertools.combinations(p.UPSTREAM, k):
base = up_apps[up]
if up:
expect[','.join(up)] = sha(base)
for combo in combos:
final = p.patch(stock, list(up) + list(combo), public=True)
alone = p.patch(stock, list(combo), public=True)
ops = ops_for(stock, alone, boxes if 'hotcuekeys' in combo else [])
if apply_ops(base, stock, ops, hk, len(alone)) != final:
raise SystemExit('%s + %s: differs from the patcher' % (','.join(up), ','.join(combo)))
expect[','.join(list(up) + list(combo))] = sha(final)
print('checked: every combination of arnoldasr and ours gives the patcher bytes')
glyphs = {c: rows for c, rows in hk.GLYPHS.items()}
data = {
'stockUpdSha256': '', # filled from the patcher's upd.py below
'stockAppSha256': p.APP_SHA256,
'appSize': p.APP_SIZE,
'dataBase': p.DATA_BASE,
'upstream': upstream,
'ours': variants,
'expect': expect,
'box': {'resTable': hk.RES_TABLE, 'width': w, 'height': h, 'glyphs': glyphs,
'gap': 3, 'space': 5},
'chunks': chunks,
}
sys.path.insert(0, os.path.join(os.path.dirname(WEB), 'CDJ900NXSPatcher'))
import upd
data['stockUpdSha256'] = upd.STOCK_UPD_SHA256
for key, b64 in chunks.items(): # no firmware in what we ship
blob = base64.b64decode(b64)
for i in range(0, max(0, len(blob) - 47), 8):
win = blob[i:i + 48]
if len(set(win)) > 2 and stock.find(win) >= 0:
raise SystemExit('chunk %s holds firmware bytes (at %d)' % (key, i))
out = os.path.join(WEB, 'builder', 'data.js')
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, 'w', encoding='utf-8', newline='\n') as f:
f.write('// Generated by tools/make_builder_data.py -- do not edit.\n')
f.write('window.BUILDER_DATA = ')
json.dump(data, f, separators=(',', ':'))
f.write(';\n')
print('wrote %s (%d bytes, %d + %d variants, %d chunks)' % (out, os.path.getsize(out), len(upstream), len(variants), len(chunks)))
write_versions(p)
def write_versions(p):
"""assets/versions.js: the site's version and changelog (tools/changelog.json)
and every mod's version as patch_cdj900nxs.VERSIONS has it."""
with open(os.path.join(HERE, 'changelog.json'), encoding='utf-8') as f:
log = json.load(f)
data = {'site': log['site'][0]['version'], 'date': log['site'][0]['date'], 'log': log['site'],
'mods': dict(p.VERSIONS), 'modlog': log.get('mods', {})}
out = os.path.join(WEB, 'assets', 'versions.js')
with open(out, 'w', encoding='utf-8', newline='\n') as f:
f.write("// Generated by tools/make_builder_data.py from tools/changelog.json and the mods' versions.\n")
f.write('window.CDJ_VERSIONS = ' + json.dumps(data, indent=1, ensure_ascii=False) + ';\n')
print('wrote %s (site %s)' % (out, data['site']))
# the pages load the site's own CSS and scripts with ?v=<site version>, so
# a browser takes the new ones after an update
import re
for name in sorted(os.listdir(WEB)):
if not name.endswith('.html'):
continue
path = os.path.join(WEB, name)
with open(path, encoding='utf-8') as f:
html = f.read()
new = re.sub(r'(href|src)="((?:style\.css|script\.js|assets/versions\.js|builder/[a-z]+\.js))(\?v=[^"]*)?"',
lambda m: '%s="%s?v=%s"' % (m.group(1), m.group(2), data['site']), html)
if new != html:
with open(path, 'w', encoding='utf-8', newline='\n') as f:
f.write(new)
print('stamped %s with ?v=%s' % (name, data['site']))
if __name__ == '__main__':
main()