This commit is contained in:
Nathan Rashleigh committed 2026-06-08 23:22:16 +10:00
1 parent 6a6553077b
commit f71a2a3512
23 files changed
+7773 -68

No files matched your search

+3 -1
View File
@@ -23,4 +23,6 @@ etc/sldl-macos.yaml
__pycache__/
*.py[cod]
.venv/
.venv/
maple-py/captures
maple-py/node_modules
+14
View File
@@ -0,0 +1,14 @@
{
"mcpServers": {
"ghidra": {
"command": "uv",
"args": [
"run",
"--script",
"/mnt/c/Users/ntr/Downloads/GhidraMCP-release-1-4/GhidraMCP-release-1-4/bridge_mcp_ghidra.py",
"--ghidra-server",
"http://127.0.0.1:8080/"
]
}
}
}
+8
View File
@@ -46,6 +46,14 @@ usb-reset dst="/Volumes/MAPLE":
COPYFILE_DISABLE=1 rsync -rtvh --no-perms --no-owner --no-group --modify-window=2 maple-py/data/MAPLE/Contents/ {{dst}}/Contents/
COPYFILE_DISABLE=1 rsync -rtvh --no-perms --no-owner --no-group --modify-window=2 maple-py/data/MAPLE/PIONEER/ {{dst}}/PIONEER/
# Build the maple AceMo export.pdb and sync it onto the Windows USB (drive F: by
# default) over rekordbox's export.pdb, so it can be tested on the player against
# the audio/USBANLZ already on the stick. Runs from WSL via powershell.exe.
usb-win dst="/mnt/f":
cd maple-py && uv run python tools/build_acemo.py
@test -d {{dst}}/PIONEER/rekordbox || (echo "no {{dst}}/PIONEER/rekordbox - is the USB mounted at {{dst}}?"; exit 1)
rsync -tvh --no-perms --no-owner --no-group maple-py/captures/maple_acemo.pdb {{dst}}/PIONEER/rekordbox/export.pdb
maple-setup:
bash ./etc/maple_setup.sh
+112
View File
@@ -0,0 +1,112 @@
# maple-py — rekordbox `export.pdb` writer + reverse-engineering notes
Python tooling to generate a rekordbox USB export (`PIONEER/rekordbox/export.pdb`
+ `exportExt.pdb` + `USBANLZ` analysis files) so a CDJ/XDJ can read a
self-assembled music library. The writer lives in `src/maple/pdb/`.
This README also documents what we learned reverse-engineering rekordbox's own
exporter, because the on-disk format has real unknowns / magic numbers that the
public kaitai schema (`src/kaitai/rekordbox_pdb.py`) only partially decodes.
## The format, briefly
`export.pdb` is a **DeviceSQL** database: a flat file of 4096-byte **pages**,
grouped into 20 **tables** (tracks, artists, albums, playlists, artwork,
colors, columns, history, and several still-unknown ones). Page 0 is the file
header + table directory; each table is a linked list of pages. See
`src/maple/pdb/{writer,pager,rows,strings}.py`.
## Reverse-engineering findings (rekordbox 7.0.5, Windows)
### Where the export code lives
- The exporter is in **`rekordbox.exe`** (the 89 MB main binary), **not**
`rekordboxAgent.exe`. Confirmed by string scan: only `rekordbox.exe` contains
`USBANLZ`, `PIONEER`, `djmd…` table names, `.pdb`. It's a JUCE C++ app.
- `export.pdb` is written by rekordbox's **bundled DeviceSQL "Page Manager"**
engine — functions prefixed `dsql*` / `edb_dyn_*` (error strings:
`dsqlOpenDeviceDB`, `edb_dyn_CriticalSectionOn ERROR`,
`Page Manager (Close/)Open returns ERROR`).
- **Key consequence:** the page-level "magic numbers" maple guesses
(page flags `0x64`/`0x44`, `0x1fff` markers, `0x3ec`, free/used sizes) are
**DeviceSQL internals**, not rekordbox-specific. They are NOT immediate
constants in the code — they're produced by the page-manager logic, so they
must be read out of the `dsql*`/`edb_dyn_*` functions, not grep'd.
### A rekordbox USB export writes three databases
| file | format | writer |
|------|--------|--------|
| `export.pdb` | DeviceSQL (CDJ reads this) | DeviceSQL Page Manager |
| `exportExt.pdb` | DeviceSQL extended | same |
| `exportLibrary.db` | **SQLite** (rekordbox's own) | bundled `sqlite3_*` |
| `USBANLZ/**/ANLZ*.DAT/.EXT/.2EX` | per-track waveform/beatgrid | main app |
### `export.pdb` serializer call stack (live `WriteFile` backtrace → Ghidra)
Ghidra static address = `0x140000000 + module_offset` (Ghidra image base is
`0x140000000`; the ASLR runtime base varies per launch — never hardcode it,
read `module+offset` from Frida).
```
0x1420e8804 calls WriteFile ─┐
0x1420d7358 │ DeviceSQL write plumbing
0x1420d6e74 │ (transactions, page I/O)
0x1420d62a4 │
0x1420eac94 │
0x1420e07d8 │
0x1420de140 ─┘
FUN_141d75c20 ← dsqlOpenDeviceDB / Page Manager (export.pdb-specific)
0x141cad100
0x141cac390
0x141b8b4c0
0x141aca850
0x1423c9ab0 ← outer export driver
```
Other anchors found by string xref: `FUN_14119d2d0` (export orchestrator,
builds `/PIONEER/rekordbox/export.pdb`, walks "Contents"), `FUN_140f37e50`
(builds `PIONEER`/`USBANLZ` paths).
### Gotchas
- **Incremental writes:** rekordbox rewrites only the *changed* pages (seeked
`WriteFile`s), so a Frida capture is partial. The complete file is on the USB
(`F:\PIONEER\rekordbox\export.pdb`) — copy it from there for a full diff.
- **CIG / injection:** `rekordboxAgent.exe` refuses live Frida injection
(Code Integrity Guard). The pdb writer (main `rekordbox.exe`) is hookable, but
only via **child-gating at spawn** — Windows has **no spawn-gating**.
## Tooling
### Static analysis (Ghidra, headless, in WSL)
- JDK 21 (`~/jdk/jdk-21.0.11+10`), Ghidra 12.1.2. Ghidra rejects JDK 25 — use 21.
- Analyzed project: `~/ghidra_proj` (project `rbx`, program `rekordbox.exe`).
Full analysis ≈ 1 h with `GHIDRA_HEADLESS_MAXMEM=10G -max-cpu 8`.
- Re-run a script without re-analyzing:
```bash
"$GHIDRA/support/analyzeHeadless" ~/ghidra_proj rbx -process rekordbox.exe \
-noanalysis -scriptPath ~/ghidra_scripts -postScript FindPdb.java
```
Scripts: `~/ghidra_scripts/FindPdb.java` (string+constant xref search),
`DecPdb.java` (decompile an address list), `CallTree.java` (call-tree drill).
### Dynamic capture (Frida, on Windows)
- `tools/rbx_pdb_capture.js` — Frida agent. Hooks `NtCreateFile`/`NtWriteFile`/
`NtClose` + `NtCreateSection`/`NtMapViewOfSection` (memory-mapped writes).
Reconstructs files under `PIONEER`/`*.pdb` to `captures/`, prints each write's
backtrace as `rekordbox.exe+0xOFFSET` (→ Ghidra `0x140000000 + offset`).
- `tools/capture.mjs` — Node host (`npm i frida`). Attaches to all rekordbox
processes, streams `[pid]`-tagged output live, stays alive, **child-gates** so
the spawn writer is hooked at birth. Clears `captures/` on start.
```powershell
npm i frida # once
node tools\capture.mjs --spawn # launch rekordbox under the gate, then Export to USB
```
## Diffing maple's output vs rekordbox
`compare_pdb.py` parses two pdbs with the kaitai schema and summarises tables/
rows. Reference real exports: `data/PIONEER/rekordbox/export.pdb`,
`data/acemo/PIONEER/rekordbox/export.pdb`.
## Status / next steps
- Writer fully located; capture + diff pipeline works.
- To decode a specific page magic number: search the `dsql*`/`edb_dyn_*`
functions (image region `0x142000000+`) for the page-header builder.
- To fix a rejected export: diff a complete USB `export.pdb` against maple's
output field-by-field; resolve any wrong value via the matching `dsql` function.
Generated Executable
+501
View File
@@ -0,0 +1,501 @@
{
"name": "maple-py",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"frida": "^17.11.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/frida": {
"version": "17.11.0",
"resolved": "https://registry.npmjs.org/frida/-/frida-17.11.0.tgz",
"integrity": "sha512-ZymNV1mr8lWMP2vo1Ed4D7w1/8VQRVeoZ439sCZN/vKjj/E0UhfQP4BYsRAUrCQZ4nBzfRNN4BouA+WvOZ1z5w==",
"hasInstallScript": true,
"license": "LGPL-2.0 WITH WxWindows-exception-3.1",
"dependencies": {
"bindings": "^1.5.0",
"minimatch": "^10.0.1",
"prebuild-install": "^7.1.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"frida": "^17.11.0"
}
}
+54
View File
@@ -0,0 +1,54 @@
# re/ — rekordbox `export.pdb` reverse-engineering
Shared record of the Ghidra/Frida work behind maple's pdb writer. See the
top-level `README.md` for the high-level findings; this dir holds the raw
artifacts so others can reproduce and extend the analysis.
## What's here
`scripts/` — Ghidra headless scripts (run against an analyzed `rekordbox.exe`):
- `FindPdb.java` — locate the writer via string + magic-constant xrefs
- `DecPdb.java` — decompile an explicit list of addresses
- `CallTree.java` — walk a call tree, flag file-I/O / 4096-using functions
- `FindHash.java` — hunt the per-track hash: decompile `formatForSearch` + callers + content-string xrefs
`findings/` — script output (decompiled C + xref maps):
- `findpdb.txt` — string xrefs → the export functions; magic-constant hit counts
- `decpdb.txt` — decompiled `export.pdb` serializer call stack (DeviceSQL Page Manager)
- `calltree.txt` — call tree under the export orchestrator `FUN_14119d2d0`
- `hash.txt` — `formatForSearch` + callers + `djmdContent`/`ContentID` xrefs
## Reproduce (not committed: the binary + Ghidra project)
`rekordbox.exe` and the analyzed Ghidra project are **not** in the repo
(copyright + size). To rebuild from your own rekordbox install (Windows path
visible from WSL):
```bash
export JAVA_HOME=~/jdk/jdk-21.0.11+10 # Ghidra needs JDK 21
GH=/path/to/ghidra_12.1.2_PUBLIC
# one-time: import + analyze (~1h, 10G heap, 8 cores)
GHIDRA_HEADLESS_MAXMEM=10G "$GH/support/analyzeHeadless" ~/ghidra_proj rbx \
-import "/mnt/c/Program Files/rekordbox/rekordbox 7.0.5/rekordbox.exe" \
-overwrite -max-cpu 8 -analysisTimeoutPerFile 7200
# then run any script without re-analyzing:
"$GH/support/analyzeHeadless" ~/ghidra_proj rbx -process rekordbox.exe -noanalysis \
-scriptPath re/scripts -postScript FindPdb.java
```
Runtime→Ghidra address map: `static = 0x140000000 + module_offset` (Ghidra image
base `0x140000000`; ASLR runtime base varies — read `rekordbox.exe+0xOFFSET` from
the Frida agent, never hardcode it).
## Live capture (Frida, Windows)
`../tools/capture.mjs` (Node, `npm i frida`) + `../tools/rbx_pdb_capture.js`
(agent). Child-gates so the CIG-protected writer is hooked at spawn:
`node tools/capture.mjs --spawn`.
## Open questions
- per-track `0x14` (28-bit) — varies per track, not a plain crc32; likely a
stored `djmdContent` value. `0x18`/`0x1a` are a per-export constant.
- `index_shift` (row `0x02`) — positional (`~0x20`/row, resets per page) but the
exact formula isn't pinned. maple writes `0`; rekordbox doesn't.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
// Walk the call tree down from the export orchestrator and surface the page
// serializer: flag functions that call file-I/O APIs or use the 4096 page size,
// then decompile those candidates. Output -> ~/ghidra_proj/calltree.txt
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.listing.*;
import ghidra.program.model.scalar.Scalar;
public class CallTree extends GhidraScript {
static final String ROOT = "0x14119d2d0";
static final int MAX_DEPTH = 4;
boolean hasPageConst(Function f) {
InstructionIterator it = currentProgram.getListing().getInstructions(f.getBody(), true);
while (it.hasNext()) {
Instruction insn = it.next();
for (int op = 0; op < insn.getNumOperands(); op++)
for (Object o : insn.getOpObjects(op))
if (o instanceof Scalar) {
long v = ((Scalar) o).getUnsignedValue();
if (v == 0x1000L || v == 4096L) return true;
}
}
return false;
}
String ioCalls(Function f) {
StringBuilder sb = new StringBuilder();
for (Function c : f.getCalledFunctions(monitor)) {
String n = c.getName().toLowerCase();
if (n.contains("write") || n.contains("createfile") || n.contains("fopen")
|| n.contains("fwrite") || n.contains("mapviewof") || n.contains("setfilepointer")
|| n.contains("flushview") || n.contains("fclose"))
sb.append(c.getName()).append(" ");
}
return sb.toString();
}
@Override
public void run() throws Exception {
PrintWriter out = new PrintWriter("/home/ntr/ghidra_proj/calltree.txt");
FunctionManager fm = currentProgram.getFunctionManager();
Function root = fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(ROOT));
Map<Function, Integer> depth = new LinkedHashMap<>();
Deque<Function> q = new ArrayDeque<>();
depth.put(root, 0); q.add(root);
while (!q.isEmpty() && !monitor.isCancelled()) {
Function f = q.poll();
int d = depth.get(f);
if (d >= MAX_DEPTH) continue;
for (Function c : f.getCalledFunctions(monitor))
if (!depth.containsKey(c)) { depth.put(c, d + 1); q.add(c); }
}
out.println("call tree from " + root.getName() + " depth<=" + MAX_DEPTH + ": " + depth.size() + " funcs\n");
List<Function> candidates = new ArrayList<>();
for (Map.Entry<Function, Integer> e : depth.entrySet()) {
Function f = e.getKey();
boolean page = hasPageConst(f);
String io = ioCalls(f);
String flags = (page ? "[4096]" : "") + (io.isEmpty() ? "" : "[io:" + io.trim() + "]");
out.println(String.format("d%d %-12s size=%-6d %s %s",
e.getValue(), f.getEntryPoint(), f.getBody().getNumAddresses(), f.getName(), flags));
if (page || !io.isEmpty()) candidates.add(f);
}
DecompInterface di = new DecompInterface();
di.openProgram(currentProgram);
out.println("\n===== CANDIDATE DECOMPILES (" + candidates.size() + ") =====");
for (Function f : candidates) {
if (monitor.isCancelled()) break;
out.println("\n//===== " + f.getName() + " @ " + f.getEntryPoint() + " =====");
DecompileResults r = di.decompileFunction(f, 60, monitor);
if (r != null && r.decompileCompleted())
out.println(r.getDecompiledFunction().getC());
else
out.println("// decompile failed");
}
out.flush(); out.close();
println("wrote calltree.txt: " + depth.size() + " funcs, " + candidates.size() + " candidates");
}
}
+44
View File
@@ -0,0 +1,44 @@
// Decompile the pdb export functions found via string xrefs.
import java.io.PrintWriter;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
public class DecPdb extends GhidraScript {
// export.pdb serializer call stack from a live Frida WriteFile backtrace.
// module+offset -> Ghidra static = 0x140000000 + offset (base confirmed).
static final String[] ADDRS = {
"0x1420e883e", // calls WriteFile
"0x1420d73a6",
"0x1420d6e92",
"0x1420d6368",
"0x1420eae07",
"0x1420e0852",
"0x1420de1c4",
"0x141d75d21", // export.pdb-specific frame
"0x141cad188",
"0x141cac585",
"0x141b8ba75",
"0x141acaf8c",
"0x1423c9b3b",
};
@Override public void run() throws Exception {
PrintWriter out = new PrintWriter("/home/ntr/ghidra_proj/decpdb.txt");
DecompInterface di = new DecompInterface();
di.openProgram(currentProgram);
for (String a : ADDRS) {
Address addr = currentProgram.getAddressFactory().getAddress(a);
Function f = currentProgram.getFunctionManager().getFunctionContaining(addr);
if (f == null) { out.println("// no function at " + a); continue; }
out.println("\n//================ " + f.getName() + " @ " + f.getEntryPoint() + " ================");
DecompileResults r = di.decompileFunction(f, 90, monitor);
if (r != null && r.decompileCompleted())
out.println(r.getDecompiledFunction().getC());
else
out.println("// decompile failed: " + (r != null ? r.getErrorMessage() : "null"));
}
out.flush(); out.close();
println("wrote decpdb.txt");
}
}
+78
View File
@@ -0,0 +1,78 @@
// Hunt for the track-row builder / the per-track 8-byte hash (row offsets
// 0x14 u4, 0x18 u2, 0x1a u2) and index_shift (0x02). Decompiles the string-hash
// function formatForSearch + its callers, and dumps xrefs of track/content/
// search-related strings. Output -> ~/ghidra_proj/hash.txt
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.app.decompiler.*;
import ghidra.program.model.listing.*;
import ghidra.program.model.symbol.*;
public class FindHash extends GhidraScript {
PrintWriter out;
DecompInterface di;
Set<String> done = new HashSet<>();
FunctionManager fm;
ReferenceManager rm;
void dec(Function f, String why) {
if (f == null) return;
if (!done.add(f.getEntryPoint().toString())) return;
out.println("\n//===== " + f.getName() + " @ " + f.getEntryPoint() + " [" + why + "] =====");
DecompileResults r = di.decompileFunction(f, 60, monitor);
out.println(r != null && r.decompileCompleted() ? r.getDecompiledFunction().getC() : "// decompile failed");
}
Function fn(String a) {
return fm.getFunctionContaining(currentProgram.getAddressFactory().getAddress(a));
}
@Override
public void run() throws Exception {
out = new PrintWriter("/home/ntr/ghidra_proj/hash.txt");
di = new DecompInterface(); di.openProgram(currentProgram);
fm = currentProgram.getFunctionManager();
rm = currentProgram.getReferenceManager();
// 1. formatForSearch (string hash) + its callers
Function fmt = fn("0x141a1afc0");
dec(fmt, "formatForSearch");
if (fmt != null) {
int n = 0;
for (Reference ref : rm.getReferencesTo(fmt.getEntryPoint())) {
if (n++ >= 10) break;
dec(fm.getFunctionContaining(ref.getFromAddress()), "caller of formatForSearch");
}
}
// 2. strings that hint at the track/content row builder -> xref functions
String[] kw = {"djmdContent", "SearchStr", "formatForSearch", "AnalysisDataPath",
"ContentID", "rb_data_status", "rb_local", "dsqlInsert", "AddTrack"};
DataIterator dit = currentProgram.getListing().getDefinedData(true);
int shown = 0;
while (dit.hasNext() && !monitor.isCancelled()) {
Data d = dit.next();
if (d == null || !d.hasStringValue()) continue;
Object v = d.getValue();
if (!(v instanceof String)) continue;
String s = (String) v;
for (String k : kw) {
if (s.contains(k)) {
out.println("\n// STRING @" + d.getAddress() + " : " + s);
int n = 0;
for (Reference ref : rm.getReferencesTo(d.getAddress())) {
if (n++ >= 5) break;
Function c = fm.getFunctionContaining(ref.getFromAddress());
out.println("// xref in " + (c != null ? c.getName() + " @" + c.getEntryPoint() : ref.getFromAddress()));
}
shown++;
break;
}
}
if (shown >= 30) break;
}
out.flush(); out.close();
println("wrote hash.txt");
}
}
+96
View File
@@ -0,0 +1,96 @@
// Locate the rekordbox .pdb serializer via known strings + magic-number constants.
// Run headless: analyzeHeadless <proj> rbx -process rekordbox.exe -noanalysis \
// -scriptPath ~/ghidra_scripts -postScript FindPdb.java
// Output goes to ~/ghidra_proj/findpdb.txt (and the log).
import java.io.PrintWriter;
import java.util.*;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.*;
import ghidra.program.model.scalar.Scalar;
import ghidra.program.model.symbol.*;
public class FindPdb extends GhidraScript {
// Strings that mark the export code paths.
static final String[] KEYWORDS = {
"export.pdb", "exportExt.pdb", "USBANLZ", "PIONEER",
"ANLZ", "HOT CUE BANK", "ORIGINAL ARTIST", "rekordbox/export"
};
// Magic numbers maple currently guesses (various widths/combinations).
static final long[] MAGICS = {
42343L, 54995L, // 0xA567 / 0xD6D3 per-track markers
0xD6D3A567L, 0xA567D6D3L, // combined 32-bit forms
788224L, // 0x000C0700 track bitmask
1004L, // 0x03EC "strange page" marker
0x64L, 0x44L, 0x24L, // page-flag bytes
19048L, 30967L // the schema author's original guesses
};
PrintWriter out;
void emit(String s) { println(s); if (out != null) out.println(s); }
@Override
public void run() throws Exception {
out = new PrintWriter("/home/ntr/ghidra_proj/findpdb.txt");
Listing listing = currentProgram.getListing();
ReferenceManager rm = currentProgram.getReferenceManager();
FunctionManager fm = currentProgram.getFunctionManager();
emit("===== STRING XREFS =====");
DataIterator di = listing.getDefinedData(true);
while (di.hasNext() && !monitor.isCancelled()) {
Data d = di.next();
if (d == null || !d.hasStringValue()) continue;
Object val = d.getValue();
if (!(val instanceof String)) continue;
String s = (String) val;
for (String kw : KEYWORDS) {
if (s.contains(kw)) {
emit("\nSTRING @ " + d.getAddress() + " : " + s.replace("\n", "\\n"));
ReferenceIterator ri = rm.getReferencesTo(d.getAddress());
int n = 0;
while (ri.hasNext() && n++ < 25) {
Reference r = ri.next();
Function f = fm.getFunctionContaining(r.getFromAddress());
emit(" xref from " + r.getFromAddress()
+ (f != null ? " in " + f.getName() + " @ " + f.getEntryPoint() : " (no func)"));
}
break;
}
}
}
emit("\n===== CONSTANT XREFS =====");
Set<Long> targets = new HashSet<>();
for (long m : MAGICS) targets.add(m);
Map<Long, Integer> counts = new TreeMap<>();
InstructionIterator ii = listing.getInstructions(true);
while (ii.hasNext() && !monitor.isCancelled()) {
Instruction insn = ii.next();
for (int op = 0; op < insn.getNumOperands(); op++) {
for (Object o : insn.getOpObjects(op)) {
if (o instanceof Scalar) {
long v = ((Scalar) o).getUnsignedValue();
if (targets.contains(v)) {
Function f = fm.getFunctionContaining(insn.getAddress());
emit(String.format("CONST 0x%X (%d) @ %s %s in %s", v, v,
insn.getAddress(), insn.toString(),
f != null ? f.getName() + " @ " + f.getEntryPoint() : "(no func)"));
counts.merge(v, 1, Integer::sum);
}
}
}
}
}
emit("\n===== CONSTANT HIT COUNTS =====");
for (Map.Entry<Long, Integer> e : counts.entrySet())
emit(String.format("0x%X (%d): %d hits", e.getKey(), e.getKey(), e.getValue()));
out.flush();
out.close();
emit("\nwrote /home/ntr/ghidra_proj/findpdb.txt");
}
}
+8
View File
@@ -28,6 +28,13 @@ class Color:
name: str
@dataclass
class Key:
id: int
name: str
id2: int | None = None # mirrors id in real exports
@dataclass
class Track:
id: int
@@ -164,3 +171,4 @@ class Database:
artwork: list[Artwork] = field(default_factory=list)
genres: list[Genre] = field(default_factory=list)
colors: list[Color] = field(default_factory=list)
keys: list[Key] = field(default_factory=list)
+7 -6
View File
@@ -41,11 +41,12 @@ def build_strange_page(page_index: int, page_type: int, next_page: int) -> bytes
class PageBuilder:
def __init__(self, page_index: int, page_type: int, max_rows: int = 0):
def __init__(self, page_index: int, page_type: int, max_rows: int = 0, data_flag: int = 0x24):
self.page_index = page_index
self.page_type = page_type
self.next_page = page_index # caller patches before finalize
self.max_rows = max_rows
self.data_flag = data_flag # page_flags for a populated data page
self.row_bytes: list[bytes] = []
self.row_offsets: list[int] = [] # relative to heap_pos
self.heap_cursor = 0 # position relative to heap_pos
@@ -76,11 +77,11 @@ class PageBuilder:
if free_size < 0:
raise ValueError("page overflow")
# 0x00 for data pages with rows, 0x44 for empty ones. The maple-rs
# writer uses this pair and produces pdbs that load on the player;
# rekordbox-exported references use 0x24 which also works, so both
# are valid -- pick the simpler form.
page_flags = 0x00 if n > 0 else 0x44
# Data pages with rows: rekordbox uses 0x24 (and 0x34 for some tables --
# see writer's PAGE_FLAG_34). maple previously wrote 0x00, which is
# missing the 0x20|0x04 bits every real rekordbox data page sets, and
# the players reject it. Empty data pages stay 0x44.
page_flags = self.data_flag if n > 0 else 0x44
header = b""
header += pack("<I", 0) # magic
+7
View File
@@ -169,6 +169,13 @@ def serialize_genre(g: Genre) -> bytes:
return _pad2(row)
def serialize_key(key_id: int, name: str, id2: int | None = None) -> bytes:
"""key_row: u4 id, u4 id2 (mirrors id in the reference), devicesql name."""
if id2 is None:
id2 = key_id
return _pad2(pack("<II", key_id, id2) + encode_devicesql(name))
def serialize_history_playlist(playlist_id: int, name: str) -> bytes:
"""history_playlist_row: u4 id, devicesql name."""
return _pad2(pack("<I", playlist_id) + encode_devicesql(name))
+57 -59
View File
@@ -18,8 +18,7 @@ from maple.pdb.rows import (
serialize_column,
serialize_genre,
serialize_history_blob,
serialize_history_entry,
serialize_history_playlist,
serialize_key,
serialize_playlist_entry,
serialize_playlist_tree,
serialize_track,
@@ -52,48 +51,19 @@ FILE_HEADER_SIZE = 28 # 0,page_size,num_tables,next_unused,unknown,sequence,gap
TABLE_ENTRY_SIZE = 16 # page_type,empty_candidate,first_page,last_page = 4*u4
def _build_chain(page_type: int, row_iter, start_index: int) -> tuple[list[bytes], int, int]:
"""Return (page_bytes_list, first_page, last_page).
# Page types whose populated data pages rekordbox tags 0x34 (vs the 0x24 the
# rest get). Matched byte-for-byte against a real rekordbox export.
PAGE_FLAG_34 = frozenset({0, 2, 3, 13, 17, 19}) # tracks, artists, albums, artwork, browse_filter_index, history
Every chain begins with a "strange" placeholder page (flags=0x64)
mirroring the reference; data pages with rows follow. Tables with no
rows emit only the strange page.
"""
rows = list(row_iter)
out: list[bytes] = []
if not rows:
out.append(build_strange_page(start_index, page_type, next_page=start_index))
return out, start_index, start_index
data_pages: list[PageBuilder] = []
page_idx = start_index + 1
current = PageBuilder(page_idx, page_type)
for row in rows:
if not current.try_add(row):
data_pages.append(current)
page_idx += 1
current = PageBuilder(page_idx, page_type)
if not current.try_add(row):
raise ValueError(f"row of size {len(row)} too large for an empty page")
data_pages.append(current)
out.append(build_strange_page(start_index, page_type, next_page=start_index + 1))
for i, p in enumerate(data_pages):
if i == len(data_pages) - 1:
p.next_page = p.page_index
else:
p.next_page = p.page_index + 1
out.append(p.finalize())
return out, start_index, page_idx
# "Core" tables always get a real data page (even when empty). The rest stay
# strange-only when empty. Matched against a real rekordbox export.
CORE_TABLES = frozenset({0, 1, 2, 3, 5, 6, 13, 16, 17, 18, 19})
def write_pdb(db: Database, path: str | Path) -> None:
# Layout: page 0 is the file header page. Data pages begin at page_index 1.
chains: list[tuple[int, list[bytes], int, int]] = [] # (page_type, pages, first, last)
next_page_idx = 1
# Layout (matching rekordbox): page 0 is the file header. Pages 1..2*N are
# reserved 2-per-table -- strange page at the odd index 1+2k, first data page
# at the even index 2+2k. Overflow data pages come from the pool at 2*N+1.
serialized_tracks = [serialize_track(t) for t in db.tracks]
serialized_artists = [serialize_artist(a) for a in db.artists]
serialized_albums = [serialize_album(a) for a in db.albums]
@@ -106,18 +76,19 @@ def write_pdb(db: Database, path: str | Path) -> None:
# so the player's Color filter has a non-empty value set.
colors = db.colors if db.colors else STANDARD_COLORS
serialized_colors = [serialize_color(c) for c in colors]
serialized_keys = [serialize_key(k.id, k.name, k.id2) for k in db.keys]
# unknown_17/unknown_18 row format isn't decoded by the kaitai schema;
# we emit the reference's byte-for-byte rows (four little-endian u2s).
serialized_unknown_17 = [pack("<HHHH", *r) for r in UNKNOWN_17_ROWS]
serialized_unknown_18 = [pack("<HHHH", *r) for r in UNKNOWN_18_ROWS]
# Both working reference pdbs have at least one history_* row each.
# Emit a single dummy session so the player sees the export as
# "complete". Date pulled from any track if available.
# A real rekordbox export leaves history_playlists and history_entries
# EMPTY (only the `history` table itself carries a row). maple used to
# fabricate a dummy "HISTORY 001" playlist + entry referencing a track,
# which the player doesn't expect -- drop them and match the reference.
today = db.tracks[0].date_added if db.tracks else "2026-01-01"
first_track_id = db.tracks[0].id if db.tracks else 1
serialized_history_playlists = [serialize_history_playlist(1, "HISTORY 001")]
serialized_history_entries = [serialize_history_entry(first_track_id, 1, 1)]
serialized_history_playlists: list[bytes] = []
serialized_history_entries: list[bytes] = []
serialized_history = [serialize_history_blob(today)]
rows_by_type: dict[int, list[bytes]] = {
@@ -125,6 +96,7 @@ def write_pdb(db: Database, path: str | Path) -> None:
PAGE_TYPE_GENRES: serialized_genres,
PAGE_TYPE_ARTISTS: serialized_artists,
PAGE_TYPE_ALBUMS: serialized_albums,
PAGE_TYPE_KEYS: serialized_keys,
PAGE_TYPE_COLORS: serialized_colors,
PAGE_TYPE_PLAYLIST_TREE: serialized_pl_tree,
PAGE_TYPE_PLAYLIST_ENTRIES: serialized_pl_entries,
@@ -136,15 +108,40 @@ def write_pdb(db: Database, path: str | Path) -> None:
PAGE_TYPE_HISTORY_ENTRIES: serialized_history_entries,
PAGE_TYPE_HISTORY: serialized_history,
}
# Emit all 20 page-type chains; tables we don't populate get a single empty page.
for ptype in ALL_PAGE_TYPES:
rows = rows_by_type.get(ptype, [])
pages, first, last = _build_chain(ptype, rows, next_page_idx)
chains.append((ptype, pages, first, last))
next_page_idx = last + 1
num_tables = len(ALL_PAGE_TYPES) # 20
overflow_next = 1 + 2 * num_tables # first overflow page (41)
pages_by_index: dict[int, bytes] = {} # page_index -> 4096-byte page
table_dir: list[tuple[int, int, int]] = [] # (page_type, first_page, last_page)
num_tables = len(chains)
next_unused_page = next_page_idx
for k, ptype in enumerate(ALL_PAGE_TYPES):
strange_idx = 1 + 2 * k
data_idx = 2 + 2 * k
rows = rows_by_type.get(ptype, [])
data_flag = 0x34 if ptype in PAGE_FLAG_34 else 0x24
# Optional table with no rows: strange page only (its even page stays blank).
if not rows and ptype not in CORE_TABLES:
pages_by_index[strange_idx] = build_strange_page(strange_idx, ptype, next_page=strange_idx)
table_dir.append((ptype, strange_idx, strange_idx))
continue
# First data page at the reserved even index; overflow pages from the pool.
builders: list[PageBuilder] = [PageBuilder(data_idx, ptype, data_flag=data_flag)]
for row in rows:
if not builders[-1].try_add(row):
nb = PageBuilder(overflow_next, ptype, data_flag=data_flag)
overflow_next += 1
if not nb.try_add(row):
raise ValueError(f"row of size {len(row)} too large for an empty page")
builders.append(nb)
pages_by_index[strange_idx] = build_strange_page(strange_idx, ptype, next_page=data_idx)
for i, b in enumerate(builders):
b.next_page = b.page_index if i == len(builders) - 1 else builders[i + 1].page_index
pages_by_index[b.page_index] = b.finalize()
table_dir.append((ptype, strange_idx, builders[-1].page_index))
next_unused_page = max(overflow_next, 1 + 2 * num_tables)
file_header = b""
file_header += pack("<I", 0) # magic
@@ -157,7 +154,7 @@ def write_pdb(db: Database, path: str | Path) -> None:
assert len(file_header) == FILE_HEADER_SIZE
tables_block = b""
for ptype, _pages, first, last in chains:
for ptype, first, last in table_dir:
tables_block += pack("<I", ptype)
tables_block += pack("<I", 0) # empty_candidate
tables_block += pack("<I", first)
@@ -170,11 +167,12 @@ def write_pdb(db: Database, path: str | Path) -> None:
raise ValueError("too many tables for one page")
page0 = header_full + b"\x00" * pad_to_page
blank = b"\x00" * PAGE_SIZE
out_path = Path(path)
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("wb") as f:
f.write(page0)
for _ptype, pages, _first, _last in chains:
for p in pages:
assert len(p) == PAGE_SIZE
f.write(p)
for idx in range(1, next_unused_page): # reserved-but-unused pages written blank
page = pages_by_index.get(idx, blank)
assert len(page) == PAGE_SIZE
f.write(page)
+2 -2
View File
@@ -25,5 +25,5 @@ def test_smoke(tmp_path: Path):
assert data[:4] == b"\x00\x00\x00\x00"
# page_size at offset 4
assert int.from_bytes(data[4:8], "little") == 4096
# num_tables at offset 8
assert int.from_bytes(data[8:12], "little") == 5
# num_tables at offset 8 -- rekordbox always emits all 20 page-type tables
assert int.from_bytes(data[8:12], "little") == 20
+80
View File
@@ -0,0 +1,80 @@
"""Build a maple export.pdb from the SAME tracks as the real rekordbox AceMo
export, so the two can be diffed and the maple pdb tested on a player against
the audio/USBANLZ already on the USB.
Reads metadata out of data/acemo/PIONEER/rekordbox/export.pdb (the real export)
and reconstructs an identical-content maple Database, then writes
captures/maple_acemo.pdb.
"""
import sys
from io import BytesIO
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from kaitaistruct import KaitaiStream
from kaitai.rekordbox_pdb import RekordboxPdb
from maple.model import Album, Artist, Artwork, Database, Key, Track
from maple.pdb.writer import write_pdb
REAL = ROOT / "data/acemo/PIONEER/rekordbox/export.pdb"
OUT = ROOT / "captures/maple_acemo.pdb"
PT = {t.value: t.name for t in RekordboxPdb.PageType}
def _rows(tbl):
out, ref = [], tbl.first_page
while True:
pg = ref.body
if pg.is_data_page:
for rg in pg.row_groups:
for r in rg.rows:
if r.present:
out.append(r.body)
if ref.index == tbl.last_page.index:
break
ref = pg.next_page
return out
def _txt(v):
try:
return v.body.text
except Exception:
return ""
def main():
pdb = RekordboxPdb(is_ext=False, _io=KaitaiStream(BytesIO(REAL.read_bytes())))
by = {PT.get(t.type.value): _rows(t) for t in pdb.tables}
db = Database()
for a in by.get("artists", []):
db.artists.append(Artist(id=a.id, name=_txt(a.name)))
for a in by.get("albums", []):
db.albums.append(Album(id=a.id, name=_txt(a.name), artist_id=a.artist_id))
for k in by.get("keys", []):
db.keys.append(Key(id=k.id, name=_txt(k.name), id2=k.id2))
for a in by.get("artwork", []):
db.artwork.append(Artwork(id=a.id, path=_txt(a.path)))
for t in by.get("tracks", []):
db.tracks.append(Track(
id=t.id, title=_txt(t.title), artist_id=t.artist_id, album_id=t.album_id,
filename=_txt(t.filename), file_path=_txt(t.file_path), file_size=t.file_size,
sample_rate=t.sample_rate, sample_depth=t.sample_depth, bitrate=t.bitrate,
duration=t.duration, track_number=t.track_number, year=t.year,
disc_number=t.disc_number, tempo=t.tempo, comment=_txt(t.comment),
date_added=_txt(t.date_added), release_date=_txt(t.release_date),
mix_name=_txt(t.mix_name), analyze_path=_txt(t.analyze_path),
analyze_date=_txt(t.analyze_date), artwork_id=t.artwork_id, genre_id=t.genre_id))
write_pdb(db, OUT)
sz = OUT.stat().st_size
print(f"wrote {OUT} ({sz} bytes, page-mult={sz % 4096 == 0}) "
f"tracks={len(db.tracks)} artists={len(db.artists)} albums={len(db.albums)} "
f"keys={len(db.keys)} artwork={len(db.artwork)}")
if __name__ == "__main__":
main()
+104
View File
@@ -0,0 +1,104 @@
// Frida host (Node ESM) for the pdb-capture agent.
// - Attaches the agent (rbx_pdb_capture.js) to rekordbox processes.
// - Streams ALL their output to THIS console (agent lines are [pid]-tagged).
// - STAYS ALIVE via the Node event loop -- no frida-CLI REPL/EOF problem.
// - Uses CHILD-GATING (Windows-supported; spawn-gating is not) so children of a
// gated process are instrumented AT BIRTH. This is how we reach the
// rekordboxAgent that writes export.pdb: it's CIG-protected and refuses
// post-hoc injection, but as a freshly-spawned child it can be hooked.
// - Clears the captures/ folder on start.
//
// Setup (Windows, repo root, once): npm i frida
// Run:
// node tools\capture.mjs --spawn (RECOMMENDED: launch rekordbox under the
// gate so every child incl. the DB agent is
// hooked at birth. Close rekordbox first.)
// node tools\capture.mjs (attach to running procs + gate their
// future children; restart rekordbox to
// respawn the agent as a gated child.)
import * as fridaNs from "frida";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frida = fridaNs.default ?? fridaNs;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REKORDBOX = "C:\\Program Files\\rekordbox\\rekordbox 7.0.5\\rekordbox.exe";
const AGENT = fs.readFileSync(path.join(__dirname, "rbx_pdb_capture.js"), "utf8");
const CAP = path.join(__dirname, "..", "captures");
const SPAWN = process.argv.includes("--spawn");
function clearCaptures() {
fs.mkdirSync(CAP, { recursive: true });
let n = 0;
for (const f of fs.readdirSync(CAP)) {
try { fs.rmSync(path.join(CAP, f), { force: true, recursive: true }); n++; } catch (e) {}
}
console.log(`[host] cleared ${n} file(s) from ${CAP}`);
}
function isRbx(name) { return /rekordbox/i.test(name || ""); }
// Attach, load the agent, and gate this process's future children too.
async function instrument(device, pid, label) {
try {
const session = await device.attach(pid);
session.detached.connect(reason => console.log(`[host] detached ${label} (${pid}): ${reason}`));
const script = await session.createScript(AGENT);
script.logHandler = (level, text) => console.log(text);
script.message.connect(msg => {
if (msg.type === "error")
console.error(`[host] ${label} (${pid}) script error:`, msg.stack || msg.description || msg);
});
await script.load();
try { await session.enableChildGating(); } catch (e) { /* not always available */ }
console.log(`[host] instrumented ${label} (${pid})`);
return session;
} catch (e) {
console.error(`[host] FAILED to instrument ${label} (${pid}): ${e.message}`);
return null;
}
}
async function main() {
clearCaptures();
const device = await frida.getLocalDevice();
// Children of any gated process are suspended at birth -> instrument, resume.
device.childAdded.connect(async child => {
const name = child.identifier || child.path || String(child.pid);
try {
console.log(`[host] child spawned: ${name} (${child.pid})`);
if (isRbx(name)) await instrument(device, child.pid, "child:" + name);
} catch (e) {
console.error(`[host] child handler error (${child.pid}): ${e.message}`);
} finally {
try { await device.resume(child.pid); } catch (_) {}
}
});
if (SPAWN) {
console.log(`[host] spawning ${REKORDBOX}`);
const pid = await device.spawn([REKORDBOX]);
await instrument(device, pid, "main"); // enables child gating on main
await device.resume(pid);
console.log(`[host] resumed main rekordbox (${pid}); children caught at birth`);
} else {
for (const p of await device.enumerateProcesses()) {
if (isRbx(p.name)) await instrument(device, p.pid, p.name);
}
console.log("[host] attached to running rekordbox processes (+child gating).");
console.log("[host] >>> the export.pdb writer (rekordboxAgent) refuses live injection.");
console.log("[host] >>> RESTART rekordbox -- the new agent spawns as a GATED CHILD and");
console.log("[host] >>> gets hooked at birth. Then Export and watch for [SAVED] ...export.pdb");
console.log("[host] >>> (or re-run with --spawn to launch rekordbox under the gate.)");
}
console.log("[host] ready. Ctrl-C to stop.");
process.stdin.resume();
process.on("SIGINT", () => { console.log("\n[host] stopping."); process.exit(0); });
}
main().catch(e => { console.error("[host] fatal:", e); process.exit(1); });
+209
View File
@@ -0,0 +1,209 @@
// Capture rekordbox's .pdb writes with Frida (run on the WINDOWS host).
//
// Hooks at the ntdll layer (NtCreateFile / NtWriteFile / NtClose) so it catches
// file IO no matter whether the app calls kernel32, kernelbase, or the CRT.
// Logs EVERY file opened for writing (so you can see temp names / the real
// filenames even if the .pdb filter is wrong), reconstructs files whose path
// matches SAVE_RE byte-for-byte, and saves them to OUT_DIR via Frida's File API.
// Prints a backtrace the first time each saved file is written -> the
// module+offset of the serializer to open in Ghidra.
//
// Usage (Windows PowerShell, rekordbox running):
// mkdir C:\Users\ntr\code\maple\maple-py\captures # OUT_DIR must exist
// frida-ps | Select-String rekordbox # find all PIDs
// frida -n rekordbox.exe -l tools\rbx_pdb_capture.js # or -p <pid>
// then trigger "Export to USB". If you see [w-open] lines but the export file
// isn't among them, widen SAVE_RE. If you see NOTHING, attach to another PID.
//
// Set VERBOSE = true to log every write-open path; false logs only SAVE_RE hits.
var OUT_DIR = "C:\\Users\\ntr\\code\\maple\\maple-py\\captures\\"; // must exist; Frida won't mkdir
var SAVE_RE = /export.*\.pdb|\.pdb$|PIONEER/i; // which opened files to reconstruct + save
var VERBOSE = true; // log ALL write-opens, not just SAVE_RE
var PID = Process.id;
var TAG = "[" + PID + "] ";
var DEBUG = true; // log every interesting open + all mapping activity
var DEBUG_RE = /export|\.pdb|PIONEER|rekordbox|Contents|USBANLZ/i; // what DEBUG opens to print
var ntdll = Process.getModuleByName("ntdll.dll");
function nt(name) { return ntdll.getExportByName(name); }
// Resolve + confirm a hook target so we can see in the console what attached.
function resolve(name) {
var a = ntdll.findExportByName(name);
console.log(TAG + "hook " + name + (a ? " -> " + a : " MISSING"));
return a;
}
// handle (string) -> { path, pos, end, chunks:[{pos,ab}], save, traced }
var files = {};
var counters = {};
function baseName(path) {
var parts = path.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
return (parts[parts.length - 1] || "capture.bin").replace(/[^A-Za-z0-9._-]/g, "");
}
// Read the wide path out of an OBJECT_ATTRIBUTES* (x64 layout).
function readObjName(pObjAttr) {
if (pObjAttr.isNull()) return null;
var pUni = pObjAttr.add(16).readPointer(); // OBJECT_ATTRIBUTES.ObjectName
if (pUni.isNull()) return null;
var len = pUni.readU16(); // UNICODE_STRING.Length (bytes)
if (!len) return null;
var buf = pUni.add(8).readPointer(); // UNICODE_STRING.Buffer
if (buf.isNull()) return null;
return buf.readUtf16String(len / 2);
}
// NtCreateFile / NtOpenFile: args[0]=PHANDLE out, [1]=ACCESS_MASK, [2]=OBJECT_ATTRIBUTES*
function hookOpen(name) {
var addr = resolve(name);
if (!addr) return;
Interceptor.attach(addr, {
onEnter: function (args) {
this.pHandle = args[0];
this.access = args[1].toUInt32();
this.path = readObjName(args[2]);
},
onLeave: function (retval) {
if (!this.path) return;
var WRITE = 0x40000000 /*GENERIC_WRITE*/ | 0x10000000 /*GENERIC_ALL*/ |
0x2 /*FILE_WRITE_DATA*/ | 0x4 /*FILE_APPEND_DATA*/;
var isWrite = (this.access & WRITE) !== 0;
var save = SAVE_RE.test(this.path);
// DEBUG: show every interesting open (incl. read-only) + its access mask,
// so we can see if/how export.pdb is opened even when our filters miss it.
if (DEBUG && DEBUG_RE.test(this.path))
console.log(TAG + "[open] access=0x" + this.access.toString(16)
+ (isWrite ? " W" : " r") + (save ? " *SAVE" : "") + " " + this.path);
if (!isWrite && !save) return;
var handleStr = this.pHandle.readPointer().toString();
files[handleStr] = { path: this.path, pos: 0, end: 0, chunks: [], save: save, traced: false };
if (!DEBUG && (save || VERBOSE)) console.log(TAG + "[w-open] " + this.path);
},
});
}
hookOpen("NtCreateFile");
hookOpen("NtOpenFile");
// NtWriteFile: [0]=handle [5]=buffer [6]=length [7]=PLARGE_INTEGER ByteOffset
Interceptor.attach(resolve("NtWriteFile"), {
onEnter: function (args) {
var f = files[args[0].toString()];
if (!f) return;
var n = args[6].toUInt32();
if (n <= 0) return;
var pos = f.pos;
var pOff = args[7];
if (!pOff.isNull()) {
var hi = pOff.add(4).readU32();
if (hi !== 0xffffffff) pos = hi * 0x100000000 + pOff.readU32(); // skip sentinels
}
if (f.save) {
f.chunks.push({ pos: pos, ab: args[5].readByteArray(n) });
if (!f.traced) {
f.traced = true;
console.log(TAG + "[trace] writer for " + f.path + ":");
Thread.backtrace(this.context, Backtracer.ACCURATE)
.slice(0, 14)
.forEach(function (a) {
// module+offset maps straight to Ghidra: static = 0x140000000 + offset
var m = Process.findModuleByAddress(a);
var loc = m ? (m.name + "+0x" + a.sub(m.base).toString(16)) : a.toString();
console.log(" " + loc + " " + DebugSymbol.fromAddress(a));
});
}
}
f.pos = pos + n;
if (f.pos > f.end) f.end = f.pos;
},
});
// Shared: write a captured buffer to OUT_DIR with a PID-tagged, de-duped name.
function saveBuffer(path, arrayBuffer, size) {
var name = baseName(path);
var c = counters[name] || 0;
counters[name] = c + 1;
var dest = OUT_DIR + "p" + PID + "_" + name + (c === 0 ? "" : "." + c);
var fh = new File(dest, "wb");
fh.write(arrayBuffer);
fh.close();
console.log(TAG + "[SAVED] " + dest + " (" + size + " bytes)");
}
// NtClose: reconstruct + flush saved files written via NtWriteFile
function flush(handleStr) {
var f = files[handleStr];
if (!f) return;
delete files[handleStr];
if (!f.save || f.end === 0) return;
var out = new Uint8Array(f.end);
for (var i = 0; i < f.chunks.length; i++) out.set(new Uint8Array(f.chunks[i].ab), f.chunks[i].pos);
saveBuffer(f.path, out.buffer, f.end);
}
Interceptor.attach(resolve("NtClose"), { onEnter: function (args) { flush(args[0].toString()); } });
// ---- memory-mapped writes (the .pdb is likely built via a file mapping) ----
// CreateFileMapping/MapViewOfFile funnel through these ntdll calls. We link
// section -> backing file (NtCreateSection takes the file handle we already
// track), record the mapped view, and snapshot its memory when it's unmapped.
var sections = {}; // sectionHandle -> { path, save, maxSize }
var views = {}; // baseAddr string -> { base, size, path, save, maxSize }
function hookSections() {
var cs = resolve("NtCreateSection");
if (cs) Interceptor.attach(cs, {
onEnter: function (args) {
this.pSection = args[0];
this.pMax = args[3]; // PLARGE_INTEGER MaximumSize (may be null)
this.fileHandle = args[6].toString();// HANDLE FileHandle
},
onLeave: function (ret) {
if (ret.toInt32() !== 0) return; // not STATUS_SUCCESS
var f = files[this.fileHandle];
if (!f) return; // only file-backed sections we track
var maxSize = (this.pMax && !this.pMax.isNull()) ? this.pMax.readU32() : 0;
sections[this.pSection.readPointer().toString()] = { path: f.path, save: f.save, maxSize: maxSize };
},
});
var mv = resolve("NtMapViewOfSection");
if (mv) Interceptor.attach(mv, {
onEnter: function (args) {
this.section = args[0].toString();
this.pBase = args[2]; // PVOID* BaseAddress
this.pViewSize = args[6]; // PSIZE_T ViewSize
},
onLeave: function (ret) {
if (ret.toInt32() < 0) return; // negative NTSTATUS = error
var s = sections[this.section];
if (!s) return;
var base = this.pBase.readPointer();
var size = (this.pViewSize && !this.pViewSize.isNull()) ? this.pViewSize.readU32() : 0;
views[base.toString()] = { base: base, size: size, path: s.path, save: s.save, maxSize: s.maxSize };
if (s.save || VERBOSE) console.log(TAG + "[mmap] " + s.path + " @ " + base + " view=" + size + " max=" + s.maxSize);
},
});
function captureView(base) {
var v = views[base.toString()];
if (!v) return;
delete views[base.toString()];
if (!v.save) return;
var size = v.maxSize || v.size;
if (!size) { var r = Process.findRangeByAddress(base); size = r ? r.size : 0; }
if (!size) return;
try { saveBuffer(v.path, Memory.readByteArray(base, size), size); }
catch (e) { console.log(TAG + "[mmap] read failed @ " + base + ": " + e); }
}
var um = ntdll.findExportByName("NtUnmapViewOfSection");
if (um) Interceptor.attach(um, { onEnter: function (args) { captureView(args[1]); } });
}
hookSections();
console.log(TAG + "ntdll + mmap hooks installed -- trigger 'Export to USB' in rekordbox now");