diff --git a/README.md b/README.md index 25e4ba7..2be90fd 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,20 @@ the mods you choose, for the player's own USB update. ```sh python cdj900nxs_patcher.py C900NXS.UPD # choose in a list -python cdj900nxs_patcher.py C900NXS.UPD --mods beatjump,cuecolours,hotcuekeys +python cdj900nxs_patcher.py C900NXS.UPD --mods cuecolours,hotcuekeys python cdj900nxs_patcher.py C900NXS.UPD --stock # the original, to go back python cdj900nxs_patcher.py --list ``` In the list: **arrow keys** move, **space** ticks or unticks, **a** ticks all -or none, **enter** builds, **q** quits. Terminals that cannot pass single +or none, **enter** builds, **q** quits. Ticking a mod ticks what it needs; +unticking one unticks what needs it (`utility` needs `code`, `hotcuekeys` +needs `cuecolours`). A mod with known faults starts unticked. + +Before anything is built the patcher shows a disclaimer that must be +accepted by typing `yes` (or with `--accept`, where no one can type): the +mods and the patcher were developed with Claude Code, Anthropic's AI coding +assistant, and are used at your own risk. Terminals that cannot pass single keys (Git Bash's mintty, a pipe) get a numbered list instead. The result goes to `out/-/C900NXS.UPD` with a `report.txt` of every check. @@ -50,9 +57,23 @@ From the emulator checkout `../CDJ900NXSVirtual` (`--repo` for another place): | `code` | arnoldasr: BPM with two decimals, two-line phase meter, RGB waveform | | `utility` | arnoldasr: UTILITY > PHASE METER switch (needs `code`) | | `overview` | arnoldasr: overview waveform colour gradient | -| `beatjump` | TRACK SEARCH jumps 1/2/4/8 beats, UTILITY > BEAT JUMP | +| `beatjump` | TRACK SEARCH jumps 1/2/4/8 beats, UTILITY > BEAT JUMP (known faults on a player, see Risk) | | `cuecolours` | memory cues in their rekordbox colours, hot cues shown | -| `hotcuekeys` | UTILITY > HOT CUE KEYS; QUANTIZE short = hot key mode | +| `hotcuekeys` | hot cues A-E on the BEAT DIVIDE keys (needs `cuecolours`), see below | + +`hotcuekeys`: UTILITY > HOT CUE KEYS ON; then QUANTIZE pressed briefly +switches the hot key mode (HOT KEYS box beside QUANTIZE), held it is +QUANTIZE. In the mode the BEAT DIVIDE keys 3/4, 1/3, 1/2, 1/4, 1/8 are hot +cues A-E: + +- press: jump to the hot cue and play (from pause too); +- hold 0.5 s or longer, then let go: back to the hot cue, standing (gate); +- an empty key held 0.8 s while paused: a hot cue there; +- DELETE held, a key pressed: its hot cue deleted (DELETE alone is DELETE); +- LEDs: off without a hot cue, dim with one, two bright blinks when pressed. + +Hot cues set or deleted live until the next track load; nothing is written +to the USB stick. As with CALL, the CUE point moves to the hot cue. arnoldasr's mods need his repository checked out in `../CDJ900NXSVirtual/mods/cdj900nxs/upstream` (it has no licence, so it is @@ -61,7 +82,10 @@ not copied here); without it they are greyed out. ## Risk Nothing here talks to a player; you copy the file to a FAT32 USB stick and run -the update yourself. Our mods have only run in the emulator. Recovery from an +the update yourself. Our mods were developed with Claude Code in an emulator. +On a player: `beatjump` jumped off the grid and, after a few jumps, reported a +corrupted file and needed a restart; it is being reworked. `cuecolours` and +earlier versions of `hotcuekeys` ran on a player. Recovery from an application that crashes at start-up has not been tested by anyone. Keep the `--stock` file at hand. diff --git a/cdj900nxs_patcher.py b/cdj900nxs_patcher.py index dcf87e1..20b14ab 100644 --- a/cdj900nxs_patcher.py +++ b/cdj900nxs_patcher.py @@ -16,6 +16,9 @@ choose, as a new C900NXS.UPD for the player's own USB update. * The new file says 1.32 in its MAIN header so that the player's updater does not skip it as the version it already has; the application itself still reports 1.31 (as arnoldasr's builds do). Only that label differs. + * Before anything is built a disclaimer must be accepted: the mods and + this patcher were developed with Claude Code (--accept where no one can + type). * Nothing here talks to a player. You copy the file to a USB stick and run the update on the player yourself, at your own risk: nothing of this has been tested on hardware except the relabelled stock update (by arnoldasr), @@ -36,8 +39,11 @@ import upd # noqa: E402 DEFAULT_REPO = os.path.join(os.path.dirname(HERE), 'CDJ900NXSVirtual') UPSTREAM_MODS = ('code', 'utility', 'overview') -NEEDS = {'utility': 'code'} # the PHASE METER switch chooses the code mod's meter +NEEDS = {'utility': 'code', # the PHASE METER switch chooses the code mod's meter + 'hotcuekeys': 'cuecolours'} # the hot cues the keys play are read by cuecolours LABEL = '1.32' +# not ticked at the start of the list, and why +KNOWN_FAULTS = {'beatjump': 'known faults on a player (jumps off the grid, "file corrupted" after a few jumps)'} def load_repo(repo): @@ -60,6 +66,8 @@ def available(patcher, repo): or os.path.join(repo, 'mods', 'cdj900nxs', 'upstream')) out = [] for name, what in patcher.MODS.items(): + if name in KNOWN_FAULTS: + what = '%s [%s]' % (what, KNOWN_FAULTS[name]) ok = upstream or name not in UPSTREAM_MODS out.append((name, what, ok, '' if ok else "needs arnoldasr's checkout in mods/cdj900nxs/upstream")) return out @@ -102,9 +110,16 @@ def _keys(): return read -def _fix_needs(chosen, mods): - for name, need in NEEDS.items(): - if name in chosen and need not in chosen and any(m[0] == need and m[2] for m in mods): +def _toggle(chosen, name, mods): + """Tick or untick one mod: ticking one ticks what it needs, unticking + one unticks what needs it (so every mod can be unticked).""" + if name in chosen: + chosen.discard(name) + chosen.difference_update(n for n, need in NEEDS.items() if need == name) + else: + chosen.add(name) + need = NEEDS.get(name) + if need and any(m[0] == need and m[2] for m in mods): chosen.add(need) @@ -115,7 +130,7 @@ def choose(mods): return choose_by_number(mods) if os.name == 'nt': os.system('') # VT sequences on in the Windows console - chosen = {m[0] for m in mods if m[2]} + chosen = {m[0] for m in mods if m[2] and m[0] not in KNOWN_FAULTS} at = 0 lines = len(mods) + 3 @@ -140,8 +155,7 @@ def choose(mods): elif k == 'down': at = (at + 1) % len(mods) elif k == 'space' and mods[at][2]: - chosen ^= {mods[at][0]} - _fix_needs(chosen, mods) + _toggle(chosen, mods[at][0], mods) elif k == 'all': usable = {m[0] for m in mods if m[2]} chosen = set() if chosen == usable else usable @@ -155,9 +169,9 @@ def choose(mods): def choose_by_number(mods): """For terminals that cannot be read key by key (mintty, pipes).""" - chosen = {m[0] for m in mods if m[2]} + chosen = {m[0] for m in mods if m[2] and m[0] not in KNOWN_FAULTS} while True: - print('\n Mods (all usable ones are ticked):') + print('\n Mods (all usable ones without known faults are ticked):') for i, (name, what, ok, why) in enumerate(mods, 1): mark = '[x]' if name in chosen else ('[ ]' if ok else ' - ') print(' %d %s %-11s %s' % (i, mark, name, what if ok else why)) @@ -168,11 +182,41 @@ def choose_by_number(mods): raise SystemExit('nothing written') for tok in answer.replace(',', ' ').split(): if tok.isdigit() and 1 <= int(tok) <= len(mods) and mods[int(tok) - 1][2]: - chosen ^= {mods[int(tok) - 1][0]} - _fix_needs(chosen, mods) + _toggle(chosen, mods[int(tok) - 1][0], mods) return [m[0] for m in mods if m[0] in chosen] +# --- the disclaimer ------------------------------------------------------------ + +DISCLAIMER = ''' + Please read before anything is built: + + The mods of this patcher (beatjump, cuecolours, hotcuekeys) and the patcher + itself were developed with Claude Code, Anthropic's AI coding assistant, + by reverse engineering the firmware in an emulator. They have not been + reviewed by Pioneer DJ / AlphaTheta and have not been tested on a player + except where the README says so. Flashing modified firmware can leave the + player unusable. You use the result at your own risk; there is no warranty. +''' + + +def confirm(accepted, ask=input): + """The disclaimer, which must be accepted before a build: by typing yes, + or with --accept where no one can type.""" + print(DISCLAIMER) + if accepted: + print(' accepted with --accept') + return + if not sys.stdin.isatty(): + raise SystemExit('nothing written: accept the disclaimer by typing yes, or with --accept') + try: + answer = ask(' Type yes to accept and go on: ').strip().lower() + except EOFError: + answer = '' + if answer not in ('yes', 'y', 'ja', 'j'): + raise SystemExit('nothing written: the disclaimer was not accepted') + + # --- building ------------------------------------------------------------------ def build(stock, names, patcher, lzss, label=LABEL, log=print): @@ -205,6 +249,8 @@ def main(argv=None): help='no mods: the original application labelled %s, to go back to stock' % LABEL) ap.add_argument('--repo', default=DEFAULT_REPO, help='the CDJ900NXSVirtual checkout (default %(default)s)') ap.add_argument('--list', action='store_true', help='list the mods and exit') + ap.add_argument('--accept', action='store_true', + help='accept the disclaimer (developed with Claude Code, at your own risk) without asking') args = ap.parse_args(argv) patcher, lzss = load_repo(args.repo) @@ -246,6 +292,7 @@ def main(argv=None): print(' no mod chosen: use --stock for the unmodified update') return 1 print(' mods: %s' % (', '.join(names) or 'none (stock application)')) + confirm(args.accept) try: out_raw, app, checks = build(stock, names, patcher, lzss) diff --git a/test_patcher.py b/test_patcher.py index 770120a..59e2338 100644 --- a/test_patcher.py +++ b/test_patcher.py @@ -93,3 +93,35 @@ def test_boot_area_tampering_caught(): bad = upd.join([sections[0].blob, upd.encode_section(main.header, recs), sections[2].blob]) with pytest.raises(upd.UpdError, match='boot loader'): upd.verify(bad, stock, stock.application) + + +MODS = [('code', '', True, ''), ('utility', '', True, ''), ('overview', '', True, ''), + ('beatjump', '', True, ''), ('cuecolours', '', True, ''), ('hotcuekeys', '', True, '')] + + +def test_every_mod_can_be_unticked(): + chosen = {m[0] for m in MODS} + cli._toggle(chosen, 'code', MODS) + assert 'code' not in chosen and 'utility' not in chosen # what needs it goes with it + cli._toggle(chosen, 'cuecolours', MODS) + assert 'cuecolours' not in chosen and 'hotcuekeys' not in chosen + cli._toggle(chosen, 'hotcuekeys', MODS) + assert {'hotcuekeys', 'cuecolours'} <= chosen # and ticking brings what it needs + cli._toggle(chosen, 'overview', MODS) + assert 'overview' not in chosen + + +def test_disclaimer_must_be_accepted(monkeypatch): + monkeypatch.setattr(cli.sys.stdin, 'isatty', lambda: True, raising=False) + assert 'Claude Code' in cli.DISCLAIMER + cli.confirm(False, ask=lambda _: 'yes') + cli.confirm(True, ask=lambda _: pytest.fail('asked although accepted')) + for answer in ('', 'no', 'q'): + with pytest.raises(SystemExit, match='not accepted'): + cli.confirm(False, ask=lambda _: answer) + + +def test_disclaimer_without_a_terminal(monkeypatch): + monkeypatch.setattr(cli.sys.stdin, 'isatty', lambda: False, raising=False) + with pytest.raises(SystemExit, match='--accept'): + cli.confirm(False)