Skip to content

Commit 27b5e81

Browse files
committed
Support V3.x clients via dynamic memory pattern matching
1 parent 7c0d41c commit 27b5e81

File tree

3 files changed

+189
-20
lines changed

3 files changed

+189
-20
lines changed

CLAUDE.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Windows-only application that reads NetEase Cloud Music (NCM) playback state directly from process memory and displays it as Discord Rich Presence. Single-file Python app (`src/main.py`) with a Tkinter GUI and system tray icon.
8+
9+
## Commands
10+
11+
```bash
12+
# Install dependencies (Python 3.8-3.12)
13+
pip install -r src/requirements.txt
14+
15+
# Run in development
16+
python src/main.py
17+
18+
# Run minimized to system tray
19+
python src/main.py --min
20+
21+
# Build exe (run from repo root)
22+
pyinstaller --log-level DEBUG --distpath ./ --clean --noconfirm src/main.spec
23+
24+
# Enable debug logging: create an empty file named debug.log in working directory
25+
```
26+
27+
CI uses GitHub Actions (`.github/workflows/pyinstaller-windows.yml`): Python 3.12, PyInstaller + UPX on Windows.
28+
29+
## Architecture
30+
31+
The entire application lives in `src/main.py` (~517 lines). There are no tests, no separate modules.
32+
33+
### Core Loop
34+
35+
`startup()` → connects to Discord via pypresence → starts a `RepeatedTimer` (1-second interval) that calls `update()`:
36+
37+
1. **Process discovery**: Uses WMI to find `cloudmusic.exe`, reads file version via `win32api.GetFileVersionInfo`
38+
2. **Memory reading**: Opens process with pyMeow, reads from `cloudmusic.dll`:
39+
- **V2.x**: base + hardcoded version-specific offsets (`current``r_float64` playback time, `song_array``r_uint` → UTF-16 song ID)
40+
- **V3.x**: AOB (array-of-bytes) pattern scan via `aob_scan_module()` to find `schedule_ptr` and `audio_player_ptr` at runtime (offsets change every launch). Song ID read via SSO string logic, UTF-8 encoded.
41+
3. **Status detection** (line 354): Compares current song ID and playback time with previous values:
42+
- Playing: same ID, time advanced ~1s
43+
- Paused: same ID, time unchanged
44+
- Changed: different ID or manual seek
45+
4. **Song info lookup**: Local history cache → playingList file → remote `pyncm` API. Results cached in `song_info_cache` dict.
46+
5. **Discord update**: Sets presence with title, artist, album art URL, play/pause icon, elapsed time, and a "Listen on NetEase" button link.
47+
48+
### Key Design Details
49+
50+
- **Version-specific offsets** (line 40-52): Dict mapping NCM V2.x version strings to memory offsets. V3.x uses dynamic AOB scanning (`scan_for_v3_offsets()`) — patterns sourced from [Kxnrl/NetEase-Cloud-Music-DiscordRPC](https://github.com/Kxnrl/NetEase-Cloud-Music-DiscordRPC/blob/d3b77c679379aff1294cc83a285ad4f695376ad6/Vanessa/Players/NetEase.cs#L24).
51+
- **Pause timeout**: After 30 minutes paused, disconnects RPC. Reconnects on resume.
52+
- **Locale detection** (line 58): UI text is bilingual (Chinese/English) based on Windows UI language.
53+
- **Startup on boot**: Writes/removes a `.bat` file in the Windows Startup folder.
54+
- **`RepeatedTimer`** (line 77): Custom timer that compensates for execution time drift.
55+
56+
### Adding Support for New NCM Versions
57+
58+
**V2.x**: Use Cheat Engine to find the `current` (float64 playback time) and `song_array` offsets relative to `cloudmusic.dll` base. Add entry to the `offsets` dict with key format `major.minor.patch.build`.
59+
60+
**V3.x**: No action needed — all V3 versions are supported automatically via AOB pattern scanning. If NetEase changes their binary significantly, the byte patterns (`V3_AUDIO_PLAYER_PATTERN`, `V3_AUDIO_SCHEDULE_PATTERN`) may need updating.
61+
62+
## Dependencies
63+
64+
Key non-obvious dependencies:
65+
- **pyMeow**: Windows process memory reading (installed from GitHub release zip, not PyPI)
66+
- **pypresence**: Discord IPC Rich Presence client
67+
- **pyncm**: Unofficial NetEase Cloud Music API wrapper (fallback for song metadata)
68+
- **orjson**: Fast JSON parsing for local NCM history cache
69+
- **pystray**: System tray icon (runs in daemon thread to avoid blocking the timer)
70+
71+
## Project Conventions
72+
73+
- All source code is in a single file `src/main.py` — no module structure
74+
- Global mutable state for RPC connection, process info, and song cache
75+
- Chinese comments and UI strings alongside English equivalents
76+
- Discord Client ID: `1045242932128645180`

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ Written in pure Python, supports latest versions of NetEase Cloud Music. Current
2525
* 2.10.11 build 201538
2626
* 2.10.12 build 201849
2727
* 2.10.13 build 202675 (last version of V2.x clients by NetEase)
28-
* 3.0.0 WIP/正在开发中 (https://github.com/aliencaocao/netease_cloudmusic_discord_rpc/issues/26)
28+
* 3.x - All V3 versions supported via dynamic memory scanning (no hardcoded offsets needed) / 所有V3版本通过动态内存扫描支持(无需硬编码偏移量)
2929

3030
还会继续支持未来的新版本。/Support for future versions will be added.
3131

32+
V3 memory scanning patterns from / V3内存扫描模式来源: [Kxnrl/NetEase-Cloud-Music-DiscordRPC](https://github.com/Kxnrl/NetEase-Cloud-Music-DiscordRPC/blob/d3b77c679379aff1294cc83a285ad4f695376ad6/Vanessa/Players/NetEase.cs#L24)
3233

3334
旧版本(2.10.3及以下)可以使用这个项目 / For older versions (2.10.3 and below), you can check out this project:https://github.com/Kxnrl/NetEase-Cloud-Music-DiscordRPC
3435

src/main.py

Lines changed: 111 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import pythoncom
1919
import wmi
2020
from PIL import Image
21-
from pyMeow import close_process, get_module, get_process_name, open_process, pid_exists, r_bytes, r_float64, r_uint
21+
from pyMeow import aob_scan_module, close_process, get_module, get_process_name, open_process, pid_exists, r_bytes, r_float64, r_int, r_int64, r_uint
2222
from pyncm import apis
2323
from pypresence import DiscordNotFound, PipeClosed, Presence
2424
from pystray import Icon as TrayIcon, Menu as TrayMenu, MenuItem as TrayItem
@@ -50,7 +50,14 @@
5050
'2.10.12.5241': {'current': 0xA7A580, 'song_array': 0xB2BCB0},
5151
'2.10.13.6067': {'current': 0xA7A590, 'song_array': 0xB2BCD0},
5252
}
53-
# '3.0.6.5811': {'current': 0x192B7F0, 'song_array': 0x0196DC38, 'song_array_offsets': [0x398, 0x0, 0x0, 0x8, 0x8, 0x50, 0xBA0]}, } # TODO: song array offsets are different for every session, current and song_array stays same
53+
# V3 byte patterns for dynamic memory scanning (offsets change every launch)
54+
# Source: https://github.com/Kxnrl/NetEase-Cloud-Music-DiscordRPC/blob/d3b77c679379aff1294cc83a285ad4f695376ad6/Vanessa/Players/NetEase.cs#L24
55+
V3_AUDIO_PLAYER_PATTERN = "48 8D 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 8D 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 90 48 8D 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 8D 05 ?? ?? ?? ?? 48 8D A5 ?? ?? ?? ?? 5F 5D C3 CC CC CC CC CC 48 89 4C 24 ?? 55 57 48 81 EC ?? ?? ?? ?? 48 8D 6C 24 ?? 48 8D 7C 24"
56+
V3_AUDIO_SCHEDULE_PATTERN = "66 0F 2E 0D ?? ?? ?? ?? 7A ?? 75 ?? 66 0F 2E 15"
57+
58+
# Cached V3 pointers (resolved per process launch via AOB scan)
59+
v3_schedule_ptr = 0
60+
v3_audio_player_ptr = 0
5461

5562
frozen = getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS')
5663
interval = 1
@@ -61,7 +68,7 @@
6168
start_minimized = '--min' in sys.argv
6269
re_song_id = re.compile(r'(\d+)')
6370

64-
logger.info(f"Netease Cloud Music Discord RPC v{__version__}\nRunning on Python {sys.version}\nSupporting NCM version: {', '.join(offsets.keys())}")
71+
logger.info(f"Netease Cloud Music Discord RPC v{__version__}\nRunning on Python {sys.version}\nSupporting NCM version: {', '.join(offsets.keys())}, 3.x (dynamic scan)")
6572

6673

6774
def get_res_path(relative_path: str) -> str:
@@ -202,7 +209,7 @@ def toggle_startup():
202209

203210

204211
def about():
205-
supported_ver_str = '\n'.join(offsets.keys())
212+
supported_ver_str = '\n'.join(offsets.keys()) + '\n3.x (dynamic scan)'
206213
messagebox.showinfo('About', f"Netease Cloud Music Discord RPC v{__version__}\nPython {sys.version}\nSupporting NCM version:\n{supported_ver_str}\nMaintainer: Billy Cao" if not is_CN else
207214
f"网易云音乐 Discord RPC v{__version__}\nPython版本 {sys.version}\n支持的网易云音乐版本:\n{supported_ver_str}\n开发者: Billy Cao")
208215

@@ -274,11 +281,38 @@ def get_song_info_from_local(song_id: str) -> bool:
274281
return False
275282

276283

284+
def get_song_info_from_playing_list(song_id: str) -> bool:
285+
filepath = os.path.join(os.path.expandvars('%LOCALAPPDATA%'), 'Netease/CloudMusic/WebData/file/playingList')
286+
if not os.path.exists(filepath):
287+
return False
288+
try:
289+
with open(filepath, 'r', encoding='utf-8') as f:
290+
data = orjson.loads(f.read())
291+
song_list = data.get('list', [])
292+
song_info_raw_list = [x for x in song_list if str(x.get('id', '')) == song_id]
293+
if not song_info_raw_list:
294+
return False
295+
track = song_info_raw_list[0]['track']
296+
song_info: SongInfo = {
297+
'cover': track['album']['cover'],
298+
'album': track['album']['name'],
299+
'duration': track.get('duration', 0) / 1000 if track.get('duration', 0) else 0,
300+
'artist': '/'.join([x['name'] for x in track['artists']]),
301+
'title': track['name'],
302+
}
303+
song_info_cache[song_id] = song_info
304+
return True
305+
except Exception as e:
306+
logger.warning('Error while reading from playingList file:', e)
307+
return False
308+
309+
277310
def get_song_info(song_id: str) -> SongInfo:
278311
global song_info_cache
279312
if song_id not in song_info_cache:
280313
if not get_song_info_from_local(song_id):
281-
get_song_info_from_netease(song_id)
314+
if not get_song_info_from_playing_list(song_id):
315+
get_song_info_from_netease(song_id)
282316
return song_info_cache[song_id]
283317

284318

@@ -301,6 +335,60 @@ def find_process() -> Tuple[int, str]:
301335
return process.ProcessId, ver
302336

303337

338+
def scan_for_v3_offsets(process: dict, module_name: str = 'cloudmusic.dll') -> Tuple[int, int]:
339+
"""Scan cloudmusic.dll for V3 audio pointers using AOB patterns.
340+
Returns (schedule_ptr, audio_player_ptr) as absolute virtual addresses."""
341+
results = aob_scan_module(process, module_name, V3_AUDIO_SCHEDULE_PATTERN)
342+
if not results:
343+
raise RuntimeError('V3 AOB scan failed: AudioSchedulePattern not found')
344+
match = results[0]
345+
text_addr = match + 4
346+
displacement = r_int(process, text_addr)
347+
schedule_ptr = text_addr + displacement + 4
348+
logger.debug(f'V3 schedule pointer: {hex(schedule_ptr)}')
349+
350+
results = aob_scan_module(process, module_name, V3_AUDIO_PLAYER_PATTERN)
351+
if not results:
352+
raise RuntimeError('V3 AOB scan failed: AudioPlayerPattern not found')
353+
match = results[0]
354+
text_addr = match + 3
355+
displacement = r_int(process, text_addr)
356+
audio_player_ptr = text_addr + displacement + 4
357+
logger.debug(f'V3 audio player pointer: {hex(audio_player_ptr)}')
358+
359+
return schedule_ptr, audio_player_ptr
360+
361+
362+
def read_v3_song_id(process: dict, audio_player_ptr: int) -> str:
363+
"""Read current song ID from V3 memory layout (UTF-8, SSO string)."""
364+
audio_play_info = r_int64(process, audio_player_ptr + 0x50)
365+
if audio_play_info == 0:
366+
return ''
367+
368+
str_ptr = audio_play_info + 0x10
369+
str_length = r_int64(process, str_ptr + 0x10)
370+
371+
if str_length <= 0:
372+
return ''
373+
374+
# Cap read size to avoid reading excessive memory; song ID strings are short (e.g. "1234567890_0")
375+
read_length = min(int(str_length), 128)
376+
377+
# Small string optimization: if length <= 15, data is inline at str_ptr; otherwise dereference
378+
if str_length <= 15:
379+
raw = r_bytes(process, str_ptr, read_length)
380+
else:
381+
str_address = r_int64(process, str_ptr)
382+
if str_address == 0:
383+
return ''
384+
raw = r_bytes(process, str_address, read_length)
385+
386+
song_str = raw.decode('utf-8')
387+
if not song_str or '_' not in song_str:
388+
return ''
389+
return song_str[:song_str.index('_')]
390+
391+
304392
def update():
305393
global first_run
306394
global pid
@@ -309,6 +397,8 @@ def update():
309397
global last_id
310398
global last_float
311399
global last_pause_time
400+
global v3_schedule_ptr
401+
global v3_audio_player_ptr
312402

313403
try:
314404
if not pid_exists(pid) or get_process_name(pid) != 'cloudmusic.exe':
@@ -322,28 +412,30 @@ def update():
322412
first_run = True
323413
return
324414

325-
if version not in offsets:
415+
is_v3 = version.startswith('3.')
416+
if not is_v3 and version not in offsets:
326417
stop_variable.set()
327418
raise UnsupportedVersionError(f"This version is not supported yet: {version}.\nSupported version: {', '.join(offsets.keys())}" if not is_CN else f"目前不支持此网易云音乐版本: {version}\n支持的版本: {', '.join(offsets.keys())}")
419+
420+
process = open_process(pid)
421+
328422
if first_run:
329423
logger.info(f'Found process: {pid}')
424+
if is_v3:
425+
v3_schedule_ptr, v3_audio_player_ptr = scan_for_v3_offsets(process, 'cloudmusic.dll')
426+
logger.info(f'V3 AOB scan complete: schedule={hex(v3_schedule_ptr)}, player={hex(v3_audio_player_ptr)}')
330427
first_run = False
331428

332-
process = open_process(pid)
333-
module_base = get_module(process, 'cloudmusic.dll')['base']
334-
335-
current_float = r_float64(process, module_base + offsets[version]['current'])
336-
current_pystr = sec_to_str(current_float)
337-
if version.startswith('2.'):
338-
songid_array = r_uint(process, module_base + offsets[version]['song_array'])
339-
song_id = (r_bytes(process, songid_array, 0x14).decode('utf-16').split('_')[0]) # Song ID can be shorter than 10 digits.
340-
elif version.startswith('3.'):
341-
songid_array = pointer_chain(process, module_base + offsets[version]['song_array'], offsets[version]['song_array_offsets'])
342-
song_id = r_bytes(process, songid_array, 0x14)
343-
song_id = song_id.decode('utf-16').replace('\x00', '').split('_')[0]
429+
if is_v3:
430+
current_float = r_float64(process, v3_schedule_ptr)
431+
song_id = read_v3_song_id(process, v3_audio_player_ptr)
344432
else:
345-
raise RuntimeError(f'Unknown version: {version}')
433+
module_base = get_module(process, 'cloudmusic.dll')['base']
434+
current_float = r_float64(process, module_base + offsets[version]['current'])
435+
songid_array = r_uint(process, module_base + offsets[version]['song_array'])
436+
song_id = r_bytes(process, songid_array, 0x14).decode('utf-16').split('_')[0] # Song ID can be shorter than 10 digits.
346437

438+
current_pystr = sec_to_str(current_float)
347439
close_process(process)
348440

349441
if not re_song_id.match(song_id):

0 commit comments

Comments
 (0)