Patcher: disclaimer, every mod can be unticked, hot cue docs

- A disclaimer (mods and patcher developed with Claude Code, at your own
  risk) must be accepted by typing yes, or with --accept, before a build.
- Unticking a mod unticks what needs it instead of ticking it back, so
  code can be unticked; hotcuekeys needs cuecolours.
- beatjump starts unticked and is marked with its faults on a player.
- README: the hot cue keys (play, gate, set, delete, LEDs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Thiemo TizianiandClaude Opus 5.5 committed 2026-10-18 01:22:01 +02:00
1 parent cfa0e85854
commit bbe784aad2
3 files changed
+119 -16

No files matched your search

+29 -5
View File
@@ -5,13 +5,20 @@ the mods you choose, for the player's own USB update.
```sh ```sh
python cdj900nxs_patcher.py C900NXS.UPD # choose in a list 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 C900NXS.UPD --stock # the original, to go back
python cdj900nxs_patcher.py --list python cdj900nxs_patcher.py --list
``` ```
In the list: **arrow keys** move, **space** ticks or unticks, **a** ticks all 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 keys (Git Bash's mintty, a pipe) get a numbered list instead. The result goes
to `out/<date>-<mods>/C900NXS.UPD` with a `report.txt` of every check. to `out/<date>-<mods>/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 | | `code` | arnoldasr: BPM with two decimals, two-line phase meter, RGB waveform |
| `utility` | arnoldasr: UTILITY > PHASE METER switch (needs `code`) | | `utility` | arnoldasr: UTILITY > PHASE METER switch (needs `code`) |
| `overview` | arnoldasr: overview waveform colour gradient | | `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 | | `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 arnoldasr's mods need his repository checked out in
`../CDJ900NXSVirtual/mods/cdj900nxs/upstream` (it has no licence, so it is `../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 ## Risk
Nothing here talks to a player; you copy the file to a FAT32 USB stick and run 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 application that crashes at start-up has not been tested by anyone. Keep the
`--stock` file at hand. `--stock` file at hand.
+58 -11
View File
@@ -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 * 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 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. 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 * 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 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), 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') DEFAULT_REPO = os.path.join(os.path.dirname(HERE), 'CDJ900NXSVirtual')
UPSTREAM_MODS = ('code', 'utility', 'overview') 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' 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): def load_repo(repo):
@@ -60,6 +66,8 @@ def available(patcher, repo):
or os.path.join(repo, 'mods', 'cdj900nxs', 'upstream')) or os.path.join(repo, 'mods', 'cdj900nxs', 'upstream'))
out = [] out = []
for name, what in patcher.MODS.items(): 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 ok = upstream or name not in UPSTREAM_MODS
out.append((name, what, ok, '' if ok else "needs arnoldasr's checkout in mods/cdj900nxs/upstream")) out.append((name, what, ok, '' if ok else "needs arnoldasr's checkout in mods/cdj900nxs/upstream"))
return out return out
@@ -102,9 +110,16 @@ def _keys():
return read return read
def _fix_needs(chosen, mods): def _toggle(chosen, name, mods):
for name, need in NEEDS.items(): """Tick or untick one mod: ticking one ticks what it needs, unticking
if name in chosen and need not in chosen and any(m[0] == need and m[2] for m in mods): 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) chosen.add(need)
@@ -115,7 +130,7 @@ def choose(mods):
return choose_by_number(mods) return choose_by_number(mods)
if os.name == 'nt': if os.name == 'nt':
os.system('') # VT sequences on in the Windows console 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 at = 0
lines = len(mods) + 3 lines = len(mods) + 3
@@ -140,8 +155,7 @@ def choose(mods):
elif k == 'down': elif k == 'down':
at = (at + 1) % len(mods) at = (at + 1) % len(mods)
elif k == 'space' and mods[at][2]: elif k == 'space' and mods[at][2]:
chosen ^= {mods[at][0]} _toggle(chosen, mods[at][0], mods)
_fix_needs(chosen, mods)
elif k == 'all': elif k == 'all':
usable = {m[0] for m in mods if m[2]} usable = {m[0] for m in mods if m[2]}
chosen = set() if chosen == usable else usable chosen = set() if chosen == usable else usable
@@ -155,9 +169,9 @@ def choose(mods):
def choose_by_number(mods): def choose_by_number(mods):
"""For terminals that cannot be read key by key (mintty, pipes).""" """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: 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): for i, (name, what, ok, why) in enumerate(mods, 1):
mark = '[x]' if name in chosen else ('[ ]' if ok else ' - ') mark = '[x]' if name in chosen else ('[ ]' if ok else ' - ')
print(' %d %s %-11s %s' % (i, mark, name, what if ok else why)) 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') raise SystemExit('nothing written')
for tok in answer.replace(',', ' ').split(): for tok in answer.replace(',', ' ').split():
if tok.isdigit() and 1 <= int(tok) <= len(mods) and mods[int(tok) - 1][2]: if tok.isdigit() and 1 <= int(tok) <= len(mods) and mods[int(tok) - 1][2]:
chosen ^= {mods[int(tok) - 1][0]} _toggle(chosen, mods[int(tok) - 1][0], mods)
_fix_needs(chosen, mods)
return [m[0] for m in mods if m[0] in chosen] 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 ------------------------------------------------------------------ # --- building ------------------------------------------------------------------
def build(stock, names, patcher, lzss, label=LABEL, log=print): 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) 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('--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('--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) args = ap.parse_args(argv)
patcher, lzss = load_repo(args.repo) patcher, lzss = load_repo(args.repo)
@@ -246,6 +292,7 @@ def main(argv=None):
print(' no mod chosen: use --stock for the unmodified update') print(' no mod chosen: use --stock for the unmodified update')
return 1 return 1
print(' mods: %s' % (', '.join(names) or 'none (stock application)')) print(' mods: %s' % (', '.join(names) or 'none (stock application)'))
confirm(args.accept)
try: try:
out_raw, app, checks = build(stock, names, patcher, lzss) out_raw, app, checks = build(stock, names, patcher, lzss)
+32
View File
@@ -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]) bad = upd.join([sections[0].blob, upd.encode_section(main.header, recs), sections[2].blob])
with pytest.raises(upd.UpdError, match='boot loader'): with pytest.raises(upd.UpdError, match='boot loader'):
upd.verify(bad, stock, stock.application) 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)