maipulse: 脱敏存档

- Python 后端 + 单页前端 + Android(Chaquopy) 壳
- 曲库 1343 首 / 称号 2800 个
- 机台身份与店铺信息已抹除,字段留空
- 打包脚本含 16KB 页对齐与自动脱敏
This commit is contained in:
shouko committed 2026-10-28 22:27:46 +08:00
commit 8421898626
161 files changed
+50297

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# 构建产物
__pycache__/
*.pyc
*.pyo
build/
.gradle/
app/build/
local.properties
# 私钥与签名
*.keystore
*.jks
*.p12
# 用户数据:绝不入库
maipulse/data/accounts.json
maipulse/data/shops.json
maipulse/data/shop_current.json
maipulse/data/covers/
maipulse/data/_backup/
maipulse/config.filled.json
maipulse/logs/
astral/data/
astral/logs/
astral/config.py
# 系统
.DS_Store
Thumbs.db
*.swp
+72
View File
@@ -0,0 +1,72 @@
# maipulse
舞萌 DX 国服(SDGB)的本地发包台。Flask + 单页前端,安卓壳用 Chaquopy 把 Python 打进 APK。
**这个仓库是脱敏存档**:所有机台身份(标题服务器地址、AES 密钥与 IV、混淆盐)和店铺信息都已经抹掉,
字段留空,运行时从配置文件读。想用的话自己填——见下面的「配置」。
## 目录
```
maipulse/ Python 后端 + 前端单页
app.py Flask 入口,所有 HTTP 接口
core/ 业务模块(session / b50 / unlock / items / settle ...)
html/index.html 整个界面(CSS/JS 全内联)
data/master/ 曲库与道具索引(公开数据)
tools/ 打包、更新合并、脱敏脚本
astral/ SDGB 协议实现(脱敏副本,config.py 需要自己生成)
android/ Android 壳工程(Gradle + MainActivity)
```
## 配置
`astral/config.py` 和 `astral/api/MaimaiNet.py` 里的关键字段全部为空:
| 字段 | 说明 |
| --- | --- |
| `CLIENT_ID` | 11 位机台 ID |
| `PLACE_ID` / `REGION_ID` | 店铺 / 区域 ID |
| `PLACE_NAME` | 店铺名,只用于界面显示 |
| `BASE_URL` | 标题服务器地址 |
| `AES_KEY` / `AES_IV` | 32 / 16 字节 |
| `OBFUSCATE_PARAM` | 8 位混淆盐 |
填完在设置页点「导入配置」加载 JSON,或者直接改这两个文件后重启。
## 跑起来
```bash
python app.py # http://127.0.0.1:8787
```
依赖:`flask`、`requests`、`pycryptodome`。
## 打包 APK
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File maipulse\tools\build_apk.ps1
```
脚本会:清空旧产物 → 同步资源 → 脱敏 → 编译 → `zipalign -P 16`(16KB 页对齐,Android 15+ 要求)→
重签 → 放到桌面。
需要自己准备一个 debug keystore 放在 `android/` 下,并在 `android/app/build.gradle` 里改路径。
**keystore 不进仓库**,它带着私钥。
## 数据更新
官方更新包(`SDGB_A0xx_*`)到手后:
```bash
python maipulse/tools/apply_update.py "路径/包目录" --dry # 先看差异
python maipulse/tools/apply_update.py "路径/包目录" # 真正写入
```
只做增量合并,写盘前自动备份到 `data/_backup/`。
## 脱敏说明
`maipulse/tools/blank_secrets.py` 是打包时自动跑的那一步:从主仓读出真实值,
在副本里全部替换成占位符,然后自己复核一遍,有残留就 return 2 让打包失败。
这个仓库里跑过同样的流程,30 处替换,复核无残留。
+65
View File
@@ -0,0 +1,65 @@
plugins {
id 'com.android.application'
id 'com.chaquo.python'
}
android {
namespace 'com.maipulse.app'
compileSdk 34
defaultConfig {
applicationId "com.maipulse.app"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
ndk {
abiFilters "arm64-v8a"
}
// Chaquopy 16 的 DSL 挂在 android.defaultConfig 下面
python {
buildPython "C:/Users/20558/AppData/Local/Programs/Python/Python38/python.exe"
pip {
install "flask"
install "requests"
install "pycryptodome"
}
}
}
// 固定 keystore:AGP 默认会在 $HOME/.android 下自动生成一个,
// 而 zipalign 之后我们要用 apksigner 重签。两边不是同一个 key 的话,
// 出来的包签名不一样,用户必须卸载才能覆盖安装。
signingConfigs {
debug {
storeFile file("../maipulse-debug.keystore")
storePassword "android"
keyAlias "androiddebugkey"
keyPassword "android"
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
minifyEnabled false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
androidResources {
noCompress 'json', 'png'
}
}
dependencies {
implementation 'androidx.core:core:1.12.0'
}
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="true"
android:label="maipulse"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
android:theme="@style/AppTheme">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,397 @@
package com.maipulse.app;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.DownloadManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.Settings;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.webkit.JavascriptInterface;
import android.webkit.ValueCallback;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebChromeClient;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.core.content.FileProvider;
import com.chaquo.python.Python;
import com.chaquo.python.android.AndroidPlatform;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
/**
* maipulse 的 Android 外壳。
*
* 流程:复制 assets/app -> filesDir/app(assets 只读,Flask 要写 logs 与缓存),
* 起 Chaquopy 跑 Flask 绑 127.0.0.1:8787,端口通了再把 WebView 指过去。
*
* 额外给了网页一个 Maipulse.installApk(url):走系统 DownloadManager 下 APK,
* 下完弹安装器,这样更新不用让用户自己去找文件。
*/
public class MainActivity extends Activity {
private static final String TAG = "maipulse";
private static final int PORT = 8787;
private WebView web;
private TextView tip;
private static final int REQ_PICK_FILE = 1001;
private ValueCallback<Uri[]> filePathCallback;
private long pendingDownloadId = -1;
private BroadcastReceiver downloadReceiver;
@SuppressLint("SetJavaScriptEnabled")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
FrameLayout root = new FrameLayout(this);
root.setBackgroundColor(Color.parseColor("#EEF2F9"));
web = new WebView(this);
web.setBackgroundColor(Color.parseColor("#EEF2F9"));
WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true);
s.setAllowFileAccess(true);
s.setDatabaseEnabled(true);
s.setMediaPlaybackRequiresUserGesture(false);
s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
s.setCacheMode(WebSettings.LOAD_DEFAULT);
// 禁掉双指缩放与双击放大(网页里能放大就是这里没设)
s.setSupportZoom(false);
s.setBuiltInZoomControls(false);
s.setDisplayZoomControls(false);
s.setTextZoom(100);
// 长按不再弹出「复制/分享」这类浏览器菜单
web.setLongClickable(false);
web.setHapticFeedbackEnabled(false);
web.setWebViewClient(new WebViewClient());
// 网页里的 <input type=file> 走 WebChromeClient.onShowFileChooser,挂在
// WebViewClient 上编译不过,必须单独设一个 chrome client。
web.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onShowFileChooser(WebView view, ValueCallback<Uri[]> callback,
FileChooserParams params) {
if (filePathCallback != null) filePathCallback.onReceiveValue(null);
filePathCallback = callback;
try {
Intent i = params.createIntent();
i.addCategory(Intent.CATEGORY_OPENABLE);
startActivityForResult(i, REQ_PICK_FILE);
return true;
} catch (Exception e) {
filePathCallback = null;
return false;
}
}
});
web.addJavascriptInterface(new Bridge(), "Maipulse");
tip = new TextView(this);
tip.setText("正在启动服务…");
tip.setTextColor(Color.parseColor("#5A6A82"));
tip.setTextSize(15);
tip.setPadding(48, 48, 48, 48);
root.addView(web, new FrameLayout.LayoutParams(-1, -1));
root.addView(tip, new FrameLayout.LayoutParams(-1, -1));
setContentView(root);
registerDownloadReceiver();
new Thread(this::bootPython, "maipulse-boot").start();
new Thread(this::waitAndLoad, "maipulse-wait").start();
}
/* ---------------- 网页可调的接口 ---------------- */
public class Bridge {
/** 下载并安装新版本。url 由网页的更新源给。 */
@JavascriptInterface
public void installApk(String url) {
runOnUiThread(() -> startDownload(url));
}
/** 当前 App 版本号,网页可以拿来显示。 */
@JavascriptInterface
public String appVersion() {
try {
return getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
} catch (Exception e) {
return "1.0";
}
}
}
private void startDownload(String url) {
if (url == null || !url.startsWith("http")) {
Toast.makeText(this, "更新地址不对", Toast.LENGTH_SHORT).show();
return;
}
try {
File dir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
if (dir != null) {
for (File f : dir.listFiles()) {
if (f.getName().endsWith(".apk")) f.delete();
}
}
DownloadManager.Request req = new DownloadManager.Request(Uri.parse(url));
req.setTitle("maipulse 新版本");
req.setDescription("下载完成后会弹安装");
req.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
req.setDestinationInExternalFilesDir(this, Environment.DIRECTORY_DOWNLOADS, "maipulse-update.apk");
req.setMimeType("application/vnd.android.package-archive");
DownloadManager dm = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
pendingDownloadId = dm.enqueue(req);
Toast.makeText(this, "开始下载,完成后会弹安装", Toast.LENGTH_LONG).show();
} catch (Exception e) {
Log.e(TAG, "download failed", e);
Toast.makeText(this, "下载失败:" + e.getMessage(), Toast.LENGTH_LONG).show();
}
}
private void registerDownloadReceiver() {
downloadReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context ctx, Intent intent) {
if (!DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) return;
long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
if (id != pendingDownloadId) return;
File dir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
if (dir == null) return;
File apk = new File(dir, "maipulse-update.apk");
if (apk.exists()) installApkFile(apk);
}
};
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(downloadReceiver, filter, Context.RECEIVER_EXPORTED);
} else {
registerReceiver(downloadReceiver, filter);
}
}
private void installApkFile(File apk) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& !getPackageManager().canRequestPackageInstalls()) {
// 没有「安装未知应用」权限,跳设置页让用户开
Intent s = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + getPackageName()));
startActivity(s);
Toast.makeText(this, "允许安装后回到 App 再点一次更新", Toast.LENGTH_LONG).show();
return;
}
Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", apk);
Intent i = new Intent(Intent.ACTION_VIEW);
i.setDataAndType(uri, "application/vnd.android.package-archive");
i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(i);
} catch (Exception e) {
Log.e(TAG, "install failed", e);
Toast.makeText(this, "拉起安装器失败:" + e.getMessage(), Toast.LENGTH_LONG).show();
}
}
/* ---------------- Python 启动 ---------------- */
private void bootPython() {
try {
File files = getFilesDir();
File appDir = new File(files, "app");
File stamp = new File(files, "asset.rev");
// 内置资源版本号:打包时由 build_apk.ps1 写进 assets/app/asset.rev,
// 每次打包都会变,所以数据更新后一定能触发重建(手写常量太容易忘)。
String want = "";
try (InputStream in = getAssets().open("app/asset.rev")) {
byte[] b = new byte[128];
int n = in.read(b);
if (n > 0) want = new String(b, 0, n, "UTF-8").trim();
} catch (Exception ignored) {
}
String have = "";
try {
if (stamp.exists()) {
byte[] b = new byte[(int) stamp.length()];
try (InputStream in = new FileInputStream(stamp)) {
int n = in.read(b);
if (n > 0) have = new String(b, 0, n, "UTF-8").trim();
}
}
} catch (Exception ignored) {
}
boolean broken = !appDir.exists()
|| !new File(appDir, "app.py").exists()
|| !new File(appDir, "data/master.json").exists();
if (broken || want.isEmpty() || !want.equals(have)) {
tip("正在释放内置资源");
// 用户导入的机台配置要保住,其余整目录重建(否则旧资源永远不更新)
byte[] conf = null;
File confFile = new File(new File(appDir, "astral"), "config.py");
if (confFile.exists() && confFile.length() > 0) {
conf = new byte[(int) confFile.length()];
try (InputStream in = new FileInputStream(confFile)) {
int n = in.read(conf);
if (n <= 0) conf = null;
}
}
deleteTree(appDir);
copyAssetDir("app", appDir);
if (conf != null) {
File cf = new File(new File(appDir, "astral"), "config.py");
File parent = cf.getParentFile();
if (parent != null && !parent.exists()) parent.mkdirs();
try (OutputStream out = new FileOutputStream(cf)) {
out.write(conf);
}
}
try (OutputStream out = new FileOutputStream(stamp)) {
out.write(want.getBytes("UTF-8"));
}
}
tip("正在加载 Python 运行时…");
if (!Python.isStarted()) {
Python.start(new AndroidPlatform(this));
}
tip("正在启动本地服务…");
Python.getInstance().getModule("android_boot").callAttr("boot", files.getAbsolutePath());
} catch (Throwable t) {
Log.e(TAG, "boot failed", t);
tip("启动失败:" + t);
}
}
private void tip(final String text) {
runOnUiThread(() -> tip.setText(text));
}
private void deleteTree(File f) {
if (f == null || !f.exists()) return;
if (f.isDirectory()) {
File[] kids = f.listFiles();
if (kids != null) {
for (File k : kids) deleteTree(k);
}
}
f.delete();
}
private void copyAssetDir(String src, File dst) throws IOException {
AssetManager am = getAssets();
String[] list = am.list(src);
if (list == null || list.length == 0) {
copyAssetFile(src, dst);
return;
}
if (!dst.exists() && !dst.mkdirs()) {
throw new IOException("mkdirs failed: " + dst);
}
for (String name : list) {
copyAssetDir(src + "/" + name, new File(dst, name));
}
}
private void copyAssetFile(String src, File dst) throws IOException {
// 不跳过已存在文件:整目录重建后旧的残留必须被覆盖
File parent = dst.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("mkdirs failed: " + parent);
}
try (InputStream in = getAssets().open(src);
OutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[64 * 1024];
int n;
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
}
}
private void waitAndLoad() {
for (int i = 0; i < 200; i++) {
try (Socket sock = new Socket()) {
sock.connect(new InetSocketAddress("127.0.0.1", PORT), 300);
runOnUiThread(() -> {
tip.setVisibility(View.GONE);
web.loadUrl("http://127.0.0.1:" + PORT + "/");
});
return;
} catch (IOException ignored) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
return;
}
}
}
tip("服务没有起来,看看日志");
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQ_PICK_FILE) {
Uri[] result = null;
if (resultCode == RESULT_OK && data != null) {
if (data.getDataString() != null) {
result = new Uri[]{Uri.parse(data.getDataString())};
} else if (data.getClipData() != null) {
int n = data.getClipData().getItemCount();
result = new Uri[n];
for (int i = 0; i < n; i++) {
result[i] = data.getClipData().getItemAt(i).getUri();
}
}
}
if (filePathCallback != null) {
filePathCallback.onReceiveValue(result);
filePathCallback = null;
}
return;
}
super.onActivityResult(requestCode, resultCode, data);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK && web != null && web.canGoBack()) {
web.goBack();
return true;
}
return super.onKeyDown(keyCode, event);
}
@Override
protected void onDestroy() {
try {
if (downloadReceiver != null) unregisterReceiver(downloadReceiver);
} catch (Exception ignored) {
}
if (web != null) {
web.destroy();
web = null;
}
super.onDestroy();
}
}
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""Android 启动器:把 filesDir/app 挂进 sys.path,然后跑 Flask。
为什么不在 assets 里直接 import:app.py 顶层会 os.makedirs(logs),
assets 是只读的,必须用 Java 侧复制出来的可写副本。
"""
import os
import sys
PORT = 8787
def boot(files_dir):
base = os.path.join(files_dir, "app")
if base not in sys.path:
sys.path.insert(0, base)
astral = os.path.join(base, "astral")
if astral not in sys.path:
sys.path.insert(0, astral)
os.environ["ASTRAL_DIR"] = astral
os.environ.setdefault("MAIPULSE_PORT", str(PORT))
os.chdir(base)
import app as maipulse_app # noqa: E402
# 绑定 127.0.0.1:只有本机 WebView 能访问
maipulse_app.app.run(host="127.0.0.1", port=PORT, debug=False,
threaded=True, use_reloader=False)
return "exited"
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBg" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="iconBg">#FFE9A8</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">maipulse</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">#EEF2F9</item>
<item name="android:navigationBarColor">#FFFFFF</item>
<item name="android:windowBackground">#EEF2F9</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="downloaded" path="Download/" />
<external-cache-path name="cache" path="." />
</paths>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '8.2.2' apply false
id 'com.chaquo.python' version '16.1.0' apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.9-bin.zip
networkTimeout=60000
validateDistributionUrl=false
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'maipulse'
include ':app'
+7
View File
@@ -0,0 +1,7 @@
# 统一换行符,避免 Windows/Linux 来回转换把每个文件都标成 modified
* text=auto
*.py text eol=lf
*.md text eol=lf
*.cs text eol=crlf
*.csproj text eol=crlf
*.json text eol=lf
+40
View File
@@ -0,0 +1,40 @@
# ---- 运行产物 ----
# 每账号一份用户数据快照(<userId>.json)
data/
logs/
__pycache__/
*.pyc
*.pyo
# ---- 本地查证用(含 SEGA 二进制 / 反编译代码 / 真机日志,不入库)----
source/
# ---- 私有配置:只提交 config.py.example,不提交 config.py ----
config.py
# ---- 文档单独放 docs 分支,主分支不含 ----
docs/
# ---- .NET 构建产物 ----
gamehook/bin/
gamehook/obj/
# ---- netdelivery 下载目录(.app / .opt 更新包很大,不入库)----
download/
# ---- get_auth_list:clientId 遍历脚本与其产物(默认不入库、不推送)----
# 产物默认落在 data/ 下(data/ 本就已经整目录忽略);
# authentication.* 再兜一层,防止用 --csv/--state 指到别处
get_auth_list.py
authentication.*
# ---- extra_data 用的节点 JSON(含账号数据,不入库)----
extra*.json
# ---- 编辑器 / 系统 ----
.vs/
.vscode/
.idea/
*.user
Thumbs.db
Desktop.ini
+499
View File
@@ -0,0 +1,499 @@
<p align="center">
<img src="icon/Astral.png" alt="Astral" width="220">
</p>
<h1 align="center">Astral</h1>
<p align="center">
<b>舞萌DX 的游戏助手</b><br>
宗旨:让舞萌DX的玩家能够更好地游玩游戏,获得快乐。
</p>
<p align="center">AI真的很好用你知道吗</p>
---
## 这是什么
Astral 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍:
扫码登录 → 取数据 → 上传成绩 / 解锁歌曲 → 登出。
> 协议实现全部来自反编译源码与真机网络日志的双向验证,
> 每个关键常量在代码注释里都标了**源码行号**。
---
## 当前状态:v0 plus 附加功能
| 脚本 | 状态 |
|---|---|
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
| **`unlockmusic.py`** | ✅ **可用** —— 解锁(解禁)歌曲 / 难度(复用 `upsert.py` 整套流程) |
| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票 |
| **`use_ticket.py`** | ✅ **可用** —— 消耗当前持有的功能票 |
| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生) |
| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(只读) |
| `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** |
**文档**在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs),见文末[文档索引](#文档索引)。
---
## 一个教训:别把一件事分段交给 AI
v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结果:
| 项目 | Token 消耗 |
|---|---|
| **v0 有效开发** | **473M+** |
| 含中途废弃的分支项目 | **1137.5M** |
**这 1137.5M 里有一大半是浪费掉的。**
真正的教训是:
> **不要把一个东西分段交给 AI,而是直接设定一个目标,然后让它自己跑完。**
分段交接会让 AI 反复丢失上下文、重复劳动、在已经解决的问题上打转 ——
而一个清晰的目标 + 足够长的执行窗口,反而更快也更省。
---
## 项目框架
```
Astral/
├── config.py 运行时可配置项(机台标识 / 歌曲数据 / 日志级别)★ 需自建
├── config.py.example 配置模板
├── icon/ Logo
├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出
├── unlockmusic.py 主脚本:解锁(解禁)歌曲(复用 upsert.py 的 run())
├── buy_ticket.py 主脚本:购买功能票
├── use_ticket.py 主脚本:消耗当前持有的功能票
├── reincarnate.py 主脚本:账号数据迁移(转生)
├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包
├── selftest.py 零网络自检(284 项,判据 = 源码 + 真机日志)
│
├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本)
│ ├── __init__.py 统一导出 + 日志自动跟踪
│ ├── MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
│ ├── LogSetup.py 日志(INFO / DEBUG 双模式)
│ ├── ChargeGuard.py 买票前置闸门(必须先跑过 GetUserCharge)
│ ├── UpsertUserAll.py 上传成绩 / 解锁歌曲(36 节点 / 110 字段 / 差分)
│ ├── GetNeedUserData.py 结算快照编排 + 落盘 data/<userId>.json
│ └── ... 40+ 个接口脚本
│
├── data/ 每账号一份用户数据快照(<userId>.json)★ 不入库
├── download/ netdelivery.py 下载的 .app / .opt ★ 不入库
└── logs/ 日志输出(latest.log + 日期.log)★ 不入库
```
**设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。
主脚本只负责编排,日志和协议细节都在 `api/` 内部。
**命名约定**:**文件名 = 函数名**,都按首字母大写的驼峰切分,一一对应:
| 服务端接口 | 脚本 | 函数 |
|---|---|---|
| `GetUserDataApi` | `api/GetUserData.py` | `GetUserData()` |
| `UpsertUserAllApi` | `api/UpsertUserAll.py` | `UpsertUserAll()` |
| `UserLoginApi` | `api/UserLogin.py` | `UserLogin()` |
服务端 API 名里带 `Get` 的,脚本名与函数名一律带 `Get`(不要模棱两可)。
### 分支说明
| 分支 | 内容 |
|---|---|
| **`mai`** | 主分支 —— 代码 |
| **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 |
两个分支**没有共同的提交历史**,各自独立。
> 🛠 **给开发者**:协议实现对照的反编译源码(`Assembly-CSharp` / `AMDaemon.NET` 等)
> 不入库,需要自己准备一份放在 `source/`;代码注释里的行号都指向它。
> 只是用工具的话**完全不需要**这个目录。
---
## 快速开始
### 1. 装依赖
```bash
pip install requests pycryptodome
```
### 2. ★ 先填 `config.py`
仓库里只有模板 `config.py.example`,先复制一份:
```bash
cp config.py.example config.py # Linux / macOS
copy config.py.example config.py # Windows
```
然后**必须**填好 **`config.py` 第二节「机台与店铺标识」**:
| 变量 | 填什么 |
|---|---|
| `CLIENT_ID` | Keychip 短值(15 位 Keychip 去掉连字符取前 11 位),形如 `A63E01ABCDE` |
| `PLACE_ID` | 你所在店铺 ID |
| `REGION_ID` | 你所在区域 ID |
| `PLACE_NAME` | 店铺名 |
| `REGION_NAME` | 区域名 |
| `KEYCHIP_ID` | **由上面两个值自动推导**,不要手改 |
> ⚠️ 填错的话,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。
>
> `config.py` 已在 `.gitignore` 里 —— 它是你的私有配置,**不要提交**。
### 3. 跑自检
```bash
python selftest.py # 零网络自检,应该 284 项全绿(不依赖你的 config)
```
### 主脚本一览
| 脚本 | 干什么 | 副作用 | 需要二维码 |
|---|---|---|---|
| `upsert.py` | 上传一局成绩 | ★ 有 | ✅ |
| `unlockmusic.py` | 解锁(解禁)歌曲 / 难度 | ★ 有 | ✅ |
| `buy_ticket.py` | 购买功能票 | ★ 有 | ✅ |
| `use_ticket.py` | 消耗当前持有的功能票 | ★ 有 | ✅ |
| `reincarnate.py` | 账号数据迁移(转生) | ★ 有 | ✅(目标号;源号可选) |
| `netdelivery.py` | 查 / 下 `.app` / `.opt` 更新包 | 只读 | ❌ |
| `selftest.py` | 零网络自检 | 无 | ❌ |
> **所有有副作用的脚本默认都不发包**:不传 `--send` 时只构造报文(dry-run)。
> 想看报文而不产生任何后果,先跑一次不带 `--send` 的。
> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**(不是按次数计量的)。
---
## upsert.py —— 上传一局成绩
```bash
# 预检:登录 + 抓快照 + 组包,不发包(零副作用,建议先跑这个)
python upsert.py "<二维码>"
# 真实上传一局成绩(★ 有副作用,不可撤销)
python upsert.py "<二维码>" --send
# 常用开关
python upsert.py "<二维码>" --send --debug # 日志里带完整收发报文
python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 15 秒冷却
python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库回查
python unlockmusic.py "<二维码>" --send --music 11414 # 解锁歌曲 / 解禁难度
```
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。**
### 额外塞节点:`--extra-data`
想手工覆盖某几个节点(比如本局拿到的新 KaleidxScope 门):
```bash
python upsert.py "<二维码>" --send --extra-data extra.json
```
键必须是 `upsertUserAll` 的 **36 个节点**之一,写错会报错并列出可用节点名。
**JSON 在扫码之前就校验**,写错直接退出,不浪费二维码。
> ⚠️ 在 PowerShell / CMD 里写内联 JSON 很容易被吞掉双引号,**建议一律用文件**。
### ★ `isNew*` 位串:0 / 1 / 2 的含义
每个数组节点都配一条 `isNew*` 位串,**一位对应数组里的一条**(顺序必须一致)。
依据 `source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:309-364` 的 `BuildListData()`:
| 位 | 含义 | 服务端行为 |
|---|---|---|
| `0` | **存量更新** —— 旧数据里**已有**同主键的条目 | UPDATE |
| `1` | **新增** —— 旧数据里**没有**同主键的条目 | INSERT |
| `2` | **无变化** —— 只有 `userFavoriteList` 用,其他节点不会出现 | 跳过 |
主键见 `api/UpsertUserAll.py` 的 `DIFF_PRIMARY_KEYS`
(如 `userKaleidxScopeList` 用 `gateId`、`userMusicDetailList` 用 `musicId`+`level`)。
> ⚠️ **对已存在的条目填 `1` 会 HTTP 500** —— 服务端会去 INSERT 一条重复记录。
> 真机抓包样本里 `isNewFavoriteList = "22212"`,可以直观看到 0/1/2 混用的样子。
> **不确定填什么?** 先看 `data/<你的userId>.json` 里对应的节点:
> 主键已存在 → `0`,不存在 → `1`。
---
## unlockmusic.py —— 解锁(解禁)歌曲
```bash
python unlockmusic.py "<二维码>" --music 11414 # 预检,不发包
python unlockmusic.py "<二维码>" --music 11414 --send # ★ 真实解锁
python unlockmusic.py "<二维码>" --music 1085,11414 --send # 多首,逗号分隔
python unlockmusic.py "<二维码>" --music 11414 --send --no-unlock-remaster
```
**它没有自己的协议实现** —— 整个流程直接复用 `upsert.py` 的 `run()`:
扫码 → 预览 → 登录 → `UpsertUserAll` → 落库回查 → 登出,一模一样。
唯一多出来的东西是「要解锁哪些歌」这个入参。
**解锁也不是单独的接口** —— 它是往 `userItemList` 里补几条 `UserItem`:
| itemKind | 解锁什么 |
|---|---|
| `5` | 歌曲本体(`MusicUnlockList`) |
| `6` | Master 难度(`MusicUnlockMasterList`) |
| `7` | Re:Master 难度(`MusicUnlockReMasterList`) |
条目形如 `{"itemKind":5,"itemId":1085,"stock":1,"isValid":true}`。
- **标准谱(`musicId < 10000`)的 Master / Re:Master 天生解锁**,只写 `kind5`;
只有 DX 谱(`>= 10000`)才需要写 `kind6` / `kind7`。
- 服务器上**已存在的条目会自动跳过** —— 重复插入会让服务端直接 HTTP 500。
- Re:Master 还受活动开关约束,那是机台侧的事,脚本改不了;写了也可能进游戏仍是灰的。
| 参数 | 说明 |
|---|---|
| `--music ID[,ID...]` | 要解锁的 musicId,可重复、可逗号分隔(`--unlock-music` 也认) |
| `--send` | 真正发包(不加就是预检,零副作用) |
| `--no-unlock-master` | 不解禁 Master |
| `--no-unlock-remaster` | 不解禁 Re:Master |
| `--cooldown N` / `--no-verify` / `--debug` | 和 `upsert.py` 完全一样 |
> 依据反编译源码 `VOExtensions.cs:981-1008`(四个解锁列表并进 `userItemList`)
> 与 `NotesListManager.cs:415/455`(`if (id < 10000) return true;`)。
> 这部分**没有真机抓包可对照**(机台侧的解锁不上报),是按源码实现的。
---
## buy_ticket.py —— 购买功能票
```bash
python buy_ticket.py "<二维码>" --list # 登录后只列当前票券,不购买
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
```
| chargeId | 票 | 上报的 `price` |
|---|---|---|
| 2 | 2倍チケット | 1 |
| 3 | 3倍チケット | 2 |
| 5 | 5倍チケット | 4 |
> 默认 `--charge-id 5`。**不同店铺上架的票不一样**,买之前先 `--list`。
### 它做的安全检查
1. 跑前查 `GetUserChargeApi` —— **账号已持有功能票时直接拒绝购买**(重复购买有封号风险)
2. 买票前 **15 秒等待**(贴近真机节奏)
3. 跑后再查一次确认状态
> **票源只看 `GetUserChargeApi`**(账号自己的票券表),不再看 `GetGameChargeApi`
> (那是机台本地的上架清单,跟账号状态无关)。
>
> `UpsertUserChargelog` 内部还有两道硬闸门:
> **必须先跑过 `GetUserCharge`**(`api/ChargeGuard.py` 记账),
> 以及**同一账号同一进程只允许买一次票** —— 都拦在发包之前。
---
## use_ticket.py —— 消耗当前持有的功能票
```bash
python use_ticket.py "<二维码>" --list # 只列当前持有的票
python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗
```
**原理**:`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList` **格式完全相同**,
所以把该节点里目标票的 `stock` 减 1,再让 playlog 带上 `useTicketId` / `playCredit`,
服务端就认为这张票被这一局用掉了。
> **典型用途**:账号手里挂着票时 `buy_ticket.py` 会拒绝再买;
> 想再买一张就先用这个把它用掉。
详细原理与报文依据见 [功能票使用说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/功能票使用说明.md)。
---
## reincarnate.py —— 账号转生
把一个账号(源 A)的数据克隆到另一个账号(目标 B)。
```bash
# 源数据来自二维码(会联网抓 A 的数据并落盘到 data/<A>.json)
python reincarnate.py --dst-qr "<B的二维码>" --src-qr "<A的二维码>"
# 源数据来自已保存的 JSON(离线,推荐)
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --plan
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --send
```
**它干了什么**:
1. 取源账号 A 的 19 个节点数据(联网抓或读 `data/<A>.json`)
2. 用目标账号 B 的二维码登录
3. 把 A 的 `userMusicDetailList` **分批**上传到 B —— 每包最多 4 首,
并自动避开**被服务端禁止的 2 首包**(`[4,4,2]` → `[4,3,3]`)
4. 每包之间:**等待 → 上传 → 登出(type=1)→ 续关登录**,循环到传完
5. 每包的 playlog 从曲目记录**反推合成**(`achievement` / `deluxscore` / `scoreRank` …)
| 参数 | 默认 | 说明 |
|---|---|---|
| `--plan` | — | 只列分批计划,不发包(**建议先跑这个**) |
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** |
| `--wait N` | 5.0 | 每次上传前的等待秒数 |
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认保留 B 自己的) |
> ⚠️ **未实跑过。** 建议先 `--plan`,再 `--limit 8` 小批量试。
完整流程、分批规则、续关与登出类型的依据见 [账号转生说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/账号转生说明.md)。
---
## netdelivery.py —— 查 / 下 `.app` / `.opt` 更新包
```bash
python netdelivery.py # 用 config.py 里的机台查,然后**多选下载**
python netdelivery.py A63E01ABCDE # 指定 Keychip
python netdelivery.py --list # 只列清单 + 直链,不下载
python netdelivery.py --all # 全下(不交互)
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
python netdelivery.py --out D:\SDGB2026\update
```
交互选择支持:逗号/空格分隔、区间(`1-3`)、`a`=全选、`n`=全不选、`q`=退出。
下载带**断点续传**与进度/速率显示,默认存到 `download/`(不入库)。
> 这是**只读**操作,不写任何账号数据。
> 需要同目录下的 `authv1.py` 提供 ALL.Net 协议层。
---
## 主脚本怎么写
**核心原则:不要为每个 api 调用写日志。** 日志已经在 `api/` 里了。
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from api import (setup_logging, teardown_logging,
ParseQRCode, GetUserPreview, UserLogin, UserLogout, LogoutType,
UpsertUserAll)
from config import REGION_ID, PLACE_ID
def main() -> int:
setup_logging("我的脚本") # ← 只需要这一句
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
user_id, token = ParseQRCode(sys.argv[1])
session.update(user_id=user_id, token=token)
GetUserPreview(user_id, token) # 顶号保护
login = UserLogin(user_id, token,
region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ★ 一个函数跑完整条结算链路
UpsertUserAll(user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
dry_run=False, confirm=True)
return 0
finally:
if session["logged_in"]: # ★ 一定要保证登出
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.Logout,
region_id=REGION_ID, place_id=PLACE_ID)
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
```
三条铁律:
1. **不要写日志** —— 每个 api 函数会自动打印 `=== 函数名 开始运行 ===` / `[运行结果]`
2. **一定要登出** —— 账号卡在 `isLogin=True` 时下次登录会 `returnCode=100`
3. **写接口要显式开闸门** —— 只写 `dry_run=False` 不够,必须同时给 `confirm=True`
完整指南见 [主脚本编写指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/主脚本编写指南.md)。
---
## 已完成 / 将来
**已完成(v0)**
- ✅ 扫码登录 / 登出 / 心跳,完整会话链路
- ✅ 上传一局成绩(36 节点 / 110 字段 / 差分 / 落库回查)
- ✅ 购买功能票
- ✅ 消耗持有的功能票
- ✅ 账号数据快照落盘(`data/<userId>.json`)
- ✅ 账号数据迁移(转生)
- ✅ 查 / 下 `.app` / `.opt` 更新包
- ✅ 解锁歌曲 / 解禁 Master 与 Re:Master 难度
- ✅ 零网络自检 284 项
- ✅ MelonLoader 抓包 Mod(`gamehook/`)
**将来可能会加**
- **网页控制** —— 用浏览器代替命令行操作
- 更多游戏功能(任务 / 地图 / 收藏夹 / 排行榜……)
- 更完整的真机验证覆盖
- `reincarnate.py` 的实跑验证
---
## 文档索引
全部在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs):
| 文档 | 内容 |
|---|---|
| [项目总览](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/项目总览.md) | 开发向主文档,该放哪 / 该怎么写 |
| [主脚本编写指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/主脚本编写指南.md) | 写新主脚本的规范 |
| [上传成绩开发文档](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/上传成绩开发文档.md) | `UpsertUserAll` 的 36 节点 / 110 字段 |
| [UpsertUserAll踩坑清单](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/UpsertUserAll踩坑清单.md) | 28 条踩坑记录 |
| [功能票使用说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/功能票使用说明.md) | 买票 / 用票的完整原理与报文依据 |
| [账号转生说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/账号转生说明.md) | `reincarnate.py` 完整流程 |
| [netdelivery说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/netdelivery说明.md) | DLI 协议与运行原理 |
| [常见坑与注意事项](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/常见坑与注意事项.md) | 二维码 / 账号状态 / 副作用 / 日志 / 网络 |
| [购买功能票流程](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/购买功能票流程.md) | 买票接口的完整排查过程 |
| [购买功能票_存档与交接](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/购买功能票_存档与交接.md) | 排查存档 |
| [真机Hook对照报告](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/真机Hook对照报告.md) | 抓包 Mod 对照 |
| [实跑验证记录](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/实跑验证记录.md) | 每次实跑的结果 |
| [舞萌DX_扫码登录协议技术规范](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/舞萌DX_扫码登录协议技术规范.md) | aimedb 扫码协议 |
| [日志与主脚本指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/日志与主脚本指南.md) | 日志系统说明 |
---
## 特别鸣谢
- **志成🍥** —— 提供脱壳 DLL
- [**WorkBuddy AI**](https://www.workbuddy.ai/)—— 提供 AI 模型
- **DeepSeek V4.1 Flash** —— 主要代码编写
- **GPT-Image-2** —— Logo 设计
- [**NVIDIA NIM**](https://build.nvidia.com/models)、
[**AMD**](https://developer.amd.com.cn/radeon/tokenfactory)、
[**Kilo**](https://kilo.ai/)—— 提供 AI 模型
- [**eaquira**](https://git.fragrance.moe/Fragrance/eaquira)—— 思路启发
- **神秘群U** —— 私有科技的 Web 启发(暂无)
- **香水所有群友** —— 和谐帮助
*(还有什么以后补充)*
---
## 免责声明
本项目仅供**学习与协议研究**使用。使用本工具产生的任何后果由使用者自行承担。
请遵守游戏服务条款,不要用于破坏游戏公平性。
+150
View File
@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""
ChargeGuard.py —— 买票前置闸门(GetUserCharge 必须已经跑过)
================================================================================
★ 这个模块解决什么问题:
````
错误写法(危险):
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
# ↑ 没先查 GetUserCharge —— 万一账号已经持有一张功能票,
# 服务端看到的就是「重复购买」,有封号风险。
```
正确写法:
```
held = GetUserCharge(user_id) # ① 先查:有没有持票
...
UpsertUserChargelog(user_id, 2, ...) # ② 再买
```
本模块在 ① 里记一笔「这个 user_id 刚查过票券」,在 ② 里检查这笔记录,
没查过就抛 `ChargelogGuardError`(**未发包**)。
★ 还顺手挡第二件事:**同一个账号在同一个进程里只允许买一次票**。
真机买票是「买完立刻被这一局用掉」(stock--),正常状态 stock 恒为 0;
脚本连着买两张是明显的异常行为,这里直接拦掉。
★ 记忆只存在于**当前进程**(内存字典),脚本退出即清空 —— 不做持久化,
免得下次运行时被上一次的记录误拦。
依据:
source/Assembly-CSharp/Manager/TicketManager.cs:130-174 UseTicketPrepare
source/Assembly-CSharp/Manager/TicketManager.cs:234-253 RequestUserCharge
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserCharge.cs:20
source/Assembly-CSharp/Net.Packet.Mai2/PacketUploadUserChargelog.cs:19
"""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
#: GetUserCharge 结果的「新鲜度」窗口(秒)。
#: 超过这个时间才买票,认为那次查询已经过期,需要重查。
CHARGE_READ_FRESH_SEC = 600.0
#: user_id -> 最近一次 GetUserCharge 的 monotonic 时刻
_LAST_READ: Dict[int, float] = {}
#: user_id -> 最近一次 GetUserCharge 返回的 userChargeList(原样)
_LAST_LIST: Dict[int, List[Dict[str, Any]]] = {}
#: user_id -> 本进程内已经买过的 chargeId 列表
_PURCHASES: Dict[int, List[int]] = {}
# =============================================================================
# 一、GetUserCharge 侧:记一笔
# =============================================================================
def note_user_charge_read(
user_id: int,
charge_list: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""
记录「刚刚为这个 user_id 查过票券」。
★ 由 GetUserCharge() 在拿到响应后自动调用,调用方不用管。
"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_LAST_READ[uid] = time.monotonic()
if isinstance(charge_list, list):
_LAST_LIST[uid] = [c for c in charge_list if isinstance(c, dict)]
def last_read_age(user_id: int) -> Optional[float]:
"""距上次 GetUserCharge 过了多少秒;从没查过返回 None。"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return None
ts = _LAST_READ.get(uid)
if ts is None:
return None
return max(0.0, time.monotonic() - ts)
def has_fresh_read(
user_id: int,
max_age: float = CHARGE_READ_FRESH_SEC,
) -> bool:
"""这个 user_id 最近(max_age 秒内)查过 GetUserCharge 吗?"""
age = last_read_age(user_id)
if age is None:
return False
return age <= float(max_age)
def last_charge_list(user_id: int) -> List[Dict[str, Any]]:
"""最近一次 GetUserCharge 返回的 userChargeList(没有则空表)。"""
try:
return list(_LAST_LIST.get(int(user_id)) or [])
except (TypeError, ValueError):
return []
def held_charges(user_id: int) -> List[Dict[str, Any]]:
"""最近一次查到的「持有中」(stock > 0)的票券。"""
return [c for c in last_charge_list(user_id) if int(c.get("stock", 0) or 0) > 0]
# =============================================================================
# 二、UpsertUserChargelog 侧:买之前查
# =============================================================================
def note_charge_purchase(user_id: int, charge_id: int) -> None:
"""记录「这个 user_id 刚刚买了一张 chargeId 的票」。"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_PURCHASES.setdefault(uid, []).append(int(charge_id))
def purchased_charge_ids(user_id: int) -> List[int]:
"""本进程内这个 user_id 已经买过的 chargeId。"""
try:
return list(_PURCHASES.get(int(user_id)) or [])
except (TypeError, ValueError):
return []
def reset(user_id: Optional[int] = None) -> None:
"""清空记忆(测试用;不传 user_id 就全清)。"""
if user_id is None:
_LAST_READ.clear()
_LAST_LIST.clear()
_PURCHASES.clear()
return
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_LAST_READ.pop(uid, None)
_LAST_LIST.pop(uid, None)
_PURCHASES.pop(uid, None)
+311
View File
@@ -0,0 +1,311 @@
# -*- coding: utf-8 -*-
"""
Chime.py —— 扫码登录(二维码 -> userId + token)
================================================================================
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
对应接口:POST https://ai.sys-allnet.cn/wc_aime/api/get_data
--------------------------------------------------------------------------------
协议依据
--------------------------------------------------------------------------------
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
CCommGetUserData_Create(strGameID, strChipID, strCommonKey,
strQRData, strTitleKey, serverURLIndex)
strGameID = "MAID"
strChipID = KeychipID
strQRData = 二维码串[20:] <- 见 CCommQRCode.EraseHashIdentifier()
strTitleKey= "SDGB"
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommQRCode.cs
IsValid() : 前缀 "SGWC"(Chime.WechatID) + 偏移 4 处为 gameID
GetTimeStamp() : [8:20] 12 位 yymmddHHMMss
EraseHashIdentifier() : [20:] 64 位载荷
source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250-255
KeychipID = AMDaemon.System.KeychipId.Value
TitleKey = AMDaemon.System.GameId
GameID = "MAID"
docs/舞萌DX_扫码登录协议技术规范.md
- 签名 key = SHA-256(chipID + timestamp + SIGN_KEY) 大写 hex
- ★ 请求头【绝不能】带 Content-Type,否则服务端返回 errorID=57
- qrCode 字段传的是 64 位载荷,不是完整 84 字符串
--------------------------------------------------------------------------------
调用示例
--------------------------------------------------------------------------------
from api import ParseQRCode
user_id, token = ParseQRCode("SGWCMAID2610022219463A...")
# 也可以直接传二维码 URL
user_id, token = ParseQRCode("https://wq.wahlap.net/qrcode/req/MAID261002....html?l=...")
"""
from __future__ import annotations
import hashlib
import http.client
import json
import re
import ssl
from datetime import datetime
from typing import Tuple
from urllib.parse import urlparse
from config import KEYCHIP_ID
# =============================================================================
# aimedb 扫码登录常量(原在 config.py,2026-10-06 下沉到本模块)
# =============================================================================
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
# + docs 分支《舞萌DX_扫码登录协议技术规范》
#: aimedb 服务器
AIMEDB_HOST = "ai.sys-allnet.cn"
AIMEDB_PORT = 443
AIMEDB_PATH = "/wc_aime/api/get_data"
#: 签名密钥(含前导 X,32 字符)
#: 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
#: 固定字段
QR_OPEN_GAME_ID = "MAID"
QR_TITLE_KEY = "SDGB"
QR_USER_AGENT = "WC_AIME_LIB"
from .LogSetup import log_debug, log_error, log_info, log_request, log_response
# =============================================================================
# 二维码格式常量(CCommQRCode.cs + 技术规范)
# =============================================================================
QR_PREFIX = "SGWC" # Chime.WechatID
QR_GAME_ID = "MAID"
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
QR_PAYLOAD_LEN = 64
QR_TOTAL_LEN = QR_PREFIX_LEN + QR_PAYLOAD_LEN # 84
#: 默认机台 chipID
#:
#: ★★ 必须用**完整形式**(带连字符),不是短值 —— 这是个容易搞错的点:
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
#: KeychipID = AMDaemon.System.KeychipId.Value; // 完整形式
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:255
#: Chime.Instance.Init(GameID, KeychipID, _chimeDevice, TitleKey);
#: 而游戏服务器那边用的是短值:
#: source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
#: clientId = AMDaemon.System.KeychipId.ShortValue; // 短值
#:
#: 即:aimedb 扫码用完整 KeychipId,游戏服务器用 ShortValue。
#: docs 分支的《扫码登录协议技术规范》§3.4 里也有对应的抓包验证向量。
#: token 与 chipID 绑定,用错会导致后续接口「returnCode=1 但不落库」。
DEFAULT_CHIP_ID = KEYCHIP_ID
# =============================================================================
# 内部工具
# =============================================================================
def _extract_qr_from_url(url: str) -> str:
"""
从二维码 URL 提取完整 84 字符串。
URL 形如:
https://wq.wahlap.net/qrcode/req/MAID2610022219463A....html?l=...&t=...&d=...
取 /qrcode/req/ 与 .html 之间的 80 字符,前面补 "SGWC"。
"""
path = urlparse(url).path
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
if not match:
raise ValueError("URL 路径格式不符合预期,无法提取二维码数据:" + url)
return QR_PREFIX + match.group(1)
def _normalize_qr(qr_input: str) -> str:
"""URL 或字符串 -> 标准 84 字符二维码串(含格式校验)。"""
qr = (qr_input or "").strip()
if qr.startswith("http://") or qr.startswith("https://"):
log_debug("输入是 URL,先提取二维码字符串")
qr = _extract_qr_from_url(qr)
if len(qr) != QR_TOTAL_LEN:
raise ValueError(
"二维码长度应为 " + str(QR_TOTAL_LEN) + " 字符,实际 " + str(len(qr)) + " 字符:" + qr
)
if not qr.startswith(QR_PREFIX):
raise ValueError("二维码必须以 '" + QR_PREFIX + "' 开头:" + qr)
if qr[4:8] != QR_GAME_ID:
raise ValueError(
"二维码 GameID 应为 '" + QR_GAME_ID + "',实际 '" + qr[4:8] + "':" + qr
)
if not all(c in "0123456789ABCDEF" for c in qr[QR_PREFIX_LEN:]):
raise ValueError("二维码载荷必须为大写十六进制:" + qr)
log_debug(
"二维码校验通过:timestamp=" + qr[8:20]
+ " payload_len=" + str(len(qr[QR_PREFIX_LEN:]))
)
return qr
def _build_body(qr_string: str, chip_id: str) -> Tuple[bytes, str, str]:
"""
构造请求体。
签名(技术规范 §4.1):
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
★ 拼接里不含 qrCode、不含 titlekey
qrCode 字段(CCommQRCode.EraseHashIdentifier):
qr_string[20:] 即 64 位载荷
返回:
(body_bytes, timestamp, key)
"""
timestamp = datetime.now().strftime("%y%m%d%H%M%S")
key = hashlib.sha256((chip_id + timestamp + QR_SIGN_KEY).encode()).hexdigest().upper()
body_dict = {
"chipID": chip_id,
"openGameID": QR_OPEN_GAME_ID,
"key": key,
"qrCode": qr_string[QR_PREFIX_LEN:],
"timestamp": timestamp,
"titlekey": QR_TITLE_KEY,
}
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode("utf-8")
return body_bytes, timestamp, key
def _send(body_bytes: bytes) -> dict:
"""
发送 HTTPS POST 并解析 JSON 响应。
★ 请求头只放 Connection / User-Agent / Content-Length。
绝不能加 Content-Type —— 加了会得到 errorID=57(技术规范 §3.3)。
"""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
headers = {
"Connection": "Keep-Alive",
"User-Agent": QR_USER_AGENT,
"Content-Length": str(len(body_bytes)),
# ★ 此处刻意不加 Content-Type
}
conn = http.client.HTTPSConnection(AIMEDB_HOST, AIMEDB_PORT, context=ctx, timeout=20)
try:
conn.request("POST", AIMEDB_PATH, body=body_bytes, headers=headers)
resp = conn.getresponse()
text = resp.read().decode("utf-8")
status = resp.status
finally:
conn.close()
log_response(status=status, body=text, api_name="aimedb/get_data")
try:
return json.loads(text)
except Exception as exc:
raise RuntimeError("aimedb 响应不是合法 JSON:" + text[:300]) from exc
def _parse(resp: dict) -> Tuple[int, str]:
"""解析响应,返回 (user_id, token)。"""
error_id = resp.get("errorID", -1)
user_id = resp.get("userID", -1)
token = resp.get("token", "") or ""
if error_id != 0:
raise RuntimeError(
"扫码登录失败:errorID=" + str(error_id) + ",响应=" + json.dumps(resp, ensure_ascii=False)
+ "\n 提示:errorID=57 通常是二维码已失效/已被使用,或请求头带了 Content-Type"
)
if user_id == -1 or not token:
raise RuntimeError(
"扫码登录失败:userID=" + str(user_id) + ", token='" + token + "'"
)
return int(user_id), str(token)
# =============================================================================
# 公开 API
# =============================================================================
def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int, str]:
"""
解析二维码并换取 userId 与 token。
参数:
qr_input: 84 字符二维码串(SGWCMAID...),或二维码 URL
chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID)
返回:
(user_id, token) 元组,可直接传给 GetUserPreview / UserLogin 等
异常:
ValueError: 二维码格式不合法
RuntimeError: 服务器返回错误或网络异常
"""
log_info("解析二维码登录:chip_id=" + chip_id)
qr_string = _normalize_qr(qr_input)
body_bytes, timestamp, key = _build_body(qr_string, chip_id)
log_request(
url="https://" + AIMEDB_HOST + ":" + str(AIMEDB_PORT) + AIMEDB_PATH,
body={
"chipID": chip_id,
"openGameID": QR_OPEN_GAME_ID,
"key": key,
"qrCode": qr_string[QR_PREFIX_LEN:],
"timestamp": timestamp,
"titlekey": QR_TITLE_KEY,
},
headers={"Connection": "Keep-Alive", "User-Agent": QR_USER_AGENT,
"Content-Length": str(len(body_bytes))},
api_name="aimedb/get_data",
)
log_info("发送登录请求:timestamp=" + timestamp + " host=" + AIMEDB_HOST + AIMEDB_PATH)
resp = _send(body_bytes)
user_id, token = _parse(resp)
log_info("扫码登录成功:user_id=" + str(user_id) + " token=" + token[:8] + "...(长度 "
+ str(len(token)) + ")")
return user_id, token
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
import sys
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chime_test")
try:
if len(sys.argv) < 2:
print("用法: python -m api.chime <二维码串或URL> [chip_id]")
return 1
chip = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_CHIP_ID
user_id, token = ParseQRCode(sys.argv[1], chip_id=chip)
print("")
print("user_id =", user_id)
print("token =", token)
return 0
except Exception as exc:
log_error("扫码登录失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
+53
View File
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
"""
GetGameCharge.py —— 取游戏计费配置(GetGameChargeApi)
================================================================================
取服务器下发的计费档位。
对应接口:GetGameChargeApi
请求体 :{"isAll": false}(GameChargeRequestVO)
响应 :{length, gameChargeList:[{orderId,chargeId,price,startDate,endDate}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameChargeRequestVO.cs
source/2026-10-05.log:9 真机请求 {"isAll":false}
调用示例:
from api import GetGameCharge
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
class GameChargeRequestVO:
"""请求 VO(对应 GameChargeRequestVO)。"""
isAll: bool = False
def GetGameCharge(
*,
is_all: bool = False,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取游戏计费配置(对应 GetGameChargeApi)。
返回:
原始响应 dict,含 gameChargeList
"""
log_info("取游戏计费配置:isAll=" + str(is_all))
return request_dict(
"GetGameChargeApi", GameChargeRequestVO(isAll=is_all),
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
+175
View File
@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
"""
GetGameSettings.py —— 获取游戏设置(GetGameSettingApi)
================================================================================
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
对应接口:GetGameSettingApi
请求体 :{"placeId": <店铺ID>, "clientId": "<机台短值>"}
source/Assembly-CSharp/Net.VO.Mai2/GameSettingRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:21-24
★ 真机调用时机:会话最开始、Ping 之前(source/2026-10-05.log:1)。
调用示例:
from api import GetGameSettings
resp = GetGameSettings()
print(resp.gameSetting.isMaintenance, resp.gameSetting.requestInterval)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from config import CLIENT_ID, PLACE_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
# =============================================================================
# VO(字段对照 source/Assembly-CSharp/Net.VO.Mai2/GameSetting.cs 与真机响应)
# =============================================================================
@dataclass
class GameSetting:
"""游戏运行时配置(对应 Net.VO.Mai2.GameSetting)。"""
isMaintenance: bool = False
requestInterval: int = 0
rebootStartTime: str = ""
rebootEndTime: str = ""
rebootInterval: int = 0
movieUploadLimit: int = 0
movieStatus: int = 0
movieServerUri: str = ""
deliverServerUri: str = ""
oldServerUri: str = ""
usbDlServerUri: str = ""
maxCountRivalMusic: int = 0
replicationDelayLimit: int = 0
exclusionStartTime: str = ""
exclusionEndTime: str = ""
pingDisable: bool = False
packetTimeout: int = 0
packetTimeoutLong: int = 0
packetRetryCount: int = 0
userDataDlErrTimeout: int = 0
userDataDlErrRetryCount: int = 0
userDataDlErrSamePacketRetryCount: int = 0
userDataUpSkipTimeout: int = 0
userDataUpSkipRetryCount: int = 0
iconPhotoDisable: bool = False
uploadPhotoDisable: bool = False
photoDisableArea: Optional[str] = None
collectDelayInfoDisable: bool = False
maxCountMusic: int = 0
maxCountItem: int = 0
nameEntryDisable: bool = False
packetRecreateCount: int = 0
@dataclass
class GameSettingRequestVO:
"""请求 VO(对应 GameSettingRequestVO)。"""
placeId: int = 0
clientId: str = ""
@dataclass
class GameSettingResponseVO:
"""响应 VO(对应 GameSettingResponseVO)。"""
isAouAccession: bool = False
gameSetting: GameSetting = field(default_factory=GameSetting)
# =============================================================================
# 公开 API
# =============================================================================
def GetGameSettings(
*,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> GameSettingResponseVO:
"""
获取游戏设置(服务器下发的运行时配置)。
参数:
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
client_id: 机台短值(可选,默认 config.CLIENT_ID)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
GameSettingResponseVO
"""
log_info("获取游戏设置:place_id=" + str(place_id) + " client_id=" + client_id)
raw = request_dict(
"GetGameSettingApi",
GameSettingRequestVO(placeId=place_id, clientId=client_id),
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
gs_raw = raw.get("gameSetting") or {}
known = {f for f in GameSetting.__dataclass_fields__}
gs = GameSetting(**{k: v for k, v in gs_raw.items() if k in known})
resp = GameSettingResponseVO(
isAouAccession=bool(raw.get("isAouAccession", False)),
gameSetting=gs,
)
if gs.isMaintenance:
log_warning("服务器处于维护状态(isMaintenance=True)")
if gs.pingDisable:
log_info("服务器已禁用心跳(pingDisable=True)")
log_info(
"游戏设置获取完成:isMaintenance=" + str(gs.isMaintenance)
+ " requestInterval=" + str(gs.requestInterval)
+ " packetTimeout=" + str(gs.packetTimeout)
+ " packetTimeoutLong=" + str(gs.packetTimeoutLong)
+ " pingDisable=" + str(gs.pingDisable)
)
return resp
def is_maintenance(settings: GameSettingResponseVO) -> bool:
"""便捷判断:服务器是否处于维护状态。"""
return bool(settings.gameSetting.isMaintenance)
def is_ping_disabled(settings: GameSettingResponseVO) -> bool:
"""便捷判断:是否已禁用心跳。"""
return bool(settings.gameSetting.pingDisable)
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("gamesettings_test")
try:
resp = GetGameSettings()
gs = resp.gameSetting
print("")
for name in GameSetting.__dataclass_fields__:
print(" " + name.ljust(34) + "= " + str(getattr(gs, name)))
return 0
except Exception as exc:
from .LogSetup import log_error
log_error("获取游戏设置失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
+413
View File
@@ -0,0 +1,413 @@
# -*- coding: utf-8 -*-
"""
GetNeedUserData.py —— 结算快照编排(统一调用各取数脚本)
================================================================================
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(GetUserData.py / GetUserMap.py …)。
--------------------------------------------------------------------------------
为什么需要快照
--------------------------------------------------------------------------------
服务器对 userData 是**整表覆盖**语义,对差分节点是**只回传变化项**语义。
要把本局的变化正确表达出来,必须先有一份「登录时的服务器状态」作基线,
再把本局的影响叠加到基线上 —— 这就是快照的作用。
真机对照(source/2026-10-05.log):
| 节点 | Get 侧(基线) | Upsert 侧(本局后) |
|---------------------|-------------------|----------------------|
| userData.playCount | 1257 | 1258 (+1) |
| userMapList | 104 条 | 仅 1 条(distance 变)|
| userMusicDetailList | 739 组 | 仅 1 条(playCount+1)|
| userItemList | 1070 条 | [](无变化) |
--------------------------------------------------------------------------------
调用示例
--------------------------------------------------------------------------------
from api import GetNeedUserData
snapshot = GetNeedUserData(user_id, token=token)
# 之后直接传给 UpsertUserAll(snapshot=snapshot, ...)
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS
from .LogSetup import log_debug, log_info, log_warning
from .MaimaiNet import MaimaiNetClient
# 各取数脚本(一个接口一个脚本)
from .GetUserData import GetUserData
from .GetUserExtend import GetUserExtend
from .GetUserOption import GetUserOption
from .GetUserCharacter import GetUserCharacter
from .GetUserGhost import GetUserGhost
from .GetUserMap import GetUserMapAll
from .GetUserLoginBonus import GetUserLoginBonusAll
from .GetUserRating import GetUserRating
from .GetUserItem import GetUserItemAll
from .GetUserMusic import GetUserMusicDetailAll
from .GetUserCourse import GetUserCourseAll
from .GetUserCharge import GetUserCharge
from .GetUserFavorite import GetUserFavoriteAll
from .GetUserActivity import GetUserActivity
from .GetUserMission import GetUserMissionData
from .GetUserIntimate import GetUserIntimate
from .GetUserShopStock import GetUserShopStock
from .GetUserFavoriteItem import GetUserFavoriteItemAll
from .GetUserKaleidxScope import GetUserKaleidxScope
# =============================================================================
# 落盘目录:<工程根>/data/<userId>.json
# =============================================================================
#: 工程根目录(本文件在 <root>/api/ 下)
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
#: 用户数据目录:<工程根>/data
DATA_DIR: Path = PROJECT_ROOT / "data"
def user_data_path(user_id: int) -> Path:
"""用户数据的落盘路径:<工程根>/data/<userId>.json"""
return DATA_DIR / (str(int(user_id)) + ".json")
def save_user_data(snapshot: Dict[str, Any], user_id: int,
path: Optional[str] = None) -> str:
"""
把 GetNeedUserData 抓到的数据写到 <工程根>/data/<userId>.json。
★ **新数据总是覆盖老数据**(不是追加、也不是合并)。
★ 目录不存在会自动创建。
参数:
snapshot: GetNeedUserData 的返回值
user_id: 用户 ID(决定文件名)
path: 自定义路径(可选,默认走 data/<userId>.json)
返回:实际写入的文件路径
"""
target = Path(path) if path else user_data_path(user_id)
try:
os.makedirs(str(target.parent), exist_ok=True)
with open(str(target), "w", encoding="utf-8") as fp:
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
except Exception as exc:
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
return str(target)
size = 0
try:
size = os.path.getsize(str(target))
except Exception:
pass
log_info("用户数据已保存(覆盖):" + str(target) + " " + str(size) + " 字节")
return str(target)
def load_user_data(user_id: int, path: Optional[str] = None) -> Dict[str, Any]:
"""
读回 <工程根>/data/<userId>.json。
找不到文件时抛 FileNotFoundError(调用方自行决定要不要回退到联网抓取)。
"""
target = Path(path) if path else user_data_path(user_id)
if not target.exists():
raise FileNotFoundError("用户数据文件不存在:" + str(target))
with open(str(target), "r", encoding="utf-8") as fp:
data = json.load(fp)
log_info("用户数据已加载:" + str(target))
return data
def list_saved_user_ids() -> List[int]:
"""列出 data/ 下已保存的全部 userId(按文件名解析,跳过解析失败的)。"""
if not DATA_DIR.exists():
return []
out: List[int] = []
for name in os.listdir(str(DATA_DIR)):
if not name.endswith(".json"):
continue
try:
out.append(int(name[:-5]))
except ValueError:
continue
return sorted(out)
#: 默认要抓的节点(顺序即抓取顺序)
#: ★ 只列 UpsertUserAll 真正需要的;纯展示类接口(排行/活动/大会…)不在此列。
DEFAULT_SNAPSHOT_NODES: tuple = (
"userData",
"userExtend",
"userOption",
"userCharacterList",
"userMapList",
"userLoginBonusList",
"userRatingList",
"userItemList",
"userMusicDetailList",
"userCourseList",
"userChargeList",
"userFavoriteList",
"userActivityList",
"userMissionDataList",
"userIntimateList",
"userShopItemStockList",
"userFavoritemusicList",
"userKaleidxScopeList",
"userGhost",
)
def GetNeedUserData(
user_id: int,
*,
token: str = "",
client_id: str = CLIENT_ID,
place_id: int = PLACE_ID,
region_id: int = REGION_ID,
shop_item_ids: Optional[Sequence[int]] = None,
nodes: Optional[Sequence[str]] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
strict: bool = False,
save: bool = True,
save_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
抓取结算所需的全部基线数据。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(可选,多数取数接口不需要)
client_id: 机台短值
place_id: 店铺 ID
region_id: 区域 ID
shop_item_ids: 商店商品清单(可选,默认 config.PLAY_SHOP_ITEM_IDS)
nodes: 要抓的节点(可选,默认 DEFAULT_SNAPSHOT_NODES)
timeout_ms: 超时毫秒
client: 可复用的 MaimaiNetClient
strict: True = 任一接口失败就抛异常;
False(默认)= 记录失败并跳过,保证链路不崩
save: True(默认)= 抓完自动落盘到 <工程根>/data/<userId>.json
(★ 新数据总是覆盖老数据)
save_path: 自定义落盘路径(可选,给了就忽略 save 的默认路径规则)
返回:
{
"userData": <GetUserDataApi 原始响应>,
"userExtend": <GetUserExtendApi 原始响应>,
"userMapList": {"userMapList": [...]}, # 已摊平/翻页
"userMusicDetailList": {"userMusicList": [...]},# 已摊平成单条
...
"_failed": [ {"node":.., "error":..}, ... ]
}
这个结构可以直接传给 UpsertUserAll(snapshot=...),也可 json 落盘。
"""
wanted = list(nodes) if nodes else list(DEFAULT_SNAPSHOT_NODES)
log_info("抓取结算快照:user_id=" + str(user_id) + " 节点数=" + str(len(wanted)))
raw: Dict[str, Any] = {}
failed: List[Dict[str, str]] = []
def _call(node: str, fn, *args, **kwargs):
"""逐节点容错:单个接口失败不影响整体。"""
try:
raw[node] = fn(*args, **kwargs)
return True
except Exception as exc:
failed.append({"node": node, "error": type(exc).__name__ + ": " + str(exc)})
log_warning("节点 " + node + " 抓取失败:" + type(exc).__name__ + ": " + str(exc))
if strict:
raise
return False
common = dict(client_id=client_id, timeout_ms=timeout_ms, client=client)
# ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)----
if "userData" in wanted:
_call("userData", GetUserData, user_id, **common)
if "userExtend" in wanted:
_call("userExtend", GetUserExtend, user_id, **common)
if "userOption" in wanted:
_call("userOption", GetUserOption, user_id, **common)
if "userCharacterList" in wanted:
_call("userCharacterList", GetUserCharacter, user_id, **common)
if "userMapList" in wanted:
_call("userMapList", GetUserMapAll, user_id, **common)
if "userLoginBonusList" in wanted:
_call("userLoginBonusList", GetUserLoginBonusAll, user_id, **common)
if "userRatingList" in wanted:
_call("userRatingList", GetUserRating, user_id, **common)
if "userItemList" in wanted:
_call("userItemList", GetUserItemAll, user_id, **common)
if "userMusicDetailList" in wanted:
_call("userMusicDetailList", GetUserMusicDetailAll, user_id, **common)
if "userCourseList" in wanted:
_call("userCourseList", GetUserCourseAll, user_id, **common)
if "userChargeList" in wanted:
_call("userChargeList", GetUserCharge, user_id, **common)
if "userFavoriteList" in wanted:
# ★ 该接口额外带一份**原始** itemKind 副本(_raw),
# 供 UpsertUserAll 生成 isNewFavoriteList 位串用
# —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。
if _call("userFavoriteList", GetUserFavoriteAll, user_id, **common):
fav_raw = (raw.get("userFavoriteList") or {}).get("_raw")
if fav_raw:
raw["_raw_favorite"] = fav_raw
if "userActivityList" in wanted:
_call("userActivityList", GetUserActivity, user_id, **common)
if "userMissionDataList" in wanted:
# ★ 该接口的响应里同时带着 userWeeklyData(周界),
# 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。
_call("userMissionDataList", GetUserMissionData, user_id, **common)
if "userIntimateList" in wanted:
_call("userIntimateList", GetUserIntimate, user_id, **common)
if "userShopItemStockList" in wanted:
ids = list(shop_item_ids) if shop_item_ids is not None else list(PLAY_SHOP_ITEM_IDS)
if ids:
_call("userShopItemStockList", GetUserShopStock, user_id,
shop_item_id_list=ids, **common)
else:
log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点")
if "userFavoritemusicList" in wanted:
_call("userFavoritemusicList", GetUserFavoriteItemAll, user_id, 1, **common)
if "userKaleidxScopeList" in wanted:
_call("userKaleidxScopeList", GetUserKaleidxScope, user_id, **common)
if "userGhost" in wanted:
_call("userGhost", GetUserGhost, user_id, **common)
raw["_failed"] = failed
raw["_userId"] = int(user_id)
log_info("结算快照抓取完成:成功 " + str(len(raw) - 2) + " 个节点,失败 "
+ str(len(failed)) + " 个")
# ★ 落盘:<工程根>/data/<userId>.json(覆盖式)
if save or save_path:
try:
save_user_data(raw, user_id, path=save_path)
except Exception as exc:
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
return raw
def save_snapshot(snapshot: Dict[str, Any], path: str) -> str:
"""把快照写到 JSON 文件(便于离线复用 / 人工核对)。"""
with open(path, "w", encoding="utf-8") as fp:
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
log_info("快照已保存:" + path)
return path
def load_snapshot(path: str) -> Dict[str, Any]:
"""从 JSON 文件读回快照。"""
with open(path, "r", encoding="utf-8") as fp:
data = json.load(fp)
log_info("快照已加载:" + path)
return data
def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
"""
统计快照各节点的条数,便于快速核对(返回 {节点名: 条数})。
条数含义:
userData 这类单元素数组 -> 1
userMusicDetailList 这类列表 -> 摊平后的条数
"""
out: Dict[str, int] = {}
for node, value in (snapshot or {}).items():
if node.startswith("_"):
continue
if isinstance(value, list):
out[node] = len(value)
elif isinstance(value, dict):
# 从响应里挑出列表
found = False
for key, sub in value.items():
if isinstance(sub, list):
out[node] = len(sub)
found = True
break
if not found:
out[node] = 1
else:
out[node] = 0
return out
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
import sys
from .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("get_need_user_data_test")
try:
if len(sys.argv) < 2:
print("用法: python -m api.get_need_user_data <user_id> [--token TOKEN] [--save 路径]")
return 1
user_id = int(sys.argv[1])
token = ""
save_path = ""
args = sys.argv[2:]
i = 0
while i < len(args):
if args[i] == "--token" and i + 1 < len(args):
token = args[i + 1]
i += 2
elif args[i] == "--save" and i + 1 < len(args):
save_path = args[i + 1]
i += 2
else:
i += 1
snap = GetNeedUserData(user_id, token=token)
summary = summarize_snapshot(snap)
print("")
for node, count in summary.items():
print(" " + node.ljust(28) + "= " + str(count) + " 条")
if save_path:
save_snapshot(snap, save_path)
return 0
except Exception as exc:
log_error("抓取快照失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
+48
View File
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""
GetUserActivity.py —— 取用户活动记录(GetUserActivityApi)
================================================================================
取玩家的活动/成就记录(playList 与 musicList 两个列表)。
对应接口:GetUserActivityApi
请求体 :UserIdRequestVO
响应 :{userActivity:{playList:[...], musicList:[...]}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserActivityRequestVO.cs
★ 形状特例(36 个 Upsert 节点里唯一一个 Get 与 Upsert 形状不同的):
Get 侧 :单个对象 userActivity = {playList, musicList}
Upsert 侧:**单元素数组** userActivityList = [ {playList, musicList} ]
不是把两个数组首尾相接摊平。
调用示例:
from api import GetUserActivity
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserActivity(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户活动记录(对应 GetUserActivityApi)。
返回:
原始响应 dict,含 userActivity 子对象
"""
log_info("取用户活动记录:user_id=" + str(user_id))
return _get("GetUserActivityApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""
GetUserCharacter.py —— 取用户角色列表(GetUserCharacterApi)
================================================================================
取玩家所有角色的等级与觉醒度(真机 312 条)。
对应接口:GetUserCharacterApi
请求体 :UserIdRequestVO
响应 :{userId, length, userCharacterList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCharacterRequestVO.cs
★ 回传 Upsert 时只保留 characterId / level / awakening / useCount 四个字段
(真机 source/2026-10-05.log:123 的 userCharacterList 就是这 4 个键)。
调用示例:
from api import GetUserCharacter
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserCharacter(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户角色列表(对应 GetUserCharacterApi)。
返回:
原始响应 dict,含 userCharacterList
"""
log_info("取用户角色列表:user_id=" + str(user_id))
return _get("GetUserCharacterApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""
GetUserCharge.py —— 取用户票券(GetUserChargeApi)
================================================================================
取玩家的票券持有情况(真机 3 条)。
对应接口:GetUserChargeApi
请求体 :UserIdRequestVO
响应 :{userId, length, userChargeList:[{chargeId,stock,purchaseDate,validDate,extNum1}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserChargeRequestVO.cs
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
★ 调用本接口会**登记一次「票券已查询」**(见 api/ChargeGuard.py)。
UpsertUserChargelog 发包前会检查这笔登记 —— 没查过票就买票会被直接拒绝。
调用示例:
from api import GetUserCharge
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .ChargeGuard import note_user_charge_read
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserCharge(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户票券(对应 GetUserChargeApi)。
返回:
原始响应 dict,含 userChargeList
"""
log_info("取用户票券:user_id=" + str(user_id))
raw = _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
# ★ 记一笔「这个账号刚查过票券」——
# UpsertUserChargelog 发包前会检查这笔记录,没查过就拒绝购买。
# 见 api/ChargeGuard.py 的说明。
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
note_user_charge_read(user_id, lst)
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
if held:
log_info("★ 该账号当前持有 " + str(len(held)) + " 张功能票:"
+ "、".join("chargeId=" + str(c.get("chargeId"))
+ " stock=" + str(c.get("stock")) for c in held))
else:
log_info("该账号当前没有持有功能票")
return raw
+71
View File
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""
GetUserCourse.py —— 取用户课题模式成绩(GetUserCourseApi)
================================================================================
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
对应接口:GetUserCourseApi
请求体 :UserCourseRequestVO(userId + nextIndex,**没有 maxCount**)
响应 :{userId, length, nextIndex, userCourseList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCourseRequestVO.cs
source/2026-10-05.log:55 请求体就是 {"userId":..,"nextIndex":0}
调用示例:
from api import GetUserCourse
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserCourseRequestVO, get_all_pages
def GetUserCourse(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户课题模式成绩(单页,对应 GetUserCourseApi)。
返回:
原始响应 dict,含 userCourseList
"""
log_info("取用户课题成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserCourseApi",
UserCourseRequestVO(userId=user_id, nextIndex=next_index),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserCourseAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户课题成绩(自动翻页取全)。
返回:
{"userCourseList": [...]}
"""
log_info("取用户课题成绩(全量):user_id=" + str(user_id))
items = get_all_pages(
"GetUserCourseApi", user_id, "userCourseList",
body_factory=lambda ni: UserCourseRequestVO(userId=user_id, nextIndex=ni or 0),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("课题成绩获取完成:" + str(len(items)) + " 条")
return {"userCourseList": items}
+58
View File
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""
GetUserData.py —— 取用户全量数据(GetUserDataApi)
================================================================================
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
对应接口:GetUserDataApi
请求体 :UserIdRequestVO(只有 userId)
响应 :{userId, userData:{86 键}, banState}
依据 :source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserData.cs:17
source/Assembly-CSharp/Net.VO.Mai2/UserDetailResponseVO.cs
★ banState 在 Get 侧是**顶层**字段(UserDetailResponseVO.cs:15),
回传 Upsert 时要搬进 userData 内部(真机 source/2026-10-05.log:123 的 userData 里有 banState)。
⚠ 常见误区:有资料认为 userData 由 GetGameKaleidxScopeApi 下发。
但 GameKaleidxScopeResponseVO.cs 里只有 gameKaleidxScopeList 字段,根本没有 userData。
真机日志里那一次「GetGameKaleidxScope 的响应带 userData」,是抓包工具按时间相邻
记录造成的假象(两条响应被合并显示)。
调用示例:
from api import GetUserData
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserData(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户全量数据(对应 GetUserDataApi)。
参数:
user_id: 用户 ID(必填)
client_id: 机台短值(可选)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
原始响应 dict:{"userId":.., "userData":{..}, "banState":..}
"""
log_info("取用户全量数据:user_id=" + str(user_id))
return _get("GetUserDataApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""
GetUserExtend.py —— 取用户扩展设置(GetUserExtendApi)
================================================================================
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
对应接口:GetUserExtendApi
请求体 :UserIdRequestVO
响应 :{userId, userExtend:{...}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserExtendRequestVO.cs
★ 其中 categoryIndex(曲种索引)与 userPlaylog 的 extNum4 必须一致
(真机两处都是 2022),组包时要注意。
调用示例:
from api import GetUserExtend
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserExtend(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户扩展设置(对应 GetUserExtendApi)。
返回:
原始响应 dict,含 userExtend 子对象
"""
log_info("取用户扩展设置:user_id=" + str(user_id))
return _get("GetUserExtendApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+102
View File
@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
"""
GetUserFavorite.py —— 取用户收藏(GetUserFavoriteApi)
================================================================================
取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。
对应接口:GetUserFavoriteApi
请求体 :UserFavoriteRequestVO(userId + itemKind)
响应 :{userId, userFavorite:{itemKind, itemIdList:[...]}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteRequestVO.cs
source/2026-10-05.log:59-68 真机依次请求 itemKind = 3,1,2,10,11
★ 回传 Upsert 时 userFavoriteList 是**恒 5 条**的完整数组(不做剔除),
位串 isNewFavoriteList 是三值 "0"/"1"/"2"。
调用示例:
from api import GetUserFavorite, GetUserFavoriteAll
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
def GetUserFavorite(
user_id: int,
item_kind: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户某一类收藏(对应 GetUserFavoriteApi)。
参数:
user_id: 用户 ID(必填)
item_kind: 收藏类别(必填):3=头像 1=称号 2=名牌 10=搭档 11=相框
返回:
原始响应 dict,含 userFavorite
"""
log_info("取用户收藏:user_id=" + str(user_id) + " itemKind=" + str(item_kind))
return _get(
"GetUserFavoriteApi",
UserFavoriteRequestVO(userId=user_id, itemKind=item_kind),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserFavoriteAll(
user_id: int,
*,
item_kinds: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户全部收藏(按真机顺序请求 5 个类别)。
参数:
item_kinds: 类别列表(可选,默认 FAVORITE_ITEM_KINDS = (3,1,2,10,11))
返回:
{"userFavoriteList": [{itemKind, itemIdList}, ...]},恒 5 条
"""
kinds = list(item_kinds) if item_kinds else list(FAVORITE_ITEM_KINDS)
log_info("取用户全部收藏:user_id=" + str(user_id) + " kinds=" + str(kinds))
result: List[Dict[str, Any]] = []
raw_items: List[Dict[str, Any]] = []
for kind in kinds:
resp = _get(
"GetUserFavoriteApi",
UserFavoriteRequestVO(userId=user_id, itemKind=kind),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
fav = resp.get("userFavorite") or {}
if not fav:
log_warning("itemKind=" + str(kind) + " 未返回 userFavorite,按空处理")
fav = {"itemKind": kind, "itemIdList": []}
# ★ 真机里 itemKind=10 的响应回的是 {"itemKind":null,"itemIdList":[]}(服务端怪癖)。
# 保留一份**原始**副本供 Upsert 差分用 —— 位串里的 "1" 正是它产生的。
raw_items.append(dict(fav))
# 回传用的副本按请求值补齐 itemKind,避免报文里出现 null
filled = dict(fav)
if not filled.get("itemKind"):
filled["itemKind"] = kind
result.append(filled)
log_info("收藏获取完成:" + str(len(result)) + " 类")
return {"userFavoriteList": result, "_raw": raw_items}
+92
View File
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
"""
GetUserFavoriteItem.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
================================================================================
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
对应接口:GetUserFavoriteItemApi
请求体 :UserFavoriteItemRequestVO(userId + kind + nextIndex + maxCount=100
+ isAllFavoriteItem)
响应 :{userId, kind, length, nextIndex, userFavoriteItemList:[{orderId,id}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteItemRequestVO.cs
source/2026-10-05.log:93,107 真机 kind=2 与 kind=1 各一次
★ 字段名是 kind(不是 itemKind),别写错。
调用示例:
from api import GetUserFavoriteItem, GetUserFavoriteItemAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserFavoriteItemRequestVO, get_all_pages
#: kind 取值
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
FAVORITE_ITEM_KIND_RIVAL = 2 # 收藏对手
def GetUserFavoriteItem(
user_id: int,
kind: int = FAVORITE_ITEM_KIND_MUSIC,
*,
next_index: int = 0,
is_all_favorite_item: bool = False,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户收藏项(单页,对应 GetUserFavoriteItemApi)。
参数:
user_id: 用户 ID(必填)
kind: 1=收藏乐曲 2=收藏对手(默认 1)
返回:
原始响应 dict,含 userFavoriteItemList
"""
log_info("取用户收藏项:user_id=" + str(user_id) + " kind=" + str(kind)
+ " next_index=" + str(next_index))
return _get(
"GetUserFavoriteItemApi",
UserFavoriteItemRequestVO(
userId=user_id, kind=kind, nextIndex=next_index,
maxCount=100, isAllFavoriteItem=is_all_favorite_item,
),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserFavoriteItemAll(
user_id: int,
kind: int = FAVORITE_ITEM_KIND_MUSIC,
*,
is_all_favorite_item: bool = False,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户收藏项(自动翻页取全)。
返回:
{"userFavoriteItemList": [...]}
"""
log_info("取用户收藏项(全量):user_id=" + str(user_id) + " kind=" + str(kind))
items = get_all_pages(
"GetUserFavoriteItemApi", user_id, "userFavoriteItemList",
body_factory=lambda ni: UserFavoriteItemRequestVO(
userId=user_id, kind=kind, nextIndex=ni or 0,
maxCount=100, isAllFavoriteItem=is_all_favorite_item),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("收藏项获取完成:" + str(len(items)) + " 条")
return {"userFavoriteItemList": items}
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""
GetUserFriendBonus.py —— 取用户好友里程(GetUserFriendBonusApi)
================================================================================
取玩家本次可领取的好友里程(getMiles)。
对应接口:GetUserFriendBonusApi
请求体 :UserIdRequestVO
响应 :{returnCode, userId, getMiles}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFriendBonusRequestVO.cs
source/2026-10-05.log:111 真机响应 {"returnCode":1,"userId":..,"getMiles":90}
调用示例:
from api import GetUserFriendBonus, extract_gift_points
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserFriendBonus(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户好友里程(对应 GetUserFriendBonusApi)。
返回:
原始响应 dict:{"returnCode":1, "userId":.., "getMiles":..}
"""
log_info("取用户好友里程:user_id=" + str(user_id))
resp = _get("GetUserFriendBonusApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
if int(resp.get("returnCode", 0) or 0) != 1:
log_warning("好友里程接口返回 returnCode=" + str(resp.get("returnCode")))
return resp
def extract_gift_points(friend_bonus: Dict[str, Any]) -> int:
"""
从好友里程响应里取出 getMiles(本次可领取的里程数)。
参数:
friend_bonus: GetUserFriendBonus 的返回值
返回:
getMiles 整数(取不到则为 0)
"""
try:
return int((friend_bonus or {}).get("getMiles", 0) or 0)
except Exception:
return 0
+47
View File
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""
GetUserGhost.py —— 取用户幽灵数据(GetUserGhostApi)
================================================================================
取玩家的「幽灵」对战记录(真机 15 条)。
对应接口:GetUserGhostApi
请求体 :UserIdRequestVO
响应 :{length, userGhostList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserGhostRequestVO.cs
★ Upsert 侧 userGhost 默认留空:源码 VOExtensions.cs:235 是
dst.userGhost = (!src.MyGhost.NeedSave) ? null : new GetUserGhost[1] {...};
只有「本局产生了幽灵」才回传,纯网络脚本复刻不出幽灵数据。
调用示例:
from api import GetUserGhost
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserGhost(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户幽灵数据(对应 GetUserGhostApi)。
返回:
原始响应 dict,含 userGhostList
"""
log_info("取用户幽灵数据:user_id=" + str(user_id))
return _get("GetUserGhostApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+43
View File
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
"""
GetUserIntimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
================================================================================
取玩家与各搭档的亲密度(真机 2 条)。
对应接口:GetUserIntimateApi
请求体 :UserIdRequestVO
响应 :{userId, length, userIntimateList:[{partnerId,intimateLevel,intimateCountRewarded}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs
调用示例:
from api import GetUserIntimate
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserIntimate(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户搭档亲密度(对应 GetUserIntimateApi)。
返回:
原始响应 dict,含 userIntimateList
"""
log_info("取用户搭档亲密度:user_id=" + str(user_id))
return _get("GetUserIntimateApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+124
View File
@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
"""
GetUserItem.py —— 取用户道具(GetUserItemApi)
================================================================================
取玩家各类道具的库存(真机按 kind 分 12 次请求)。
对应接口:GetUserItemApi
请求体 :UserItemRequestVO(userId + nextIndex + maxCount=100)
响应 :{userId, length, nextIndex, itemKind, userItemList:[{itemKind,itemId,stock,isValid}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserItemRequestVO.cs
--------------------------------------------------------------------------------
★★ 首发 nextIndex 必须带哨兵值(最容易踩的坑)
--------------------------------------------------------------------------------
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
public PacketGetUserItem(ulong userId, long itemId, ...)
{
Create(GetNetQuery(userId, itemId * 10000000000L)); // <- 首包哨兵
}
// Proc(): nextIndex != 0 时继续翻页
真机日志 source/2026-10-05.log:32 也是 nextIndex=10000000000。
传 0 会被服务端拒绝(HTTP 500)。
★ 请求体里**没有 itemKind 字段** —— 类别是靠 nextIndex 的哨兵值表达的:
第 1 页 nextIndex = kind * 1e10
后续翻页沿用响应回的 nextIndex
调用示例:
from api import GetUserItem, GetUserItemAll
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_debug, log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
def GetUserItem(
user_id: int,
item_kind: int,
*,
next_index: Optional[int] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户某一类道具(对应 GetUserItemApi)。
参数:
user_id: 用户 ID(必填)
item_kind: 道具类别(必填):1=名牌 2=称号 3=头像 4=礼物 5=乐曲 …
next_index: 分页索引;**None(默认)表示首页**,会自动换算成
item_kind * 10000000000 的哨兵值。
返回:
原始响应 dict,含 userItemList
"""
if next_index is None:
next_index = item_kind * ITEM_NEXT_INDEX_FACTOR
log_info("取用户道具:user_id=" + str(user_id) + " item_kind=" + str(item_kind)
+ " nextIndex=" + str(next_index))
return _get(
"GetUserItemApi",
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserItemAll(
user_id: int,
*,
item_kinds: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户全部道具(按 ITEM_KINDS 逐个类别抓取,并自动翻页)。
参数:
item_kinds: 类别列表(可选,默认 ITEM_KINDS)
返回:
{"userItemList": [...]},已合并所有类别与分页
"""
kinds = list(item_kinds) if item_kinds else list(ITEM_KINDS)
log_info("取用户全部道具:user_id=" + str(user_id) + " kinds=" + str(kinds))
merged: List[Dict[str, Any]] = []
for kind in kinds:
next_index: Optional[int] = kind * ITEM_NEXT_INDEX_FACTOR
page = 0
while next_index:
page += 1
resp = _get(
"GetUserItemApi",
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
items = resp.get("userItemList") or []
if isinstance(items, dict):
items = [items]
merged.extend(items)
next_index = int(resp.get("nextIndex") or 0)
log_debug("item_kind=" + str(kind) + " 第 " + str(page) + " 页:"
+ str(len(items)) + " 条,nextIndex=" + str(next_index))
if page >= 200:
log_warning("item_kind=" + str(kind) + " 翻页超过 200 页,已中止")
break
log_info("道具获取完成:" + str(len(merged)) + " 条")
return {"userItemList": merged}
+75
View File
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
"""
GetUserKaleidxScope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
================================================================================
对应接口:
GetUserKaleidxScopeApi 请求体 UserIdRequestVO(userId)
响应 {userId, userKaleidxScopeList:[...]}
GetGameKaleidxScopeApi 请求体 **空对象 {}**(GameKaleidxScopeRequestVO 没有任何字段)
响应 {gameKaleidxScopeList:[{gateId, phaseId}]}
依据:
source/Assembly-CSharp/Net.VO.Mai2/UserKaleidxScopeRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeRequestVO.cs(空类)
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeResponseVO.cs
source/2026-10-05.log:15,119
★★ 重要区分(曾导致 userData 取空)
--------------------------------------------------------------------------------
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
玩家档案 userData 的正确来源是 GetUserDataApi(见 GetUserData.py)。
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
多发字段会让服务端返回空内容。
调用示例:
from api import GetUserKaleidxScope, GetGameKaleidxScope
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, EmptyRequestVO, UserIdRequestVO
def GetUserKaleidxScope(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取玩家 KALEIDX 门进度(对应 GetUserKaleidxScopeApi)。
返回:
原始响应 dict,含 userKaleidxScopeList
"""
log_info("取玩家 KALEIDX 门进度:user_id=" + str(user_id))
return _get("GetUserKaleidxScopeApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
def GetGameKaleidxScope(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取全服 KALEIDX 门开放进度(对应 GetGameKaleidxScopeApi)。
★ 请求体是空对象 {} —— 该 RequestVO 一个字段都没有,**不要传 userId**。
返回:
原始响应 dict,含 gameKaleidxScopeList
"""
log_info("取全服 KALEIDX 门进度")
return _get("GetGameKaleidxScopeApi", EmptyRequestVO(), 0,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
"""
GetUserLoginBonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
================================================================================
取玩家的登录奖励领取进度(真机 38 条,分两页)。
对应接口:GetUserLoginBonusApi
请求体 :UserLoginBonusRequestVO(userId + nextIndex + maxCount=20)
响应 :{userId, length, nextIndex, userLoginBonusList:[{bonusId,point,isCurrent,isComplete}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserLoginBonusRequestVO.cs
★ 回传 Upsert 时只回传 isCurrent=true 的那一条(本次签到的那条)。
调用示例:
from api import GetUserLoginBonus, GetUserLoginBonusAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserLoginBonusRequestVO, get_all_pages
def GetUserLoginBonus(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户登录奖励(单页,对应 GetUserLoginBonusApi)。
参数:
next_index: 分页起始索引(0 = 首页)
返回:
原始响应 dict,含 userLoginBonusList
"""
log_info("取用户登录奖励:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserLoginBonusApi",
UserLoginBonusRequestVO(userId=user_id, nextIndex=next_index, maxCount=20),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserLoginBonusAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户登录奖励(自动翻页取全)。
返回:
{"userLoginBonusList": [...]}
"""
log_info("取用户登录奖励(全量):user_id=" + str(user_id))
items = get_all_pages(
"GetUserLoginBonusApi", user_id, "userLoginBonusList",
body_factory=lambda ni: UserLoginBonusRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=20),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("登录奖励获取完成:" + str(len(items)) + " 条")
return {"userLoginBonusList": items}
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""
GetUserMap.py —— 取用户地图进度(GetUserMapApi)
================================================================================
取玩家各地图的推进距离(真机 104 条)。
对应接口:GetUserMapApi
请求体 :UserMapRequestVO(userId + nextIndex + maxCount=1000)
响应 :{userId, length, nextIndex, userMapList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMapRequestVO.cs
★ Get 侧下发 100+ 条;Upsert 只回传「本次距离变化」的那 1 条
(真机:mapId=550003 的 distance 1424000 -> 1435000)。
调用示例:
from api import GetUserMap / GetUserMapAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdPageRequestVO, get_all_pages
def GetUserMap(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户地图进度(单页,对应 GetUserMapApi)。
参数:
next_index: 分页起始索引(0 = 首页)
返回:
原始响应 dict,含 userMapList
"""
log_info("取用户地图进度:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserMapApi",
UserIdPageRequestVO(userId=user_id, nextIndex=next_index, maxCount=1000),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserMapAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户地图进度(自动翻页取全)。
返回:
{"userMapList": [...]}
"""
log_info("取用户地图进度(全量):user_id=" + str(user_id))
items = get_all_pages(
"GetUserMapApi", user_id, "userMapList",
body_factory=lambda ni: UserIdPageRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=1000),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("地图进度获取完成:" + str(len(items)) + " 条")
return {"userMapList": items}
+48
View File
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""
GetUserMission.py —— 取用户任务与周数据(GetUserMissionDataApi)
================================================================================
取玩家的任务进度与周常数据(真机 6 条任务)。
对应接口:GetUserMissionDataApi
请求体 :UserIdRequestVO
响应 :{userId, userWeeklyData:{...}, userMissionDataList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMissionRequestVO.cs
★ 一个接口同时下发两个 Upsert 节点:
userMissionDataList 任务列表
userWeeklyData 周常数据(★ 是**对象**不是数组)
★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。
调用示例:
from api import GetUserMissionData
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserMissionData(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户任务与周数据(对应 GetUserMissionDataApi)。
返回:
原始响应 dict:{"userId":.., "userWeeklyData":{..}, "userMissionDataList":[..]}
"""
log_info("取用户任务与周数据:user_id=" + str(user_id))
return _get("GetUserMissionDataApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+90
View File
@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""
GetUserMusic.py —— 取用户曲目成绩(GetUserMusicApi)
================================================================================
取玩家全部曲目的历史成绩归档(真机 739 组)。
对应接口:GetUserMusicApi
请求体 :UserMusicRequestVO(userId + nextIndex + maxCount=50)
响应 :{userId, nextIndex, userMusicList:[{userMusicDetailList:[...], length}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMusicRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/UserMusic.cs(外层按曲分组)
source/Assembly-CSharp/Net.VO.Mai2/GetUserMusicDetail.cs(内层按难度)
★ 响应是**两层嵌套**:外层每元素是一首歌,内层的 userMusicDetailList 才是
按 (musicId, level) 的成绩条目。取数时要摊平。
★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。
调用示例:
from api import GetUserMusicDetail, UserMusicDetailFlat
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserMusicRequestVO, get_all_pages
def GetUserMusicDetail(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户曲目成绩(单页,对应 GetUserMusicApi)。
参数:
next_index: 分页起始索引(0 = 首页)
返回:
原始响应 dict,含 userMusicList(两层嵌套)
"""
log_info("取用户曲目成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserMusicApi",
UserMusicRequestVO(userId=user_id, nextIndex=next_index, maxCount=50),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserMusicDetailAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户曲目成绩(自动翻页取全,已摊平成单层)。
返回:
{"userMusicList": [ {"musicId":..,"level":..,"playCount":..}, ... ]}
每元素是**单条 (musicId, level) 成绩**,不是按曲分组的嵌套结构。
"""
log_info("取用户曲目成绩(全量):user_id=" + str(user_id))
groups = get_all_pages(
"GetUserMusicApi", user_id, "userMusicList",
body_factory=lambda ni: UserMusicRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=50),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
flat: List[Dict[str, Any]] = []
for group in groups:
if isinstance(group, dict):
details = group.get("userMusicDetailList") or []
if isinstance(details, dict):
details = [details]
flat.extend(details)
log_info("曲目成绩获取完成:" + str(len(flat)) + " 条(原始 "
+ str(len(groups)) + " 组)")
return {"userMusicList": flat}
+84
View File
@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""
GetUserNewItem.py —— 取新增道具(GetUserNewItemListApi)
================================================================================
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
对应接口:GetUserNewItemListApi
请求体 :UserNewItemListRequestVO(userId + version + userData[] + userPlaylogList[])
响应 :{userId, userItemList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserNewItemListRequestVO.cs
source/2026-10-05.log:121 真机请求带 userData 与 userPlaylogList
★ 这是**结算之后**才调的接口,用于回查本局获得的新道具。
★ 请求体里的 userData / userPlaylogList 需要传,内容与 UpsertUserAll 一致。
调用示例:
from api import GetUserNewItemList
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLAY_VERSION_INT, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
class UserNewItemListRequestVO:
"""请求 VO(对应 UserNewItemListRequestVO)。"""
userId: int
version: int = PLAY_VERSION_INT
userData: List[Any] = field(default_factory=list)
userPlaylogList: List[Any] = field(default_factory=list)
def GetUserNewItemList(
user_id: int,
*,
version: int = PLAY_VERSION_INT,
user_data: Optional[List[Any]] = None,
user_playlog_list: Optional[List[Any]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取本次新增道具(对应 GetUserNewItemListApi)。
★ 这是**结算流程的前置接口**,真机在 UpsertUserAll 之前约 8 秒调用
(source/2026-10-05-new.log: 19:22:53 -> 19:23:02)。
★★ 传入的 user_data 必须是**游玩后**的档案,不是登录时的旧值。
真机实证(source/2026-10-05-new.log 的该请求):
userData[0].playCount = 1258 (登录时是 1258,本局后 1259)
userData[0].point = 4460 (登录时是 4110,本局后 4460)
userData[0].lastPlayDate = "2026-10-05 19:22:53.0"
即:**与 UpsertUserAll 里的 userData 完全一致**。
所以正确用法是先把本局演算跑完,再拿演算后的 userData 传进来。
参数:
user_id: 用户 ID(必填)
version: ROM 版本整数(默认取 config.PLAY_VERSION_INT)
user_data: 玩家档案数组(通常传 [游玩后的 userData])
user_playlog_list: 本局成绩数组(通常传 [playlog])
返回:
原始响应 dict,含 userItemList
"""
log_info("取新增道具:user_id=" + str(user_id) + " version=" + str(version))
return request_dict(
"GetUserNewItemListApi",
UserNewItemListRequestVO(
userId=user_id, version=version,
userData=list(user_data or []),
userPlaylogList=list(user_playlog_list or []),
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
+47
View File
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""
GetUserOption.py —— 取用户游戏设置(GetUserOptionApi)
================================================================================
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
对应接口:GetUserOptionApi
请求体 :UserIdRequestVO
响应 :userOption 的字段直接平铺在响应顶层
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserOptionRequestVO.cs
★ 形态提醒:Get 侧响应是**平铺**的(顶层直接就是 optionKind/noteSpeed/...),
而回传 Upsert 时要包成单元素数组 userOption:[{...}]。
裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。
调用示例:
from api import GetUserOption
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserOption(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户游戏设置(对应 GetUserOptionApi)。
返回:
原始响应 dict,设置字段平铺在顶层
"""
log_info("取用户游戏设置:user_id=" + str(user_id))
return _get("GetUserOptionApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+156
View File
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""
GetUserPreview.py —— 用户预览(GetUserPreviewApi)
================================================================================
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
对应接口:GetUserPreviewApi
请求体 :{"userId":.., "segaIdAuthKey":"", "token":"..", "clientId":".."}
source/Assembly-CSharp/Net.VO.Mai2/UserPreviewRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserPreview.cs:20-25
响应 :见 UserPreviewResponseVO(23 字段,含 errorId)
★ 真机调用时机:扫码拿到 userId/token 之后、UserLoginApi 之前
(source/2026-10-05.log:23)。
★ 客户端行为:isLogin=True 时拒绝继续登录,避免顶号
(source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。
调用示例:
from api import GetUserPreview
pv = GetUserPreview(12345678, "TESTTOKEN0000000000")
print(pv.userName, pv.playerRating)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
class UserPreviewRequestVO:
"""请求 VO(对应 UserPreviewRequestVO)。"""
userId: int
segaIdAuthKey: str = ""
token: str = ""
clientId: str = ""
@dataclass
class UserPreviewResponseVO:
"""
响应 VO(对应 UserPreviewResponseVO + 真机响应字段)。
★ 字段顺序按 source/Assembly-CSharp/Net.VO.Mai2/UserPreviewResponseVO.cs
与 source/2026-10-05.log:24 的真机响应合并。
"""
userId: int = 0
userName: str = ""
isLogin: bool = False
lastGameId: Optional[str] = None
lastRomVersion: str = ""
lastDataVersion: str = ""
lastLoginDate: str = ""
lastPlayDate: str = ""
playerRating: int = 0
nameplateId: int = 0
iconId: int = 0
trophyId: int = 0
partnerId: int = 0
frameId: int = 0
dispRate: int = 0
totalAwake: int = 0
isNetMember: int = 0
dailyBonusDate: str = ""
headPhoneVolume: Optional[int] = None
isInherit: bool = False
banState: int = 0
errorId: int = 0
def GetUserPreview(
user_id: int,
token: str,
*,
client_id: str = CLIENT_ID,
sega_id_auth_key: str = "",
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserPreviewResponseVO:
"""
获取用户预览信息。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填,来自 ParseQRCode)
client_id: 机台短值(可选)
sega_id_auth_key: SEGA ID 鉴权 key(可选,通常为空)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserPreviewResponseVO
"""
log_info("获取用户预览:user_id=" + str(user_id) + " client_id=" + client_id)
raw = request_dict(
"GetUserPreviewApi",
UserPreviewRequestVO(
userId=user_id,
segaIdAuthKey=sega_id_auth_key,
token=token,
clientId=client_id,
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
known = {f for f in UserPreviewResponseVO.__dataclass_fields__}
resp = UserPreviewResponseVO(**{k: v for k, v in raw.items() if k in known})
if resp.errorId != 0:
log_warning("服务器返回 errorId=" + str(resp.errorId))
if resp.banState != 0:
log_warning("账号可能被封禁:banState=" + str(resp.banState))
if resp.isLogin:
log_warning("账号处于已登录状态(isLogin=True),继续登录会顶号")
log_info(
"用户预览获取完成:userName=" + resp.userName
+ " playerRating=" + str(resp.playerRating)
+ " isLogin=" + str(resp.isLogin)
+ " isNetMember=" + str(resp.isNetMember)
)
return resp
if __name__ == "__main__":
import sys
from .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("userpreview_test")
try:
if len(sys.argv) < 3:
print("用法: python -m api.get_user_preview <user_id> <token> [client_id]")
return 1
cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID
pv = GetUserPreview(int(sys.argv[1]), sys.argv[2], client_id=cid)
print("")
for name in UserPreviewResponseVO.__dataclass_fields__:
print(" " + name.ljust(18) + "= " + str(getattr(pv, name)))
return 0
except Exception as exc:
log_error("获取用户预览失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""
GetUserRating.py —— 取用户 Rating 明细(GetUserRatingApi)
================================================================================
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
对应接口:GetUserRatingApi
请求体 :UserIdRequestVO
响应 :{userId, userRating:{rating, ratingList, newRatingList, nextRatingList,
nextNewRatingList, udemae}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRatingRequestVO.cs
★ 回传 Upsert 时是**单元素数组** userRatingList:[{...}],不是对象。
调用示例:
from api import GetUserRating
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRating(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户 Rating 明细(对应 GetUserRatingApi)。
返回:
原始响应 dict,含 userRating 子对象
"""
log_info("取用户 Rating 明细:user_id=" + str(user_id))
return _get("GetUserRatingApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""
GetUserRecommendMusic.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
================================================================================
对应接口:
GetUserRecommendRateMusicApi 请求体 UserIdRequestVO
响应 {userId, userRecommendRateMusicIdList:[...]}
GetUserRecommendSelectMusicApi 请求体 UserIdRequestVO
响应 {userId, userRecommendSelectionMusicIdList:[...]}
依据:
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendRateMusicRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendSelectionMusicRequestVO.cs
source/2026-10-05.log:79,81
调用示例:
from api import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRecommendRateMusic(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取推荐上分曲目(对应 GetUserRecommendRateMusicApi)。
返回:
原始响应 dict,含 userRecommendRateMusicIdList
"""
log_info("取推荐上分曲目:user_id=" + str(user_id))
return _get("GetUserRecommendRateMusicApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
def GetUserRecommendSelectMusic(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取推荐选曲(对应 GetUserRecommendSelectMusicApi)。
返回:
原始响应 dict,含 userRecommendSelectionMusicIdList
"""
log_info("取推荐选曲:user_id=" + str(user_id))
return _get("GetUserRecommendSelectMusicApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+44
View File
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""
GetUserRegion.py —— 取用户地区游玩统计(GetUserRegionApi)
================================================================================
取玩家在各地区的游玩次数。
对应接口:GetUserRegionApi
请求体 :UserIdRequestVO
响应 :{userId, length, userRegionList:[{regionId,playCount,created}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRegionRequestVO.cs
source/2026-10-05.log:77
调用示例:
from api import GetUserRegion
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRegion(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户地区游玩统计(对应 GetUserRegionApi)。
返回:
原始响应 dict,含 userRegionList
"""
log_info("取用户地区统计:user_id=" + str(user_id))
return _get("GetUserRegionApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""
GetUserRival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
================================================================================
取玩家的对手名单及其曲目成绩。
对应接口:
GetUserRivalDataApi 请求体 UserRivalRequestVO(userId + rivalId)
响应 {userId, userRivalData:{rivalId, rivalName}}
GetUserRivalMusicApi 请求体 UserRivalMusicRequestVO(userId + rivalId + nextIndex
+ userRivalMusicLevelList)
响应 {userId, rivalId, length, nextIndex, userRivalMusicList}
依据:
source/Assembly-CSharp/Net.VO.Mai2/UserRivalRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/UserRivalMusicRequestVO.cs
source/2026-10-05.log:95,101
★ 对手 ID 需要调用方提供(先由 GetUserFavoriteItemApi 的 kind=2 取到)。
调用示例:
from api import GetUserRivalData, GetUserRivalMusic
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import (
_get,
UserRivalRequestVO,
UserRivalMusicRequestVO,
RIVAL_MUSIC_LEVELS,
get_all_pages,
)
def GetUserRivalData(
user_id: int,
rival_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取对手资料(对应 GetUserRivalDataApi)。
参数:
user_id: 用户 ID(必填)
rival_id: 对手 ID(必填)
返回:
原始响应 dict,含 userRivalData
"""
log_info("取对手资料:user_id=" + str(user_id) + " rival_id=" + str(rival_id))
return _get(
"GetUserRivalDataApi",
UserRivalRequestVO(userId=user_id, rivalId=rival_id),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserRivalMusic(
user_id: int,
rival_id: int,
*,
next_index: int = 0,
levels: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取对手曲目成绩(单页,对应 GetUserRivalMusicApi)。
参数:
user_id: 用户 ID(必填)
rival_id: 对手 ID(必填)
next_index: 分页索引(0 = 首页)
levels: 难度档位列表(可选,默认 0/1/2/3/4/10)
返回:
原始响应 dict,含 userRivalMusicList
"""
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
log_info("取对手曲目成绩:user_id=" + str(user_id) + " rival_id=" + str(rival_id)
+ " levels=" + str(lv))
return _get(
"GetUserRivalMusicApi",
UserRivalMusicRequestVO(
userId=user_id, rivalId=rival_id, nextIndex=next_index,
userRivalMusicLevelList=[{"level": x} for x in lv],
),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserRivalMusicAll(
user_id: int,
rival_id: int,
*,
levels: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取对手曲目成绩(自动翻页取全)。
返回:
{"userRivalMusicList": [...]}
"""
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
log_info("取对手曲目成绩(全量):user_id=" + str(user_id) + " rival_id=" + str(rival_id))
items = get_all_pages(
"GetUserRivalMusicApi", user_id, "userRivalMusicList",
body_factory=lambda ni: UserRivalMusicRequestVO(
userId=user_id, rivalId=rival_id, nextIndex=ni or 0,
userRivalMusicLevelList=[{"level": x} for x in lv]),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("对手曲目获取完成:" + str(len(items)) + " 条")
return {"userRivalMusicList": items}
+58
View File
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""
GetUserShopStock.py —— 取用户商店库存(GetUserShopStockApi)
================================================================================
取玩家在各商店商品的已兑换次数。
对应接口:GetUserShopStockApi
请求体 :UserShopStockRequestVO(userId + shopItemIdList)
响应 :{userId, userShopStockList:[{shopItemId,tradeCount}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserShopStockRequestVO.cs
source/2026-10-05.log:117 真机请求体带 100+ 个 shopItemId
★ shopItemIdList 需要调用方提供(来自游戏 Master 数据,脚本无法自行推导)。
不传时按空列表发送,服务端返回空 userShopStockList —— 这是合法的。
调用示例:
from api import GetUserShopStock
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLAY_SHOP_ITEM_IDS, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserShopStockRequestVO
def GetUserShopStock(
user_id: int,
*,
shop_item_id_list: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户商店库存(对应 GetUserShopStockApi)。
参数:
user_id: 用户 ID(必填)
shop_item_id_list: 商品 ID 清单(可选,默认取 config.PLAY_SHOP_ITEM_IDS)
返回:
原始响应 dict,含 userShopStockList
"""
ids = list(shop_item_id_list) if shop_item_id_list is not None \
else list(PLAY_SHOP_ITEM_IDS)
log_info("取用户商店库存:user_id=" + str(user_id) + " 商品数=" + str(len(ids)))
return _get(
"GetUserShopStockApi",
UserShopStockRequestVO(userId=user_id, shopItemIdList=ids),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
+704
View File
@@ -0,0 +1,704 @@
# -*- coding: utf-8 -*-
"""
LogSetup.py —— 统一日志模块(INFO / DEBUG 双模式)
================================================================================
【两种模式】
INFO (默认) 只展示 api 脚本的运行情况和结果:
=== ParseQRCode 开始运行 ===
[接口说明] ...
[调用参数] ...
[运行结果] ...
=== ParseQRCode 运行结束 ===
DEBUG 在 INFO 基础上,额外展示「向 api 发送的数据」和「api 返回的数据」:
-> 请求 GetUserPreviewApi
URL : https://...
请求头 : {...}
请求体 : {...JSON 全文...}
<- 响应 GetUserPreviewApi
状态码 : 200
响应体 : {...JSON 全文...}
【模式从哪来】
优先级: setup_logging(level=...) 显式传参 > config.py 的 LOG_LEVEL > INFO
日常只改 config.py 一行:
LOG_LEVEL = "info" # 只看流程
LOG_LEVEL = "debug" # 连收发数据一起看
【日志文件(两种形式同时存在、同时写入)】
logs/latest.log 覆盖模式("w")—— 只包含【当次】主脚本运行产出的日志,
下一次运行主脚本时被覆盖
logs/YYYY-MM-DD.log 追加模式("a")—— 存放【当日】产生的所有日志
每行都带 [YYYY-MM-DD HH:MM:SS] 时间戳。
【主脚本怎么用】
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
def main():
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
try:
user_id, token = ParseQRCode(qr) # 自动产出「开始运行 / 运行结束」
login = UserLogin(user_id, token)
finally:
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
api 函数不需要自己写 print,也不需要装饰器 —— api/__init__.py 在导入时就
统一套上了日志外壳(见 autowrap)。
【api 脚本怎么用】
from api import log_info, log_debug, log_request, log_response
def GetUserPreview(user_id, token, ...):
# 函数的第一行 docstring 会成为日志里的「[接口说明]」
# 例如: '''获取用户预览信息'''
log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写
log_request(url=url, body=req, headers=hdr, api_name="GetUserPreviewApi")
raw = _http_post(...)
log_response(status=200, body=raw, headers=hdr, api_name="GetUserPreviewApi")
return parsed
"""
from __future__ import annotations
import atexit
import functools
import json
import os
import platform
import time
from datetime import datetime
from typing import Any, Callable, Iterable, Optional
# =============================================================================
# 常量与全局状态
# =============================================================================
LEVEL_INFO = "info"
LEVEL_DEBUG = "debug"
_LEVEL_RANK = {LEVEL_INFO: 20, LEVEL_DEBUG: 10}
# 项目根目录(本文件在 api/ 下,根目录是上一级)
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_LOGS_DIR = os.path.join(_ROOT_DIR, "logs")
_SEPARATOR = "=" * 66
_INDENT = " "
# 全局状态
_session_started = False
_session_script_name = ""
_current_level = LEVEL_INFO
_value_max = 0
_date_log_path: Optional[str] = None
_latest_log_path: Optional[str] = None
_date_log_file = None
_latest_log_file = None
# 被自动跟踪的 api 函数名(供横幅展示)
_tracked_api_names: list = []
# 本次会话实际调用过的 api(按首次调用顺序)
_called_apis: list = []
# =============================================================================
# 模式判定
# =============================================================================
def _normalize_level(level: Any) -> str:
"""把各种写法的模式名归一化成 'info' / 'debug'。"""
if level is None:
return LEVEL_INFO
text = str(level).strip().lower()
if text in ("debug", "dbg", "verbose", "详细", "调试"):
return LEVEL_DEBUG
return LEVEL_INFO
def _config_level() -> str:
"""从 config.py 读 LOG_LEVEL;读不到就用 INFO。"""
try:
import config # type: ignore
return _normalize_level(getattr(config, "LOG_LEVEL", LEVEL_INFO))
except Exception:
return LEVEL_INFO
def _config_value_max() -> int:
"""从 config.py 读 LOG_VALUE_MAX(0 = 不截断)。"""
try:
import config # type: ignore
return int(getattr(config, "LOG_VALUE_MAX", 0) or 0)
except Exception:
return 0
def get_level() -> str:
"""返回当前日志模式('info' / 'debug')。"""
return _current_level
def is_debug() -> bool:
"""当前是否 DEBUG 模式。"""
return _LEVEL_RANK.get(_current_level, 20) <= _LEVEL_RANK[LEVEL_DEBUG]
def set_level(level: Any) -> str:
"""运行时切换模式(一般不用,改 config.py 即可)。返回切换后的模式。"""
global _current_level
_current_level = _normalize_level(level)
return _current_level
# =============================================================================
# 日志文件
# =============================================================================
def _ensure_logs_dir() -> None:
os.makedirs(_LOGS_DIR, exist_ok=True)
def _get_date_log_path() -> str:
return os.path.join(_LOGS_DIR, datetime.now().strftime("%Y-%m-%d") + ".log")
def _get_latest_log_path() -> str:
return os.path.join(_LOGS_DIR, "latest.log")
def _write_files(message: str) -> None:
"""把一段文本写进两个日志文件(每行带时间戳)。"""
timestamp = datetime.now().strftime("[%Y-%m-%d %H:%M:%S]")
lines = message.splitlines() or [""]
payload = "".join(timestamp + line + "\n" for line in lines)
for handle in (_date_log_file, _latest_log_file):
if handle:
try:
handle.write(payload)
handle.flush()
except Exception:
pass
def _emit(message: str = "", *, console: bool = True) -> None:
"""统一出口:控制台 + 两个日志文件。"""
if console:
try:
print(message)
except Exception:
try:
print(message.encode("utf-8", "replace").decode("utf-8", "replace"))
except Exception:
pass
_write_files(message)
def _close_log_files() -> None:
global _date_log_file, _latest_log_file
for name in ("_date_log_file", "_latest_log_file"):
handle = globals()[name]
if handle:
try:
handle.close()
except Exception:
pass
globals()[name] = None
# =============================================================================
# 自动跟踪:给 api 函数套日志外壳
# =============================================================================
#
# 目标:主脚本只写 setup_logging("名字") + 业务代码,
# 调用的每个 api 函数自动产出「===函数名 开始运行=== / ===函数名 运行结束===」,
# 调用方不必自己写装饰器,也不必自己埋点。
#
# 原理:api/__init__.py 在导入完各 api 脚本后调用 autowrap(globals(), _API_FUNCS),
# 把命名空间里的函数替换成带日志的版本 —— 于是 from api import ParseQRCode
# 拿到的已经是包装后的函数。
def _describe(func: Callable) -> str:
"""取函数 docstring 首行,作为「[接口说明]」。"""
doc = (func.__doc__ or "").strip()
if not doc:
return ""
return doc.splitlines()[0].strip()
def _make_tracked(func: Callable) -> Callable:
"""生成带日志外壳的包装函数。"""
name = func.__name__
summary = _describe(func)
@functools.wraps(func)
def tracked(*args, **kwargs) -> Any:
_note_api_call(name)
_emit("")
_emit("=== " + name + " 开始运行 ===")
if summary:
_emit(_INDENT + "[接口说明] " + summary)
# 调用参数:DEBUG 展开多行 JSON,INFO 记单行摘要
if args:
if is_debug():
_emit(_INDENT + "[位置参数]")
_emit(_fmt_pretty(list(args)))
else:
_emit(_INDENT + "[位置参数] " + ", ".join(_fmt(a) for a in args))
if kwargs:
shown = {k: v for k, v in kwargs.items() if v is not None}
if shown:
if is_debug():
_emit(_INDENT + "[关键字参数]")
_emit(_fmt_pretty(shown))
else:
_emit(_INDENT + "[关键字参数] "
+ ", ".join(k + "=" + _fmt(v) for k, v in shown.items()))
t0 = time.monotonic()
try:
result = func(*args, **kwargs)
except BaseException as exc:
ms = int((time.monotonic() - t0) * 1000)
_emit(_INDENT + "[运行结果] 异常 " + type(exc).__name__ + ": "
+ str(exc) + "(耗时 " + str(ms) + " ms)")
_emit("=== " + name + " 运行结束 ===")
_emit("")
raise
ms = int((time.monotonic() - t0) * 1000)
_emit(_INDENT + "[运行结果] 成功 " + _fmt_result(result)
+ "(耗时 " + str(ms) + " ms)")
if is_debug() and result is not None:
_emit(_INDENT + "[返回数据]")
_emit(_fmt_pretty(result))
_emit("=== " + name + " 运行结束 ===")
_emit("")
return result
tracked.__mai_logged__ = True # type: ignore[attr-defined]
tracked.__mai_original__ = func # type: ignore[attr-defined]
return tracked
def autowrap(namespace: dict, names: Iterable[str]) -> list:
"""
给命名空间里的函数套上日志外壳(导入时调用,幂等)。
参数:
namespace: 目标命名空间,通常是 api/__init__.py 的 globals()
names: 要包装的函数名序列
返回:
实际完成包装的函数名列表
"""
wrapped = []
for name in names:
obj = namespace.get(name)
if obj is None or not callable(obj):
continue
if getattr(obj, "__mai_logged__", False):
wrapped.append(name)
continue
try:
namespace[name] = _make_tracked(obj)
wrapped.append(name)
except Exception:
continue
for n in wrapped:
if n not in _tracked_api_names:
_tracked_api_names.append(n)
return wrapped
def _note_api_call(name: str) -> None:
"""记录主脚本调用过的 api(去重,保留首次调用顺序)。"""
if name not in _called_apis:
_called_apis.append(name)
def get_called_apis() -> list:
"""返回本次会话实际调用过的 api 名称(按首次调用顺序)。"""
return list(_called_apis)
# =============================================================================
# 会话:初始化 / 收尾
# =============================================================================
def setup_logging(script_name: str, level: Any = None) -> str:
"""
初始化日志会话(主脚本开头调用一次)。
做三件事:
1. 确定日志模式(level 显式传参 > config.LOG_LEVEL > INFO)
2. 打开两个日志文件:日期日志(追加)+ latest.log(覆盖)
3. 打印会话横幅
参数:
script_name: 脚本名,显示在横幅上
level: 日志模式;None(默认)= 读 config.py 的 LOG_LEVEL
返回:
实际生效的模式('info' / 'debug')
"""
global _session_started, _session_script_name, _value_max
global _date_log_path, _latest_log_path, _date_log_file, _latest_log_file
set_level(level if level is not None else _config_level())
_value_max = _config_value_max()
if _session_started:
return _current_level
_ensure_logs_dir()
_session_script_name = script_name
_date_log_path = _get_date_log_path()
_latest_log_path = _get_latest_log_path()
_called_apis.clear()
_date_log_file = open(_date_log_path, "a", encoding="utf-8") # 追加:当日全部
_latest_log_file = open(_latest_log_path, "w", encoding="utf-8") # 覆盖:仅当次
_print_banner(script_name)
_session_started = True
return _current_level
def _print_banner(script_name: str) -> None:
mode_text = "DEBUG(含收发数据)" if is_debug() else "INFO(仅运行情况与结果)"
lines = [
_SEPARATOR,
_INDENT + "日志会话开始 —— " + script_name,
_SEPARATOR,
_INDENT * 2 + "主脚本 : " + script_name,
_INDENT * 2 + "开始时间 : " + datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
_INDENT * 2 + "日志模式 : " + mode_text,
_INDENT * 2 + "Python : " + platform.python_version()
+ " (" + platform.python_implementation() + " " + platform.machine() + ")",
_INDENT * 2 + "工作目录 : " + os.getcwd(),
_INDENT * 2 + "已挂载 api : " + str(len(_tracked_api_names))
+ " 个(自动跟踪,主脚本无需写日志代码)",
_INDENT * 2 + "当次日志 : " + str(_latest_log_path),
_INDENT * 2 + "日期日志 : " + str(_date_log_path),
_SEPARATOR,
]
_emit("\n".join(lines))
def teardown_logging() -> None:
"""结束日志会话(主脚本结尾调用;不调用也会在退出时自动收尾)。"""
global _session_started
if not _session_started:
return
lines = [_SEPARATOR, _INDENT + "本次运行调用的 api", _SEPARATOR]
if _called_apis:
for i, name in enumerate(_called_apis, 1):
lines.append(_INDENT * 2 + str(i).rjust(2) + ". " + name)
else:
lines.append(_INDENT * 2 + "(未调用任何 api)")
lines.append(_SEPARATOR)
lines.append(_INDENT + "日志会话结束 —— " + _session_script_name)
lines.append(_SEPARATOR)
_emit("\n".join(lines))
_close_log_files()
_session_started = False
# =============================================================================
# 值格式化
# =============================================================================
def _truncate(text: str) -> str:
if _value_max and len(text) > _value_max:
return text[:_value_max] + "...(共 " + str(len(text)) + " 字符)"
return text
def _fmt(value: Any) -> str:
"""把一个值渲染成紧凑单行文本。"""
if isinstance(value, str):
return _truncate(value)
if value is None or isinstance(value, (int, float, bool)):
return repr(value)
if isinstance(value, (dict, list, tuple)):
try:
return _truncate(json.dumps(value, ensure_ascii=False,
separators=(",", ":"), default=str))
except Exception:
return _truncate(repr(value))
if hasattr(value, "__dict__"):
try:
data = {k: v for k, v in vars(value).items() if not k.startswith("_")}
return _truncate(json.dumps(data, ensure_ascii=False,
separators=(",", ":"), default=str))
except Exception:
return _truncate(repr(value))
return _truncate(repr(value))
def _to_plain(value: Any) -> Any:
"""把 dataclass / 普通对象递归转成可 JSON 化的结构。"""
if value is None or isinstance(value, (int, float, bool, str)):
return value
if isinstance(value, dict):
return {k: _to_plain(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_to_plain(v) for v in value]
if hasattr(value, "__dataclass_fields__"):
return {k: _to_plain(getattr(value, k, None)) for k in value.__dataclass_fields__}
if hasattr(value, "__dict__"):
return {k: _to_plain(v) for k, v in vars(value).items() if not k.startswith("_")}
return str(value)
def _fmt_pretty(value: Any, indent: str = " ") -> str:
"""把 dict / list / VO 渲染成多行缩进的 JSON。DEBUG 下完整输出,不折叠。"""
data = _to_plain(value)
if isinstance(data, str):
stripped = data.strip()
if stripped[:1] in ("{", "["):
try:
data = json.loads(stripped)
except Exception:
return "\n".join(indent + line for line in data.splitlines())
try:
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
except Exception:
text = repr(data)
if _value_max and len(text) > _value_max:
text = text[:_value_max] + "\n" + indent + "...(共 " + str(len(text)) + " 字符,已截断)"
return "\n".join(indent + line for line in text.splitlines())
def _fmt_result(result: Any) -> str:
"""结果的紧凑摘要(INFO 用)。"""
if isinstance(result, list):
return "list(" + str(len(result)) + " 项)"
if isinstance(result, tuple):
return "tuple(" + str(len(result)) + " 项)"
if isinstance(result, dict):
return "dict(" + str(len(result)) + " 键)"
if isinstance(result, str):
return repr(result) if len(result) <= 60 else "str(" + str(len(result)) + " 字符)"
if hasattr(result, "__dataclass_fields__"):
return type(result).__name__ + "(" + str(len(result.__dataclass_fields__)) + " 字段)"
return repr(result)
# =============================================================================
# 流程日志(INFO 可见)
# =============================================================================
def log_info(message: str) -> None:
"""普通信息 —— 相当于「会进日志文件的 print」。"""
_emit(str(message))
def log_step(title: str, *, index: int = 0, total: int = 0) -> None:
"""记一个运行步骤:log_step("扫码登录", index=1, total=5) -> [步骤 1/5] 扫码登录"""
if index and total:
_emit("[步骤 " + str(index) + "/" + str(total) + "] " + title)
elif index:
_emit("[步骤 " + str(index) + "] " + title)
else:
_emit("[步骤] " + title)
def log_result(message: Any) -> None:
"""记一条运行结果。"""
_emit("[结果] " + (message if isinstance(message, str) else _fmt(message)))
def log_warning(message: str) -> None:
"""警告(不影响流程,但需要留意)。"""
_emit("[警告] " + str(message))
def log_error(message: str) -> None:
"""错误。"""
_emit("[错误] " + str(message))
def log_section(title: str) -> None:
"""记一个分节(带分隔线),用于主脚本分段。"""
_emit("\n" + "-" * 60 + "\n" + title + "\n" + "-" * 60)
def log_field(name: str, value: Any, *, debug_only: bool = False) -> None:
"""
记一个「字段: 值」;DEBUG 下 dict / list / VO 自动多行展开。
log_field("user_id", 12345)
log_field("请求体", {"userId": 12345}, debug_only=True) # 只在 DEBUG 出现
"""
if debug_only and not is_debug():
return
if is_debug() and (isinstance(value, (dict, list)) or hasattr(value, "__dataclass_fields__")):
_emit(name + ":")
_emit(_fmt_pretty(value))
return
_emit(name + ": " + _fmt(value))
def log_debug(message: Any) -> None:
"""调试信息 —— 仅 DEBUG 模式输出。"""
if not is_debug():
return
_emit("[调试] " + _fmt(message))
# =============================================================================
# 收发数据日志(DEBUG 专属)—— api 脚本用它记录「发送的数据 / 返回的数据」
# =============================================================================
def log_request(
url: str = "",
body: Any = None,
headers: Any = None,
*,
api_name: str = "",
) -> None:
"""
记录一次「向 api 发送的数据」(仅 DEBUG 模式输出)。
参数:
url: 完整请求地址
body: 请求体(dict / VO / JSON 字符串),展开成 JSON 全文
headers: 请求头(dict)
api_name: 接口名
"""
if not is_debug():
return
_emit("")
_emit("-> 请求 " + api_name if api_name else "-> 请求")
if url:
_emit(" URL : " + str(url))
if headers:
_emit(" 请求头 : " + _fmt(headers))
if body is not None:
_emit(" 请求体 :")
_emit(_fmt_pretty(body))
def log_response(
status: Any = None,
body: Any = None,
headers: Any = None,
*,
api_name: str = "",
elapsed_ms: Optional[int] = None,
) -> None:
"""
记录一次「api 返回的数据」(仅 DEBUG 模式输出)。
参数:
status: HTTP 状态码
body: 响应体(JSON 字符串 / dict / VO)
headers: 响应头(dict)
api_name: 接口名
elapsed_ms: 耗时(毫秒)
"""
if not is_debug():
return
_emit("")
_emit("<- 响应 " + api_name if api_name else "<- 响应")
if status is not None:
_emit(" 状态码 : " + str(status))
if elapsed_ms is not None:
_emit(" 耗时 : " + str(elapsed_ms) + " ms")
if headers:
_emit(" 响应头 : " + _fmt(headers))
if body is not None:
_emit(" 响应体 :")
_emit(_fmt_pretty(body))
def log_payload(label: str, data: Any) -> None:
"""记录任意报文 / 数据结构(仅 DEBUG 模式输出)。"""
if not is_debug():
return
_emit("")
_emit(" " + label + " :")
_emit(_fmt_pretty(data))
# =============================================================================
# 兼容:装饰器与 logger 对象
# =============================================================================
def api_log(func: Callable) -> Callable:
"""
api 函数装饰器 —— 与自动跟踪等价(重复包装会被跳过)。
一般【不需要】手写:api/__init__.py 的 autowrap 已经统一套好外壳。
这里保留是为了让 api 脚本单独运行(python -m api.xxx)时也能记录。
"""
if getattr(func, "__mai_logged__", False):
return func
return _make_tracked(func)
class _Logger:
"""兼容旧写法的 logger 对象;新代码直接用模块级函数即可。"""
def info(self, message: str) -> None:
log_info(message)
def warning(self, message: str) -> None:
log_warning(message)
def error(self, message: str) -> None:
log_error(message)
def debug(self, message: Any) -> None:
log_debug(message)
def step(self, title: str, **kwargs) -> None:
log_step(title, **kwargs)
def result(self, message: Any) -> None:
log_result(message)
_logger_instance = _Logger()
def get_logger() -> _Logger:
"""获取全局 logger 实例(等价于直接用模块级函数)。"""
return _logger_instance
# =============================================================================
# 退出时自动收尾
# =============================================================================
def _auto_teardown() -> None:
try:
if _session_started:
teardown_logging()
else:
_close_log_files()
except Exception:
pass
atexit.register(_auto_teardown)
+820
View File
@@ -0,0 +1,820 @@
# -*- coding: utf-8 -*-
"""
MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
================================================================================
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
--------------------------------------------------------------------------------
一、协议依据(全部来自 source/ 反编译源码)
--------------------------------------------------------------------------------
1) 服务器基址与 API 路径
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
ServerUri = Auth.GameServerUri + MovieServerUri("<REDACTED>/")
source/Assembly-CSharp/Net.Packet/Packet.cs:116
Client = NetHttpClient.Create(BaseUrl + Obfuscator(query.Api))
source/Assembly-CSharp/Net.Packet/Packet.cs:485
Obfuscator(s) = md5_hex_lower(s + "<REDACTED>")
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
Api = api + "MaimaiChn"
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "<REDACTED>")
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "<REDACTED>")
= 83a5d5a20b062c5c2ad817460b8f7f76
(source/2026-10-05.log:1 的请求就是打到这个尾段)
2) 请求体封装(三段:JSON -> deflate -> AES)
source/Assembly-CSharp/Net/NetHttpClient.cs:262-300 RequestCallback
a. DeflateStream(CompressionMode.Compress) 压缩 JSON 字节
b. 手工拼 zlib 头 0x78 0x9C + 压缩数据 + Adler32(4 字节大端)
c. CipherAES.Encrypt(...) 整体 AES 加密
source/Assembly-CSharp/Net/CipherAES.cs:11-30
AES-256-CBC / PKCS7 / Key 32B / IV 16B
3) 响应体解封装(逆过程)
source/Assembly-CSharp/Net/NetHttpClient.cs:355-380 Decrypt + Decompress
a. AES 解密
b. 去掉前 2 字节 zlib 头与后 4 字节 Adler32
c. Deflate 解压 -> UTF-8 JSON
4) 请求头
source/Assembly-CSharp/Net/NetHttpClient.cs:174-190 Request()
Content-Type : application/json
charset : UTF-8
Mai-Encoding : 1.55 (DefaultHttpHeaderEncryption)
Content-Encoding: deflate (有请求体时)
User-Agent : <混淆后的API名>#<userId 或 clientId>
(source/Assembly-CSharp/Net/NetPacketUtil.cs:12-19)
number : <ping 毫秒>(非 Ping 接口;Packet.cs:189)
★ number 的语义:GameManager.PingMsec,只由 Ping 心跳包刷新
(PingChecker.cs:227 <- PacketPing.cs:34 的 sw.ElapsedMilliseconds)。
真机整局 53 个业务请求的 number 恒为同一个值(实测 63)。
所以调用方**应当先跑一次 Ping()**,之后的请求才会带上真实 number;
没跑过时 number = 0,与真机开机后第一屏的行为一致。
★ Cookie:★ UserLoginApi 的响应会下发
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
(Packet.cs:228-234 只在 UserLogin 响应上把它收进 CookieContainer,
之后 Packet.cs:190-193 每个带 userId 的请求都挂回去)。
Ping 等接口的响应头里没有 Set-Cookie,所以必须跑过登录才会拿到。
这是**真实的会话粘滞**,必须带着。
requests.Session 会自动收下并自动带上,无需额外代码 ——
但前提是**全程复用同一个 MaimaiNetClient**(用 get_default_client())。
--------------------------------------------------------------------------------
二、★ 请求头集合:必须与真机一致(2026-10-06 真机抓包实证)
--------------------------------------------------------------------------------
真机(Unity Mono 的 HttpWebRequest)实际发出的头**只有这几个**:
number / Content-Type / User-Agent / charset / Mai-Encoding /
Content-Encoding / (+ 登录后的 Cookie)
**没有 Accept,也没有 Connection。**
requests 默认会补 Accept: * / Connection: keep-alive,
已在 MaimaiNetClient.__init__ 里从 session.headers 删掉。
★ Accept-Encoding 是唯一的例外,必须**存在且为空**:
实测(GetGameSettingApi,2026-10-06):
Accept-Encoding: "" -> 200 + application/json
不发 / "gzip, deflate" / "identity" -> 200 + application/octet-stream(0 字节)
反推:真机能拿到 JSON 响应体,说明 Mono 发的也是**空值**的 Accept-Encoding
(Headers.AllKeys 里看不到它,是框架层加的)。
所以这里固定 Accept-Encoding: ""。
--------------------------------------------------------------------------------
三、对外接口
--------------------------------------------------------------------------------
MaimaiNetClient 客户端类(连接复用 + Cookie 管理)
get_default_client() 取全局默认客户端
request(...) 便捷函数:发一次请求并返回原始 JSON 字符串
request_vo(...) 便捷函数:发一次请求并返回映射后的 dataclass
加解密工具:aes_encrypt / aes_decrypt / deflate_compress / deflate_decompress
协议工具 :obfuscate_api_name / build_api_url / adler32
"""
from __future__ import annotations
import hashlib
import json
import time
import zlib
from dataclasses import dataclass, field, is_dataclass
from typing import Any, Dict, List, Optional, Type, TypeVar
from urllib.parse import urljoin
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from config import CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS
# =============================================================================
# 协议常量(原在 config.py,2026-10-06 下沉到本模块 —— 它们只服务于网络层)
# =============================================================================
#: 游戏服务器(Maimai2Servlet)
#: 依据 source/Assembly-CSharp/Manager/OperationManager.cs:225,338
#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("<REDACTED>/")
BASE_URL = ""
#: AES 参数,依据 source/Assembly-CSharp/Net/CipherAES.cs:11-12
#: AesKey 32 字节 -> AES-256;AesIV 16 字节
AES_KEY = b""
AES_IV = b""
#: API 名混淆盐,依据 source/Assembly-CSharp/Net.Packet/Packet.cs:44
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
OBFUSCATE_PARAM = ""
from .LogSetup import log_debug, log_error, log_request, log_response
# =============================================================================
# 协议常量
# =============================================================================
#: API 名后缀(NetQuery.cs:14 Api = api + "MaimaiChn")
API_SUFFIX = "MaimaiChn"
#: HTTP 头(NetHttpClient.cs:174-190)
HEADER_CONTENT_TYPE = "application/json"
HEADER_CHARSET = "UTF-8"
HEADER_CONTENT_ENCODING = "deflate"
#: AES 参数
AES_BLOCK_SIZE = 16
T = TypeVar("T")
# =============================================================================
# 一、协议工具
# =============================================================================
def obfuscate_api_name(api_name: str) -> str:
"""
对逻辑接口名做 MD5 混淆,得到 URL 尾段(对应 Packet.Obfuscator)。
逻辑名 "GetUserPreviewApi" -> "GetUserPreviewApiMaimaiChn"
-> md5(上面 + "<REDACTED>")
参数:
api_name: 逻辑接口名,**不要**自己带 MaimaiChn 后缀
返回:
32 位小写十六进制字符串
"""
full_name = api_name if api_name.endswith(API_SUFFIX) else api_name + API_SUFFIX
return hashlib.md5((full_name + OBFUSCATE_PARAM).encode("utf-8")).hexdigest()
def build_api_url(api_name: str) -> str:
"""构建完整的请求 URL。"""
return urljoin(BASE_URL, obfuscate_api_name(api_name))
def build_user_agent(api_name: str, user_id: int = 0, client_id: str = CLIENT_ID) -> str:
"""
构建 User-Agent(对应 NetPacketUtil.GetUserAgent)。
userId != 0 -> "<混淆API名>#<userId>"
userId == 0 -> "<混淆API名>#<clientId>"
"""
tail = user_id if user_id else client_id
return obfuscate_api_name(api_name) + "#" + str(tail)
def adler32(data: bytes) -> int:
"""
计算 Adler-32 校验和(对应 NetHttpClient.Adler32:437-452)。
★ 注意与 zlib.adler32 的区别:C# 端是自己实现的,
返回值是 (b << 16) + a,与 zlib.adler32 一致,这里保留原实现以便对照。
"""
a = 1
b = 0
for byte in data:
a = (a + byte) % 65521
b = (b + a) % 65521
return (b << 16) | a
# =============================================================================
# 二、加解密 / 压缩
# =============================================================================
def aes_encrypt(data: bytes) -> bytes:
"""AES-256-CBC 加密(PKCS7 填充)。对应 CipherAES.Encrypt。"""
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
return cipher.encrypt(pad(data, AES_BLOCK_SIZE))
def aes_decrypt(data: bytes) -> bytes:
"""AES-256-CBC 解密(PKCS7 去填充)。对应 CipherAES.Decrypt。"""
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
return unpad(cipher.decrypt(data), AES_BLOCK_SIZE)
def deflate_compress(data: bytes) -> bytes:
"""
原始 deflate 压缩 + 手工拼 zlib 头与 Adler32。
对应 NetHttpClient.cs:262-300 RequestCallback:
memoryStream.WriteByte(120); // 0x78
memoryStream.WriteByte(156); // 0x9C
... 原始 deflate 数据 ...
... Adler32 大端 4 字节 ...
★ C# 的 DeflateStream 产生的是**原始 deflate**(无 zlib 头),
所以 Python 侧要用 compressobj(wbits=-zlib.MAX_WBITS)。
"""
compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS)
compressed = compressor.compress(data) + compressor.flush()
checksum = adler32(data)
return bytes([0x78, 0x9C]) + compressed + checksum.to_bytes(4, "big")
def deflate_decompress(data: bytes) -> bytes:
"""
去掉 zlib 头与 Adler32 后做原始 deflate 解压。
对应 NetHttpClient.cs:355-380 Decompress:
_temporaryStream.Position = 2; // 跳过 2 字节头
_temporaryStream.SetLength(_temporaryStream.Length - 4); // 去掉 4 字节校验
"""
if len(data) < 6:
return b""
return zlib.decompress(data[2:-4], -zlib.MAX_WBITS)
# =============================================================================
# 三、真机时间戳
# =============================================================================
def local_utc_offset_seconds() -> int:
"""取本机时区相对 UTC 的偏移秒数(东八区 = +28800)。"""
offset = -time.timezone
if time.daylight and time.localtime().tm_isdst > 0:
offset = -time.altzone
return int(offset)
def login_date_time_now() -> int:
"""
生成真机 UserLoginApi 的 loginDateTime。
★ 真机算法(source/Assembly-CSharp/MAI2/DateTime.cs + PacketUserLogin.cs:29):
MAI2.DateTime.Now 返回 DateTime.UtcNow + LocalOffset,Kind = Unspecified;
new DateTimeOffset(该 Unspecified 值) 会再按本地时区加一次偏移,
最终 ToUnixTimeSeconds() = 标准 Unix 秒 + 本地时区偏移。
东八区即 now + 28800。
★ 为什么必须一致:
PacketUserLogin.cs:43 会把请求值回填进响应,
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
与登录时不一致会被服务端拒绝。
"""
return int(time.time()) + local_utc_offset_seconds()
# =============================================================================
# 四、VO 序列化 / 反序列化
# =============================================================================
def vo_to_json(obj: Any) -> str:
"""
把请求对象序列化成紧凑 JSON(对应 VOSerializer.Serialize)。
规则:
- dataclass -> 按字段声明顺序,跳过值为 None 的字段
- 带 __dict__ 的对象 -> 同上
- dict / 其他 -> 直接 json.dumps
"""
if obj is None:
return "{}"
if is_dataclass(obj) and not isinstance(obj, type):
data = {k: getattr(obj, k) for k in obj.__dataclass_fields__}
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
if isinstance(obj, dict):
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
if hasattr(obj, "__dict__"):
data = {k: v for k, v in vars(obj).items() if v is not None and not k.startswith("_")}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
def _json_default(value: Any) -> Any:
"""json.dumps 的兜底转换:dataclass / 普通对象 -> dict。"""
if is_dataclass(value) and not isinstance(value, type):
return {k: getattr(value, k) for k in value.__dataclass_fields__}
if hasattr(value, "__dict__"):
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
return str(value)
def _convert_value(value: Any, target_type: Any) -> Any:
"""按目标类型递归转换嵌套 dataclass。"""
if value is None:
return None
if isinstance(target_type, type) and is_dataclass(target_type) and isinstance(value, dict):
kwargs = {}
for fname, fdef in target_type.__dataclass_fields__.items():
if fname in value:
kwargs[fname] = _convert_value(value[fname], fdef.type)
return target_type(**kwargs)
origin = getattr(target_type, "__origin__", None)
if origin is list and isinstance(value, list):
args = getattr(target_type, "__args__", None)
if args and isinstance(args[0], type) and is_dataclass(args[0]):
return [_convert_value(v, args[0]) for v in value]
return value
return value
def vo_from_json(cls: Type[T], json_str: str) -> T:
"""
把响应 JSON 反序列化成 dataclass(对应 VOSerializer.Deserialize)。
★ 只填充类里声明过的字段 —— 服务端多给的字段会被忽略,不会报错。
"""
data = json.loads(json_str)
if not isinstance(data, dict):
return data # type: ignore[return-value]
if is_dataclass(cls):
kwargs = {}
for fname, fdef in cls.__dataclass_fields__.items():
if fname in data:
kwargs[fname] = _convert_value(data[fname], fdef.type)
return cls(**kwargs)
obj = cls() # type: ignore[call-arg]
for k, v in data.items():
if hasattr(obj, k):
setattr(obj, k, v)
return obj
# =============================================================================
# 五、网络客户端
# =============================================================================
class MaimaiNetError(RuntimeError):
"""网络层 / 协议层错误。"""
class MaimaiNetClient:
"""
舞萌DX 游戏服务器客户端。
一次完整请求的流程:
请求方向:VO -> 紧凑 JSON -> 原始 deflate -> 0x78 0x9C + Adler32 -> AES-256-CBC -> HTTP POST
响应方向:HTTP Body -> AES 解密 -> 去 2 字节头 / 4 字节尾 -> deflate 解压 -> JSON -> VO
Cookie 由 requests.Session 自动管理;登录接口返回的 Set-Cookie 会被保存,
后续请求自动带上(对应 C# 的 CookieContainer,Packet.cs:183-190)。
"""
def __init__(
self,
base_url: str = BASE_URL,
timeout_ms: int = TIMEOUT_MS,
verify_ssl: bool = False,
) -> None:
self.base_url = base_url.rstrip("/") + "/"
self.timeout = timeout_ms / 1000.0
self.verify_ssl = verify_ssl
self.session = requests.Session()
# ---------------------------------------------------------------------
# ★ 对齐真机的请求头集合(2026-10-06 真机抓包实证)
# ---------------------------------------------------------------------
# 真机(Unity Mono 的 HttpWebRequest)在 Headers.AllKeys 里**只有**:
# number / Content-Type / User-Agent / charset / Mai-Encoding /
# Content-Encoding / (+ Cookie)
# 没有 Accept,也没有 Connection。
#
# 而 requests 默认会补上:
# Accept: */* <- 删掉(真机不发)
# Connection: keep-alive <- 删掉(真机不发;HTTP/1.1 本来就是长连接)
# Accept-Encoding <- 删掉(真机不发),改由 _build_headers 显式置空
#
# ★ Accept-Encoding 为什么必须**存在且为空**:
# 实测(GetGameSettingApi,2026-10-06):
# Accept-Encoding: "" -> 200 + application/json(384 B)
# 不发该头 / "gzip, deflate" / "identity"
# -> 200 + application/octet-stream(0 B)
# 所以这里从 Session 默认里删掉,再由 _build_headers 显式发一个空值。
self.session.headers.pop("Accept", None)
self.session.headers.pop("Connection", None)
self.session.headers.pop("Accept-Encoding", None)
#: ★ number 头的值(Packet.cs:189 GameManager.PingMsec)。
#: 只由 **Ping 心跳包**更新(对应 PingChecker.cs:227 PingSuccess),
#: 普通业务请求不会改动它 —— 真机整局 53 个请求的 number 恒为 63。
self.ping_msec: int = 0
if not verify_ssl:
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
# -------------------------------------------------------------------------
# 内部
# -------------------------------------------------------------------------
def _build_headers(
self,
api_name: str,
user_id: int = 0,
client_id: str = CLIENT_ID,
has_body: bool = True,
ping_msec: Optional[int] = None,
) -> Dict[str, str]:
"""
构建请求头(对应 NetHttpClient.Request:174-190 + Packet.ProcImpl:166-176)。
★ 关于 number 头:
source/Assembly-CSharp/Net.Packet/Packet.cs:187-194
if (!IsPingPacket())
{
Client.AddHeader("number", GameManager.PingMsec.ToString());
if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); }
}
即**除 Ping 外的每个接口**都会带一个 number 头,值是本机到服务器的
ping 毫秒数(GameManager.PingMsec)。
★ number 的来源(源码链路,逐条可查):
Main/PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
Net.Packet.Mai2/PacketPing.cs:34
_onDone((int)sw.ElapsedMilliseconds)
→ 值 = **Ping 包的往返毫秒**
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
所以 number **只由 Ping 心跳刷新**,普通业务请求不会改它。
实测(gamehook 抓真机一局 70 个请求):
#1 ~ #10 number = 0 ← 还没跑过 Ping
#11 ~ #17 Ping 心跳(不带 number 头)
#18 ~ #70 number = 63 ← Ping 往返 63ms,此后恒定
本类在 request() 里对 Ping 包做同样的事:把往返毫秒写进 self.ping_msec。
★ 关于 Cookie(Packet.cs:190-193 + 228-234):
Client.AddCookie(Query.UserId) 把「登录时服务端 Set-Cookie 存下来的
CookieContainer」挂到本次请求上(NetHttpClient.cs:170-177)。
★ 实测 **UserLoginApi 的响应头里确实有**:
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
而 Ping 等接口的响应头里没有 Set-Cookie(只有 Date / Content-Type /
Content-Length / Connection / Pragma / Cache-Control)。
所以「登录前没有 Cookie、登录后一直带着 JSESSIONID」正是真机行为。
Cookie 由 requests.Session 自动管理:收到就存、后续自动带 —— 无需额外代码,
但**必须全程复用同一个 MaimaiNetClient**,否则会丢掉 JSESSIONID。
"""
headers = {
"Content-Type": HEADER_CONTENT_TYPE,
"charset": HEADER_CHARSET,
"Mai-Encoding": MAI_ENCODING,
"User-Agent": build_user_agent(api_name, user_id, client_id),
# ★ 必须显式置空,见模块 docstring 第二节
"Accept-Encoding": "",
}
if has_body:
headers["Content-Encoding"] = HEADER_CONTENT_ENCODING
# ★ number 头(Packet.cs:189):除 Ping 外的接口都要带
if not api_name.startswith("Ping"):
value = self.ping_msec if ping_msec is None else ping_msec
headers["number"] = str(int(value))
return headers
@property
def last_ping_msec(self) -> int:
"""number 头的当前值(= 最近一次 Ping 的往返毫秒)。兼容旧字段名。"""
return self.ping_msec
def _encrypt_request(self, json_str: str) -> bytes:
"""JSON -> deflate(zlib头+Adler32) -> AES。"""
return aes_encrypt(deflate_compress(json_str.encode("utf-8")))
def _decrypt_response(self, encrypted: bytes) -> str:
"""AES -> 去头尾 -> deflate -> UTF-8 字符串。"""
return deflate_decompress(aes_decrypt(encrypted)).decode("utf-8")
# -------------------------------------------------------------------------
# 公开
# -------------------------------------------------------------------------
def request(
self,
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> str:
"""
发送一次请求,返回**原始 JSON 响应字符串**。
参数:
api_name: 逻辑接口名(如 "GetUserPreviewApi",不要带 MaimaiChn)
request_vo: 请求体对象(dataclass / dict / None 表示空对象 {})
user_id: 用户 ID(0 表示未登录请求;影响 User-Agent 与 Cookie)
client_id: 客户端 ID
timeout_ms: 超时覆盖(毫秒)
extra_headers: 额外请求头(如 number)
返回:
响应 JSON 字符串
异常:
MaimaiNetError: 网络异常 / 空响应 / 解密或解压失败
"""
url = urljoin(self.base_url, obfuscate_api_name(api_name))
timeout = (timeout_ms or self.timeout * 1000) / 1000.0
request_json = vo_to_json(request_vo)
body = self._encrypt_request(request_json)
headers = self._build_headers(api_name, user_id, client_id, has_body=True)
if extra_headers:
headers.update(extra_headers)
# ★ DEBUG 模式:记录「向 api 发送的数据」
log_request(url=url, body=request_json, headers=headers, api_name=api_name)
t0 = time.monotonic()
try:
response = self.session.post(
url,
data=body,
headers=headers,
timeout=timeout,
verify=self.verify_ssl,
)
except requests.exceptions.RequestException as exc:
log_error("接口 " + api_name + " 请求失败:" + str(exc))
raise MaimaiNetError("接口 " + api_name + " 请求失败:" + str(exc)) from exc
elapsed_ms = int((time.monotonic() - t0) * 1000)
# ★ 只有 Ping 包会刷新 number(对应 PingChecker.cs:224-229 PingSuccess)。
# 普通业务包不动它,否则 number 会随每个包的耗时抖动,与真机不符。
if api_name.startswith("Ping"):
self.ping_msec = elapsed_ms
content = response.content
# 0 字节响应:请求未进入业务层
if len(content) == 0:
detail = (
"接口 " + api_name + " 返回 HTTP " + str(response.status_code)
+ ",响应体为 0 字节(空响应)。\n"
" 含义:服务端没有返回任何内容,请求很可能未进入业务层。\n"
" 排查:userId/token 是否有效、接口名与请求体字段是否与源码一致、"
"Accept-Encoding 是否被置空。\n"
" 响应头:" + repr(dict(response.headers))
)
log_error(detail)
raise MaimaiNetError(detail)
try:
response_json = self._decrypt_response(content)
except ValueError as exc:
detail = (
"接口 " + api_name + " 响应解密失败(HTTP " + str(response.status_code)
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
" 含义:服务器返回了内容,但不是合法的 AES 密文,或长度不是 16 的倍数。"
)
log_error(detail)
raise MaimaiNetError(detail) from exc
except zlib.error as exc:
detail = (
"接口 " + api_name + " 响应解压失败(HTTP " + str(response.status_code)
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
" 含义:AES 解密成功但 deflate 解压失败,响应不是预期的压缩格式"
"(可能是网关/防火墙返回的页面)。"
)
log_error(detail)
raise MaimaiNetError(detail) from exc
if not response_json:
detail = (
"接口 " + api_name + " 返回空明文(HTTP " + str(response.status_code)
+ ",密文 " + str(len(content)) + " 字节,解密后为空串)。\n"
" 含义:链路与加密都正常,但服务端没有返回内容。\n"
" 排查:userId / token 是否失效、keychip 与该卡是否匹配、该卡是否正在游戏中。"
)
log_error(detail)
raise MaimaiNetError(detail)
# ★ DEBUG 模式:记录「api 返回的数据」
try:
shown_body: Any = json.loads(response_json)
except Exception:
shown_body = response_json
log_response(
status=response.status_code,
body=shown_body,
headers={k: v for k, v in response.headers.items()},
api_name=api_name,
elapsed_ms=elapsed_ms,
)
return response_json
def request_vo(
self,
api_name: str,
request_vo: Any,
response_cls: Type[T],
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> T:
"""发送请求并把响应映射成 dataclass。"""
raw = self.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
return vo_from_json(response_cls, raw)
def request_dict(
self,
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""
发送请求并返回**原始响应 dict**。
★ 取数脚本一律用这个:upsertUserAll 需要响应原文,
映射成 dataclass 会丢掉服务端多给的字段。
"""
raw = self.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
data = json.loads(raw)
return data if isinstance(data, dict) else {"_data": data}
def close(self) -> None:
"""关闭底层会话。"""
try:
self.session.close()
except Exception:
pass
def __enter__(self) -> "MaimaiNetClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
# =============================================================================
# 六、全局默认客户端
# =============================================================================
_default_client: Optional[MaimaiNetClient] = None
def get_default_client() -> MaimaiNetClient:
"""获取(必要时创建)全局默认客户端 —— 连接与 Cookie 会自动复用。"""
global _default_client
if _default_client is None:
_default_client = MaimaiNetClient()
return _default_client
def close_default_client() -> None:
"""关闭全局默认客户端(一般不需要手动调用)。"""
global _default_client
if _default_client is not None:
_default_client.close()
_default_client = None
def request(
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> str:
"""便捷函数:用默认客户端发一次请求,返回原始 JSON 字符串。"""
net = client or get_default_client()
return net.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
def request_dict(
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""便捷函数:用默认客户端发一次请求,返回原始响应 dict。"""
net = client or get_default_client()
return net.request_dict(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
def request_vo(
api_name: str,
request_vo: Any,
response_cls: Type[T],
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> T:
"""便捷函数:用默认客户端发一次请求,返回映射后的 dataclass。"""
net = client or get_default_client()
return net.request_vo(
api_name, request_vo, response_cls,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
# =============================================================================
# 七、通用请求 / 响应 VO
# =============================================================================
@dataclass
class EmptyRequestVO:
"""空请求体 —— 对应 C# 里没有任何字段的 RequestVO(如 PingRequestVO)。"""
@dataclass
class UserIdRequestVO:
"""只有 userId 的请求体(对应 UserDetailRequestVO / UserCharacterRequestVO 等)。"""
userId: int
@dataclass
class ReturnCodeVO:
"""通用「只有 returnCode」的响应体。"""
returnCode: int = 0
# =============================================================================
# 单独运行:协议自检(不发业务请求)
# =============================================================================
if __name__ == "__main__":
print("=== maimai_net 协议自检 ===")
print("BASE_URL :", BASE_URL)
print("GetGameSettingApi :", build_api_url("GetGameSettingApi"))
print(" 期望尾段 : 83a5d5a20b062c5c2ad817460b8f7f76")
print("User-Agent 示例 :", build_user_agent("UserLoginApi", 12345678))
sample = '{"userId":12345678,"token":"abc"}'
packed = deflate_compress(sample.encode("utf-8"))
print("deflate 头/尾 :", packed[:2].hex(), packed[-4:].hex(),
" Adler32 =", hex(adler32(sample.encode("utf-8"))))
assert deflate_decompress(packed).decode("utf-8") == sample
enc = aes_encrypt(packed)
assert aes_decrypt(enc) == packed
print("AES 往返 : OK(密文", len(enc), "字节,16 的倍数:", len(enc) % 16 == 0, ")")
print("自检通过")
+95
View File
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""
Ping.py —— 心跳探活(PingMaimaiChn)
================================================================================
功能:向游戏服务器发一次心跳,确认链路可达。
对应接口:PingMaimaiChn
请求体 :{}(source/Assembly-CSharp/Net.VO.Mai2/PingRequestVO.cs 是空类)
响应 :{"result": "Pong"}
★ 接口名(容易写错,注意):
source/Assembly-CSharp/Net.Packet.Mai2/PacketPing.cs:22
new NetQuery<PingRequestVO, PingResponseVO>("Ping", 0uL);
客户端传的逻辑名是 **"Ping"**,NetQuery.cs:14 再补上 "MaimaiChn" 后缀,
所以混淆用的全名是 "PingMaimaiChn":
md5("PingMaimaiChn" + "<REDACTED>") = 3bae8c1162b3148abcfc38a89c03e3e7
本模块传 "Ping" 即可,obfuscate_api_name 会自动补后缀。
(Packet.cs:96 用 Query.Api.StartsWith("Ping") 判断是否心跳包,
也是基于补完后缀的 "PingMaimaiChn"。)
★ 为什么要跑它(不只是探活):
Packet.cs:189 非 Ping 接口都会带 HTTP 头 number = GameManager.PingMsec
PingChecker.cs:227 GameManager.PingMsec = Ping 包的往返毫秒
所以 **Ping 是 number 头的唯一来源**。先 Ping 一次,后面的业务请求才会
带上真实 number(真机实测 63ms);不 Ping 则 number = 0,
与真机开机后第一屏(还没跑过心跳)的行为一致。
MaimaiNetClient 在收到 Ping 响应后会自动把往返毫秒记进 client.ping_msec。
调用示例:
from api import Ping
if Ping():
print("服务器可达")
"""
from __future__ import annotations
from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
def Ping(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> bool:
"""
心跳探活。
参数:
client_id: 机台短值(可选)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
True = 服务器返回 "Pong"
"""
log_info("发送心跳探活")
raw = request_dict(
"Ping", {},
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
result = raw.get("result", "")
ok = (result == "Pong")
if ok:
log_info("心跳正常:result=Pong")
else:
log_warning("心跳返回异常:result=" + str(result))
return ok
if __name__ == "__main__":
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("ping_test")
try:
ok = Ping()
print("")
print("服务器可达:" + str(ok))
return 0 if ok else 1
finally:
teardown_logging()
raise SystemExit(_main())
+54
View File
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""
UploadUserPlaylog —— 上传单局成绩原始记录(UploadUserPlaylogApi)
★ 为什么需要它:
真机结算序列是**先 UploadUserPlaylog、后 UpsertUserAll**(2026-10-21 抓包 055/056 号):
055_UploadUserPlaylogApi request 2083 字节 -> returnCode=1
056_UpsertUserAllApi request 21833 字节 -> returnCode=1
Astral 原来只发 UpsertUserAll,服务端就只回 returnCode=1 而不落库。
这条前置是「本次会话确实打过这一局」的凭证。
请求体(源码 Net.VO.Mai2/UserPlaylogRequestVO.cs):
{ "userId": <int>, "userPlaylog": { ...110 字段... } }
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .MaimaiNet import MaimaiNetClient, get_default_client, request_dict
from .LogSetup import log_info
def UploadUserPlaylog(
user_id: int,
user_playlog: Dict[str, Any],
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""上传一局成绩明细(无落库副作用,但服务端会记账,供 UpsertUserAll 消费)。
参数:
user_id: 用户 ID
user_playlog: 110 字段成绩明细,用
api.UpsertUserAll.build_user_playlog(playlog_id, login_date, play) 构造
返回:原始响应 dict({"returnCode": 1, "apiName": "UploadUserPlaylogApi"})
"""
body = {"userId": int(user_id), "userPlaylog": user_playlog}
log_info("上传成绩明细:user_id=" + str(user_id)
+ " musicId=" + str(user_playlog.get("musicId"))
+ " level=" + str(user_playlog.get("level"))
+ " achievement=" + str(user_playlog.get("achievement")))
return request_dict(
"UploadUserPlaylogApi", body,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS,
client=client,
)
File diff suppressed because it is too large. Load diff
+620
View File
@@ -0,0 +1,620 @@
# -*- coding: utf-8 -*-
r"""
UpsertUserChargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
================================================================================
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
对应接口:UpsertUserChargelogApi
请求体 :UserChargelogRequestVO(4 字段,顺序照 C# 声明)
source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs
public ulong userId;
public UserChargelog userChargelog;
public GetUserCharge userCharge;
public long loginDateTime;
响应 :{returnCode, apiName}
--------------------------------------------------------------------------------
一、真机的购买流程(源码逐行可查)
--------------------------------------------------------------------------------
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
├─ ticket.ticketKind.id == 1(付费票)
├─ 从 OperationManager.GetGameChargeDataList() 找该 chargeId 的 price
│ (= GetGameChargeApi 的响应)
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
│ └─ 没有 -> 新增一条 stock=1
└─ TicketManager.cs:234 RequestUserCharge(playerId)
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
│ -> 取**购买后**(stock > 0)的那条 GetUserCharge
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
│ -> chargeId = ticketId
│ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price
│ purchaseDate = TimeManager.GetNowDateString()
│ playCount = 玩家总游玩次数
│ playerRating = 玩家 Rating
│ placeId / regionId / clientId = 当前机台
└─ 发 UpsertUserChargelogApi
★ 成功后 TicketManager.cs:255 RequestSuccess 会把本地 stock 再 --,
因为那张票马上就被这一局用掉了。本模块只负责「买」,
所以默认传的是**购买后**的库存(现有 + 1)。
--------------------------------------------------------------------------------
二、票的有效期算法
--------------------------------------------------------------------------------
Manager/TicketManager.cs:564
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
真机验证(source/2026-10-05.log:123 的 userChargeList):
chargeId=5 purchaseDate="2026-10-04 18:22:10"
-> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02
-> validDate="2027-01-02 04:00:00" ✅ 完全吻合
--------------------------------------------------------------------------------
三、ticket.creditNum 从哪来
--------------------------------------------------------------------------------
游戏本地主数据:
<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
已抄进 TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
--------------------------------------------------------------------------------
四、安全闸门
--------------------------------------------------------------------------------
本接口会**真实写入一条购买记录**,所以默认不发包:
UpsertUserChargelog(...) -> 抛 ChargelogConfirmError
UpsertUserChargelog(..., dry_run=True) -> 只构造报文
UpsertUserChargelog(..., confirm=True) -> 真实发送
全局开关:config.UPSERT_CHARGELOG_CONFIRM(默认 False)
--------------------------------------------------------------------------------
五、调用示例
--------------------------------------------------------------------------------
from api import setup_logging, ParseQRCode, UserLogin, UserLogout,
LogoutType, UpsertUserChargelog
setup_logging("买票")
user_id, token = ParseQRCode(qr)
login = UserLogin(user_id, token)
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
UserLogout(user_id, token, login.loginDateTime, logout_type=LogoutType.TestIn)
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Union
import config
from config import (
CLIENT_ID,
PLACE_ID,
REGION_ID,
TIMEOUT_MS,
UPSERT_CHARGELOG_CONFIRM,
)
# =============================================================================
# 功能票(Ticket)主数据(原在 config.py,2026-10-06 下沉到本模块)
# =============================================================================
#
# 依据:游戏本地主数据
# Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml
# 源码侧 Manager.MaiStudio.TicketData.creditNum
#
# ★ 为什么需要它:
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
# result.price = ticket.creditNum;
#
# ticketKind 含义:1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
#: chargeId -> creditNum
TICKET_CREDIT_NUM = {
0: 0,
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
10005: 0, 10105: 0, 10205: 0, # 活动票
11001: 0, # 免费 1.5 倍券
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
}
#: 票名(只用于日志好看,不影响协议)
TICKET_NAMES = {
0: "使用しない",
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
}
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
#: 票有效期算法参数
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00"
TICKET_VALID_HOUR_OFFSET = -4
TICKET_VALID_DAYS = 90
from .ChargeGuard import (
has_fresh_read,
last_read_age,
note_charge_purchase,
purchased_charge_ids,
)
from .LogSetup import log_debug, log_error, log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
from .UpsertUserAll import UpsertResponseVO, resolve_api_func
class ChargelogConfirmError(RuntimeError):
"""有副作用的接口被安全闸门拦下(此时**未发包**)。"""
class ChargelogGuardError(RuntimeError):
"""
安全检查未通过(此时**未发包**)。
★ 目前唯一的触发条件:**账号当前已经持有功能票**。
真机行为:`UseTicketPrepare` 只在「本局要用这张票」时才走购买分支,
而一张票买到手后会立刻被本局用掉(`RequestSuccess` 里 stock--)。
所以正常状态下 `userChargeList` 里的 stock 全是 0。
如果账号**已经持有**一张功能票还去发购买请求,
服务端看到的是「重复购买 / 状态矛盾」,有**封号风险**。
所以本模块默认硬拦。
"""
# =============================================================================
# 一、VO(字段顺序照 source/Assembly-CSharp/Net.VO.Mai2/*.cs 声明)
# =============================================================================
@dataclass
class UserChargeVO:
"""GetUserCharge —— 一张票的持有状态(GetUserCharge.cs,4 字段)。"""
chargeId: int = 0
stock: int = 0
purchaseDate: str = ""
validDate: str = ""
@dataclass
class UserChargelogVO:
"""UserChargelog —— 一条购买记录(UserChargelog.cs,8 字段)。"""
chargeId: int = 0
price: int = 0
purchaseDate: str = ""
playCount: int = 0
playerRating: int = 0
placeId: int = 0
regionId: int = 0
clientId: str = ""
@dataclass
class UserChargelogRequestVO:
"""请求 VO(UserChargelogRequestVO.cs,4 字段,顺序照声明)。"""
userId: int
userChargelog: UserChargelogVO
userCharge: UserChargeVO
loginDateTime: int = 0
# =============================================================================
# 二、工具
# =============================================================================
def ticket_name(charge_id: int) -> str:
"""取票名(只用于日志)。"""
return TICKET_NAMES.get(int(charge_id), "未知票(" + str(charge_id) + ")")
def ticket_price(charge_id: int, override: Optional[int] = None) -> int:
"""
取 userChargelog.price —— 即 ticket.creditNum。
依据 VOExtensions.cs:410 result.price = ticket.creditNum;
数据来源 TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
"""
if override is not None:
return int(override)
if int(charge_id) not in TICKET_CREDIT_NUM:
log_warning("TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
+ " —— 按 0 处理,建议补进配置")
return 0
return int(TICKET_CREDIT_NUM[int(charge_id)])
#: 北京时间(CST = UTC+8)
#: 依据 Manager/TimeManager.cs:9 CSToffset = new TimeSpan(8, 0, 0)
CST = timezone(timedelta(hours=8))
def now_cst(now: Optional[datetime] = None) -> datetime:
"""
取「北京时间现在」;传了 now 就用它(视作已是北京时间)。
★ 为什么要显式 +8 而不是 datetime.now():
TimeManager.GetNowTime() 返回 **DateTime.UtcNow**,
GetDateString() 再 Add(CSToffset) —— 即游戏里的日期串**恒是北京时间**,
与本机时区无关。脚本跑在非东八区机器上时 datetime.now() 会错。
"""
if now is not None:
return now
return datetime.now(timezone.utc).astimezone(CST)
def _now_string(now: Optional[datetime] = None) -> str:
"""
TimeManager.GetNowDateString() 的等价物。
依据 Manager/TimeManager.cs:7,62-70:
DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"
GetDateString(unix) = GetDateTime(unix).ToString("yyyy-MM-dd HH:mm:ss.f")
★★ 注意末尾那个 **.f**(一位小数)—— 真机报文长这样:
"purchaseDate":"2026-10-06 13:20:10.0"
少写这个 ".0" 服务端会直接 returnCode=0(实测踩过)。
★ 而 validDate 用的是另一套格式(见 calc_valid_date),**不带**小数。
"""
d = now_cst(now)
return d.strftime("%Y-%m-%d %H:%M:%S") + "." + str(d.microsecond // 100000)
def calc_valid_date(
now: Optional[datetime] = None,
*,
hour_offset: int = TICKET_VALID_HOUR_OFFSET,
days: int = TICKET_VALID_DAYS,
) -> str:
"""
算票的有效期字符串。
依据 TicketManager.cs:562-565:
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
★ 注意这里用的是**显式格式串**(没有 .f),所以结果形如
"2027-01-04 04:00:00" —— 与 purchaseDate 的 "…10.0" 不同。
"""
base = now_cst(now)
d = (base + timedelta(hours=hour_offset) + timedelta(days=days)).date()
return d.strftime("%Y-%m-%d") + " 04:00:00"
def parse_wall_time(text: Any) -> Optional[datetime]:
"""
解析墙上时间串,兼容两种格式:
"2026-10-06 13:20:10.0" ← TimeManager.DateTimeFormat(一位小数,真机报文用这个)
"2026-10-06 13:20:10" ← 无小数(服务端回给 GetUserChargeApi 的是这个)
解析不出来返回 None。
"""
s = str(text or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
return None
def _find_charge(charge_list: Any, charge_id: int) -> Optional[Dict[str, Any]]:
"""在 GetUserChargeApi 的 userChargeList 里找指定 chargeId。"""
if not isinstance(charge_list, list):
return None
for c in charge_list:
if isinstance(c, dict) and int(c.get("chargeId", -1) or -1) == int(charge_id):
return c
return None
# =============================================================================
# 三、主函数
# =============================================================================
def UpsertUserChargelog(
user_id: int,
charge_id: int,
login_date_time: int,
*,
charge_list: Optional[List[Dict[str, Any]]] = None,
game_charge_list: Optional[List[Dict[str, Any]]] = None,
user_data: Optional[Dict[str, Any]] = None,
stock: Optional[int] = None,
price: Optional[int] = None,
purchase_date: Optional[str] = None,
valid_date: Optional[str] = None,
play_count: Optional[int] = None,
player_rating: Optional[int] = None,
place_id: int = PLACE_ID,
region_id: int = REGION_ID,
client_id: str = CLIENT_ID,
guard_no_stock: bool = True,
guard_purchasable: bool = True,
guard_require_charge_read: bool = True,
guard_single_purchase: bool = True,
confirm: Optional[bool] = None,
dry_run: bool = False,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Union[UserChargelogRequestVO, UpsertResponseVO]:
"""
购买一张功能票(UpsertUserChargelogApi,★ 有副作用)。
参数:
user_id: 用户 ID(必填)
charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId)
★ 付费票是 2/3/4/5/6(PAID_TICKET_IDS)
login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime)
charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。
用来算「购买后库存」和是否已有该票。
user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。
用来填 playCount / playerRating。
stock: ★ 购买后该票的库存;不传 = 现有 + 1
price: ★ userChargelog.price;不传 = TICKET_CREDIT_NUM[charge_id]
purchase_date: 购买时刻字符串;不传 = 现在
valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date)
play_count: 覆盖 playCount(不传则取 userData.playCount)
player_rating: 覆盖 playerRating(不传则取 userData.playerRating)
guard_purchasable: ★ 默认 True = **只允许买 GetGameChargeApi 列出来的票**
(不在清单里就抛 ChargelogGuardError,未发包)。
依据 TicketManager.cs:137-150 —— 真机也是先在
GetGameChargeDataList() 里找到该 chargeId 才发购买请求,
找不到直接 return false。服务端对未上架的票会 returnCode=0。
实测:本机台只上架 chargeId=2/3,买 5 会失败。
game_charge_list: 可购买清单(可选)。不传则自动调 GetGameChargeApi。
guard_no_stock: ★ 默认 True = **账号当前若已持有任何功能票就直接拒绝购买**
(抛 ChargelogGuardError,未发包)。
理由见 ChargelogGuardError 的 docstring —— 有封号风险。
确认要强行购买才传 False。
guard_require_charge_read:
★ 默认 True = **运行本函数之前必须先调用过 GetUserCharge**
(本进程内、10 分钟内),否则直接拒绝购买。
目的:防止「没查票就买票」造成同一账号重复购买。
显式传了 charge_list 也算「查过了」。
实现见 api/ChargeGuard.py。
guard_single_purchase:
★ 默认 True = **同一账号在同一进程里只允许买一次票**。
真机买完票会立刻被本局用掉(stock 恒为 0),
连着买两张属于异常行为。实现见 api/ChargeGuard.py。
confirm: 副作用闸门;None 时取 config.UPSERT_CHARGELOG_CONFIRM
dry_run: True = 只构造并返回报文,绝不发包
返回:
dry_run=True -> UserChargelogRequestVO
dry_run=False -> UpsertResponseVO
"""
if not login_date_time:
raise ValueError("缺少 login_date_time:必须传 UserLoginApi 的 loginDateTime"
"(PacketUploadUserChargelog.cs:23)")
name = ticket_name(charge_id)
log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id)
+ "(" + name + ")dry_run=" + str(dry_run))
if int(charge_id) not in tuple(PAID_TICKET_IDS):
log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS="
+ str(tuple(PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
# ---------- 0. ★ 前置闸门:必须先跑过 GetUserCharge ----------
# ★ 为什么放在「取当前状态」之前:下面那一步在 charge_list 为空时会
# 自己调一次 GetUserCharge —— 闸门若放在后面,它自己就把自己放行了。
if guard_require_charge_read and charge_list is None:
if not has_fresh_read(user_id):
msg = ("安全检查未通过:本次购买之前**没有调用过 GetUserCharge**。\n"
" 买票前必须先查一次账号当前票券(有没有持票),否则无法排除"
"「重复购买」——服务端看到的是状态矛盾,有**封号风险**。\n"
" 正确顺序:GetUserCharge(user_id) -> UpsertUserChargelog(...)\n"
" 或者直接把查到的票券列表传进来:charge_list=[...]\n"
" 见 api/ChargeGuard.py。")
log_error(msg)
raise ChargelogGuardError(msg)
log_info("前置检查通过:本次购买前已调用 GetUserCharge("
+ str(round(float(last_read_age(user_id) or 0.0), 1)) + " 秒前)")
# ---------- 0.5 ★ 前置闸门:同一账号本进程内只允许买一次 ----------
if guard_single_purchase:
done = purchased_charge_ids(user_id)
if done:
msg = ("安全检查未通过:本进程内该账号(user_id=" + str(user_id)
+ ")**已经买过功能票**(chargeId=" + str(done) + ")。\n"
" 真机买完票会立刻被这一局用掉,正常状态 stock 恒为 0;"
"连着买两张属于异常行为,有**封号风险**。\n"
" 确需强行购买请传 guard_single_purchase=False。")
log_error(msg)
raise ChargelogGuardError(msg)
# ---------- 1. 取当前状态 ----------
if charge_list is None:
log_info("未传 charge_list,自动抓取当前票券 ...")
raw = resolve_api_func("GetUserCharge")(
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
charge_list = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
if user_data is None:
log_info("未传 user_data,自动抓取当前档案 ...")
raw_ud = resolve_api_func("GetUserData")(
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
user_data = (raw_ud.get("userData") if isinstance(raw_ud, dict) else None) or {}
# ---------- 1.4 ★ 安全检查:这张票当前**上架了吗** ----------
# 依据 TicketManager.cs:137-150:真机先在 GetGameChargeDataList() 里找该
# chargeId,找到才 RequestUserCharge,找不到直接 return false(不买)。
# 服务端对未上架的票会 returnCode=0 —— 实测买 chargeId=5 就是这种情况。
if guard_purchasable:
if game_charge_list is None:
log_info("未传 game_charge_list,自动抓取可购买清单 ...")
_gc = resolve_api_func("GetGameCharge")(
client_id=client_id, timeout_ms=timeout_ms, client=client)
game_charge_list = (_gc.get("gameChargeList")
if isinstance(_gc, dict) else None) or []
avail = [int(g.get("chargeId")) for g in (game_charge_list or [])
if isinstance(g, dict) and g.get("chargeId") is not None]
log_info("当前可购买 chargeId:" + str(avail))
if int(charge_id) not in avail:
msg = ("安全检查未通过:chargeId=" + str(charge_id) + "(" + name
+ ")**不在当前可购买清单里**。\n"
" 可购买:" + (str(avail) if avail else "(空)") + "\n"
" 依据 TicketManager.cs:137-150 —— 真机也只买 GetGameChargeApi\n"
" 列出来的票,找不到就不发购买请求。\n"
" 服务端对未上架的票会 returnCode=0(实测过)。\n"
" 确需强行购买请传 guard_purchasable=False。")
log_error(msg)
raise ChargelogGuardError(msg)
log_info("安全检查通过:chargeId=" + str(charge_id) + " 在可购买清单里")
# ---------- 1.5 ★ 安全检查:账号当前是否已持有功能票 ----------
held = [c for c in (charge_list or [])
if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
if held:
desc = "、".join(
"chargeId=" + str(c.get("chargeId")) + "(" + ticket_name(c.get("chargeId"))
+ ") stock=" + str(c.get("stock")) + " validDate=" + str(c.get("validDate"))
for c in held)
log_warning("★ 账号当前已持有 " + str(len(held)) + " 张功能票:" + desc)
if guard_no_stock:
msg = ("安全检查未通过:账号当前**已持有功能票**(" + desc + "),拒绝再次购买。\n"
" 原因:真机只在「本局要用这张票」时才购买,且买到手会立刻用掉"
"(stock--),正常状态 stock 恒为 0。\n"
" 已经持票还发购买请求,服务端看到的是重复购买,有**封号风险**。\n"
" 确需强行购买请传 guard_no_stock=False。")
log_error(msg)
raise ChargelogGuardError(msg)
log_warning("guard_no_stock=False —— 已跳过该检查,继续购买")
else:
log_info("安全检查通过:账号当前没有持有任何功能票")
existing = _find_charge(charge_list, charge_id)
old_stock = int(existing.get("stock", 0) or 0) if existing else 0
if stock is None:
stock = old_stock + 1
stock = int(stock)
if price is None:
price = ticket_price(charge_id)
price = int(price)
if play_count is None:
play_count = int(user_data.get("playCount", 0) or 0)
if player_rating is None:
player_rating = int(user_data.get("playerRating", 0) or 0)
now = now_cst()
if not purchase_date:
purchase_date = _now_string(now)
if not valid_date:
# ★ 真机里 purchaseDate 与 validDate 都来自同一个 DateTime.Now
# (TicketManager.cs:216-219 与 :564),所以调用方若显式给了 purchaseDate,
# 有效期就该从**那个时刻**推,而不是从现在推。
base = parse_wall_time(purchase_date)
if base is None:
log_warning("purchase_date 解析不了(应为 'YYYY-MM-DD HH:MM:SS[.f]'),"
"validDate 改按「现在」计算:" + str(purchase_date))
base = now
valid_date = calc_valid_date(base)
log_info(" 票名 : " + name)
log_info(" 库存 : " + str(old_stock) + " -> " + str(stock))
log_info(" price : " + str(price) + "(= ticket.creditNum)")
log_info(" purchaseDate: " + purchase_date)
log_info(" validDate : " + valid_date)
# ---------- 2. 构造请求 ----------
req = UserChargelogRequestVO(
userId=int(user_id),
userChargelog=UserChargelogVO(
chargeId=int(charge_id),
price=price,
purchaseDate=purchase_date,
playCount=int(play_count),
playerRating=int(player_rating),
placeId=int(place_id),
regionId=int(region_id),
clientId=client_id,
),
userCharge=UserChargeVO(
chargeId=int(charge_id),
stock=stock,
purchaseDate=purchase_date,
validDate=valid_date,
),
loginDateTime=int(login_date_time),
)
# ---------- 3. dry-run ----------
if dry_run:
log_info("[dry-run] 已构造购买报文,未发包")
log_debug("dry-run 报文:" + __import__("json").dumps(
req, ensure_ascii=False, default=str))
return req
# ---------- 4. 副作用闸门 ----------
effective = UPSERT_CHARGELOG_CONFIRM if confirm is None else bool(confirm)
if not effective:
msg = ("UpsertUserChargelogApi 会**真实写入一条购买记录**(不可撤销)。"
"本脚本默认不放行。确认要发请传 confirm=True,"
"或改 config.UPSERT_CHARGELOG_CONFIRM = True。"
"只想看报文请传 dry_run=True。")
log_error(msg)
raise ChargelogConfirmError(msg)
# ---------- 5. 发包 ----------
log_info("闸门已放行,开始上传购买记录 ...")
raw = request_dict(
"UpsertUserChargelogApi", req,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
resp = UpsertResponseVO(
returnCode=int(raw.get("returnCode", 0) or 0),
apiName=str(raw.get("apiName", "") or ""),
)
if resp.returnCode != 1:
msg = ("购买失败:returnCode=" + str(resp.returnCode)
+ " apiName=" + resp.apiName)
log_error(msg)
raise RuntimeError(msg)
log_info("购买成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName)
log_info(" 票名 " + name + " 库存 " + str(old_stock) + " -> " + str(stock))
# ★ 记一笔「这个账号本进程已经买过票」,同进程再买会被前置闸门拦下
note_charge_purchase(user_id, charge_id)
return resp
# =============================================================================
# 四、单独运行(调试)
# =============================================================================
if __name__ == "__main__":
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chargelog_test")
try:
log_info("构造一张 2 倍券的购买报文(不发包)")
req = UpsertUserChargelog(
12345678, 2, 1791159576,
charge_list=[], user_data={"playCount": 1260, "playerRating": 15000},
dry_run=True,
)
log_info("userChargelog = " + str(req.userChargelog))
log_info("userCharge = " + str(req.userCharge))
log_info("validDate 算法自检:")
log_info(" 2026-10-04 18:22:10 买 5 倍券 -> "
+ calc_valid_date(datetime(2026, 10, 4, 18, 22, 10))
+ "(真机是 2027-01-02 04:00:00)")
return 0
finally:
teardown_logging()
raise SystemExit(_main())
+245
View File
@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
"""
UserGetterCommon.py —— 各取数脚本的公共底座
================================================================================
只提供三样东西,取数逻辑都在各自的脚本里:
1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs)
2. _get() 发一次请求并返回**原始响应 dict**
3. get_all_pages() 自动翻页
--------------------------------------------------------------------------------
★ 为什么单独抽出来:各接口的请求体并不统一
--------------------------------------------------------------------------------
权威依据 source/Assembly-CSharp/Net.VO.Mai2/ 下各自的 XxxRequestVO.cs:
GameKaleidxScopeRequestVO : 空类,一个字段都没有
UserDetailRequestVO : 只有 userId
UserMapRequestVO : userId + nextIndex + maxCount = 1000
UserLoginBonusRequestVO : userId + nextIndex + maxCount = 20
UserItemRequestVO : userId + nextIndex + maxCount = 100
UserMusicRequestVO : userId + nextIndex + maxCount = 50
UserFavoriteRequestVO : userId + itemKind
UserFavoriteItemRequestVO : userId + kind + nextIndex + maxCount = 100 + isAllFavoriteItem
UserShopStockRequestVO : userId + shopItemIdList
UserRivalMusicRequestVO : userId + rivalId + nextIndex + userRivalMusicLevelList
发错字段名或漏字段,服务端会返回异常或空数据。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_debug, log_warning
from .MaimaiNet import MaimaiNetClient, get_default_client
# =============================================================================
# 请求 VO(逐字段对应 C# 的 XxxRequestVO)
# =============================================================================
@dataclass
class EmptyRequestVO:
"""空请求体(对应 GameKaleidxScopeRequestVO —— 它确实没有任何字段)。"""
@dataclass
class UserIdRequestVO:
"""只有 userId(对应 UserDetailRequestVO / UserCharacterRequestVO / UserOptionRequestVO 等)。"""
userId: int
@dataclass
class UserIdPageRequestVO:
"""通用分页(对应 UserMapRequestVO,maxCount 默认 1000)。"""
userId: int
nextIndex: int = 0
maxCount: int = 1000
@dataclass
class UserLoginBonusRequestVO:
"""对应 UserLoginBonusRequestVO(maxCount 默认 20)。"""
userId: int
nextIndex: int = 0
maxCount: int = 20
@dataclass
class UserItemRequestVO:
"""对应 UserItemRequestVO(maxCount 默认 100)。"""
userId: int
nextIndex: int = 0
maxCount: int = 100
@dataclass
class UserMusicRequestVO:
"""对应 UserMusicRequestVO(maxCount 默认 50)。"""
userId: int
nextIndex: int = 0
maxCount: int = 50
@dataclass
class UserCourseRequestVO:
"""对应 UserCourseRequestVO(只有 userId + nextIndex,**没有** maxCount)。"""
userId: int
nextIndex: int = 0
@dataclass
class UserFavoriteRequestVO:
"""对应 UserFavoriteRequestVO(按 itemKind 分次请求)。"""
userId: int
itemKind: int = 0
@dataclass
class UserFavoriteItemRequestVO:
"""对应 UserFavoriteItemRequestVO(注意字段名是 kind,不是 itemKind)。"""
userId: int
kind: int = 1
nextIndex: int = 0
maxCount: int = 100
isAllFavoriteItem: bool = False
@dataclass
class UserShopStockRequestVO:
"""对应 UserShopStockRequestVO。"""
userId: int
shopItemIdList: List[int] = field(default_factory=list)
@dataclass
class UserRivalRequestVO:
"""对应 UserRivalRequestVO。"""
userId: int
rivalId: int = 0
@dataclass
class UserRivalMusicRequestVO:
"""对应 UserRivalMusicRequestVO(level 列表固定 6 档)。"""
userId: int
rivalId: int = 0
nextIndex: int = 0
userRivalMusicLevelList: List[Dict[str, int]] = field(
default_factory=lambda: [{"level": lv} for lv in (0, 1, 2, 3, 4, 10)]
)
# =============================================================================
# 常量
# =============================================================================
#: GetUserItemApi 的 itemKind 取值(对应 Net.VO.Mai2/ItemKind.cs)
#: 客户端在 StateUserDownload.cs 里依次下载这些 kind
ITEM_KINDS: tuple = (1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 14)
#: ★ GetUserItemApi 首页 nextIndex 的哨兵系数。
#: 依据 source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
#: Create(GetNetQuery(userId, itemId * 10000000000L));
#: 传 0 会被服务端拒绝。真机日志 source/2026-10-05.log:32 也是 10000000000。
ITEM_NEXT_INDEX_FACTOR: int = 10000000000
#: GetUserFavoriteApi 的 itemKind(真机顺序固定 3/1/2/10/11,见 source/2026-10-05.log:59-68)
FAVORITE_ITEM_KINDS: tuple = (3, 1, 2, 10, 11)
#: GetUserRivalMusicApi 的 level 档位(真机 source/2026-10-05.log:101)
RIVAL_MUSIC_LEVELS: tuple = (0, 1, 2, 3, 4, 10)
# =============================================================================
# 内部工具
# =============================================================================
def _get(
api_name: str,
body: Any,
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
发一次请求,返回**原始响应 dict**(不做 VO 映射,保留服务端全部字段)。
★ 取数脚本一律走这里:upsertUserAll 需要响应原文,
映射成 dataclass 会丢掉服务端多给的字段。
"""
net = client or get_default_client()
raw = net.request(
api_name, body,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS,
)
if isinstance(raw, str):
return json.loads(raw) if raw else {}
return raw or {}
def get_all_pages(
api_name: str,
user_id: int,
list_key: str,
*,
body_factory: Optional[Callable[[Optional[int]], Any]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
max_pages: int = 200,
) -> List[Any]:
"""
自动翻页抓取某个列表接口的全部条目。
分页约定(各 Packet 的 Proc 里都是这个逻辑):
nextIndex == 0 -> 没有更多数据
nextIndex != 0 -> 下一页的起始索引
参数:
api_name: 逻辑接口名
list_key: 响应里承载列表的键名(如 "userMapList")
body_factory: 可选;接收 next_index 返回请求 VO 的工厂。
★ 首页会以 next_index=None 调用它 —— 这是为了支持
GetUserItemApi 这类「首发要带哨兵值」的接口
(首包 nextIndex = itemKind * 1e10,不能是 0)。
max_pages: 翻页上限(防御性)
返回:
合并后的全部条目
"""
all_items: List[Any] = []
next_index: Optional[int] = None
for page in range(max_pages):
if body_factory is not None:
body = body_factory(next_index)
else:
body = UserIdPageRequestVO(userId=user_id, nextIndex=next_index or 0)
resp = _get(api_name, body, user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
items = resp.get(list_key) or []
if isinstance(items, dict):
items = [items]
all_items.extend(items)
next_index = int(resp.get("nextIndex") or 0)
log_debug(
api_name + " 第 " + str(page + 1) + " 页:本页 " + str(len(items))
+ " 条,累计 " + str(len(all_items)) + " 条,nextIndex=" + str(next_index)
)
if not next_index:
break
else:
log_warning(api_name + " 超过 " + str(max_pages) + " 页仍未结束,已中止翻页")
return all_items
+222
View File
@@ -0,0 +1,222 @@
# -*- coding: utf-8 -*-
"""
UserLogin.py —— 用户正式登录(UserLoginApi)
================================================================================
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
loginId 与 loginDateTime 是后续结算上报与登出的**必需输入**。
对应接口:UserLoginApi
请求体 :10 个字段,见 source/Assembly-CSharp/Net.VO.Mai2/UserLoginRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:21-31
响应 :UserLoginResponseVO(returnCode=1 表示成功)
--------------------------------------------------------------------------------
★ 关于 loginDateTime(与后续接口强相关,务必理解)
--------------------------------------------------------------------------------
真机取的是「本地墙上时间被当作 UTC 时刻」的秒数,即
loginDateTime = 标准 Unix 时间戳 + 本地时区偏移
东八区即 now + 28800。
成因:MAI2.DateTime.cs 里 DateTime.UtcNow + LocalOffset 得到 Kind=Unspecified
的值,PacketUserLogin.cs:29 再用 new DateTimeOffset(该值) 又加一次本地偏移。
影响:PacketUserLogin.cs:43 会把请求值回填响应;
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
与登录时不一致会被服务端拒绝。
本模块默认按真机算法生成;需要手动指定时传 login_date_time。
★ 关于 dateTime 与 loginDateTime 的区别
dateTime = ALL.Net 认证时刻(Auth.AuthTime,标准 Unix 秒)
loginDateTime= 本次登录时刻(上面的特殊算法)
两者都出现在 UserLoginRequestVO 里,但只有 loginDateTime 会被后续接口复用。
调用示例:
from api import UserLogin
login = UserLogin(12345678, "TESTTOKEN0000000000", region_id=1, place_id=1000)
print(login.loginId, login.loginDateTime)
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS
from .LogSetup import log_debug, log_error, log_info
from .MaimaiNet import (
MaimaiNetClient,
local_utc_offset_seconds,
login_date_time_now,
request_dict,
)
@dataclass
class UserLoginRequestVO:
"""请求 VO(对应 UserLoginRequestVO,字段顺序照 C# 声明)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
dateTime: int = 0
loginDateTime: int = 0
isContinue: bool = False
genericFlag: int = 0
token: str = ""
@dataclass
class UserLoginResponseVO:
"""
响应 VO(对应 UserLoginResponseVO)。
★ 后两个字段是**客户端回填**的,服务端不返回:
loginDateTime —— PacketUserLogin.cs:43 用请求值回填,
UpsertUserAll / UserLogout 都复用它
dateTime —— 本次请求发出的 ALL.Net 认证时刻,
UpsertUserAll 的 userData.dateTime 要用它
(真机 source/2026-10-05.log:123 两处都是 1791134329)
"""
returnCode: int = 0
lastLoginDate: str = ""
loginCount: int = 0
consecutiveLoginCount: int = 0
loginId: int = 0
loginDateTime: int = 0
token: str = ""
dateTime: int = 0
def UserLogin(
user_id: int,
token: str,
*,
access_code: str = "",
region_id: int = REGION_ID,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
is_continue: bool = False,
generic_flag: int = 0,
date_time: Optional[int] = None,
login_date_time: Optional[int] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserLoginResponseVO:
"""
用户正式登录(创建登录会话)。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填,来自 ParseQRCode)
access_code: 访问码(可选,通常为空)
region_id: 区域 ID(可选,默认 config.REGION_ID)
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
client_id: 机台短值(可选)
is_continue: 是否续玩(可选)
generic_flag: 通用标志位(可选)
date_time: ALL.Net 认证时刻(可选,默认当前 Unix 秒)
login_date_time: 本次登录时刻(可选,默认按真机算法生成)
timeout_ms: 超时毫秒(可选,默认 60 秒)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserLoginResponseVO(loginDateTime 已按真机行为回填)
异常:
RuntimeError: returnCode != 1
"""
log_info(
"用户正式登录:user_id=" + str(user_id)
+ " region_id=" + str(region_id) + " place_id=" + str(place_id)
)
now = int(time.time())
if date_time is None:
date_time = now
if login_date_time is None:
login_date_time = login_date_time_now()
log_debug(
"时间戳:dateTime=" + str(date_time) + " loginDateTime=" + str(login_date_time)
+ "(本地时区偏移 " + str(local_utc_offset_seconds()) + " 秒)"
)
raw = request_dict(
"UserLoginApi",
UserLoginRequestVO(
userId=user_id,
accessCode=access_code,
regionId=region_id,
placeId=place_id,
clientId=client_id,
dateTime=date_time,
loginDateTime=login_date_time,
isContinue=is_continue,
genericFlag=generic_flag,
token=token,
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_LONG_MS, client=client,
)
known = {f for f in UserLoginResponseVO.__dataclass_fields__}
resp = UserLoginResponseVO(**{k: v for k, v in raw.items() if k in known})
if resp.returnCode != 1:
msg = "登录失败:returnCode=" + str(resp.returnCode)
log_error(msg)
raise RuntimeError(msg)
# ★ 回填 loginDateTime(对应 PacketUserLogin.cs:43)
# 服务端不返回该字段,客户端用请求值回填;后续 UpsertUserAll / UserLogout 复用。
if not resp.loginDateTime:
resp.loginDateTime = login_date_time
log_debug("服务端未返回 loginDateTime,按真机行为回填为 " + str(login_date_time))
# ★ 回填 dateTime:本次请求实际发出的 ALL.Net 认证时刻。
# UpsertUserAll 的 userData.dateTime 要用它(真机两处一致)。
resp.dateTime = date_time
log_info(
"用户登录成功:loginId=" + str(resp.loginId)
+ " loginDateTime=" + str(resp.loginDateTime)
+ " dateTime=" + str(resp.dateTime)
+ " lastLoginDate=" + resp.lastLoginDate
+ " loginCount=" + str(resp.loginCount)
+ " consecutiveLoginCount=" + str(resp.consecutiveLoginCount)
)
return resp
if __name__ == "__main__":
import sys
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogin_test")
try:
if len(sys.argv) < 3:
print("用法: python -m api.userlogin <user_id> <token> [region_id] [place_id]")
return 1
rid = int(sys.argv[3]) if len(sys.argv) > 3 else REGION_ID
pid = int(sys.argv[4]) if len(sys.argv) > 4 else PLACE_ID
lg = UserLogin(int(sys.argv[1]), sys.argv[2], region_id=rid, place_id=pid)
print("")
for name in UserLoginResponseVO.__dataclass_fields__:
print(" " + name.ljust(24) + "= " + str(getattr(lg, name)))
return 0
except Exception as exc:
log_error("用户登录失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
+254
View File
@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""
UserLogout.py —— 用户登出(UserLogoutApi)
================================================================================
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
对应接口:UserLogoutApi
请求体 :两种形态,见 source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogout.cs:22-63
type == 1 (Logout) -> UserLogoutRequest2VO(含 delayLog)
其他 type -> UserLogoutRequestVO (不含 delayLog)
--------------------------------------------------------------------------------
登出类型(source/Assembly-CSharp/Net.VO.Mai2/LogoutType.cs)
--------------------------------------------------------------------------------
None = 0
Logout = 1 正常登出(带 delayLog 统计)
Cancel = 2 取消 / 强制登出指定座席(NetPacketUtil.cs:47)
Error = 3 错误强制登出(GameMain.cs:91)
TestIn = 4 测试登入 / 顶号(GameMain.cs:104)
Quit = 5 退出时强制登出,可释放残留会话(GameMainObject.cs:200)
★ 真机会话结束时用的是 type=4(TestIn),见 source/2026-10-05.log:125。
★ delayLog 结构(source/Assembly-CSharp/Net.VO.Mai2/DelayLog.cs)
request 是定长 26 的数组(DelayLog.cs:17 new RequestAPI[26]),
每个元素 {count, size, msec, retry}。
调用示例:
from api import UserLogout, LogoutType
UserLogout(12345678, token, login.loginDateTime,
logout_type=LogoutType.TestIn, region_id=1, place_id=1000)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS
from .LogSetup import log_debug, log_error, log_info
from .MaimaiNet import MaimaiNetClient, request_dict
#: DelayLog.request 的定长(DelayLog.cs:17)
DELAY_LOG_SLOTS = 26
class LogoutType:
"""登出类型(对应 Net.VO.Mai2.LogoutType)。"""
None_ = 0
Logout = 1 # 正常登出(带 delayLog)
Cancel = 2 # 取消 / 强制登出
Error = 3 # 错误强制登出
TestIn = 4 # 测试登入 / 顶号(真机会话结束用这个)
Quit = 5 # 退出强制登出
@dataclass
class RequestAPI:
"""单个接口的统计(对应 Net.VO.Mai2/RequestAPI.cs)。"""
count: int = 0
size: int = 0
msec: int = 0
retry: int = 0
@dataclass
class DelayLog:
"""会话网络统计(对应 Net.VO.Mai2/DelayLog.cs)。"""
dlRequests: int = 0
dlSize: int = 0
dlRetry: int = 0
loginMsec: int = 0
saveMsec: int = 0
reductionMusic: int = 0
reductionItem: int = 0
request: List[RequestAPI] = field(
default_factory=lambda: [RequestAPI() for _ in range(DELAY_LOG_SLOTS)]
)
@dataclass
class UserLogoutRequestVO:
"""简单登出请求 VO(对应 UserLogoutRequestVO)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
loginDateTime: int = 0
type: int = 0
@dataclass
class UserLogoutRequest2VO:
"""完整登出请求 VO(对应 UserLogoutRequest2VO,含 delayLog)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
loginDateTime: int = 0
type: int = 0
delayLog: Optional[DelayLog] = None
@dataclass
class UserLogoutResponseVO:
"""响应 VO(对应 UserLogoutResponseVO)。"""
returnCode: int = 0
def create_empty_delay_log() -> DelayLog:
"""创建全零的 DelayLog(26 个槽位)。"""
return DelayLog()
def create_delay_log_from_stats(
requests_stats: Optional[List[Dict[str, Any]]] = None,
*,
dl_requests: int = 0,
dl_size: int = 0,
dl_retry: int = 0,
login_msec: int = 0,
save_msec: int = 0,
reduction_music: int = 0,
reduction_item: int = 0,
) -> DelayLog:
"""
用统计数据构造 DelayLog。
参数:
requests_stats: 每个接口的统计列表,元素形如
{"count":1,"size":2090,"msec":73,"retry":0}
按 DelayLog.request 的槽位顺序填入(最多 26 个)
"""
dl = DelayLog(
dlRequests=dl_requests, dlSize=dl_size, dlRetry=dl_retry,
loginMsec=login_msec, saveMsec=save_msec,
reductionMusic=reduction_music, reductionItem=reduction_item,
)
for i, stat in enumerate(requests_stats or []):
if i >= DELAY_LOG_SLOTS:
break
dl.request[i] = RequestAPI(
count=int(stat.get("count", 0)),
size=int(stat.get("size", 0)),
msec=int(stat.get("msec", 0)),
retry=int(stat.get("retry", 0)),
)
return dl
def UserLogout(
user_id: int,
token: str,
login_date_time: int,
*,
logout_type: int = LogoutType.TestIn,
access_code: str = "",
region_id: int = REGION_ID,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
delay_log: Optional[DelayLog] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserLogoutResponseVO:
"""
用户登出。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填)
login_date_time: 登录时刻(必填,取自 UserLogin 的 loginDateTime)
logout_type: 登出类型(可选,默认 4=TestIn,与真机一致)
access_code: 访问码(可选)
region_id: 区域 ID(可选)
place_id: 店铺 ID(可选)
client_id: 机台短值(可选)
delay_log: 会话统计(可选;仅 logout_type=1 时随报文发出)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserLogoutResponseVO
异常:
RuntimeError: returnCode != 1
"""
log_info(
"用户登出:user_id=" + str(user_id) + " logout_type=" + str(logout_type)
+ " loginDateTime=" + str(login_date_time)
)
if logout_type == LogoutType.Logout:
body: Any = UserLogoutRequest2VO(
userId=user_id, accessCode=access_code,
regionId=region_id, placeId=place_id, clientId=client_id,
loginDateTime=login_date_time, type=logout_type,
delayLog=delay_log if delay_log is not None else create_empty_delay_log(),
)
log_debug("使用完整登出报文(含 delayLog)")
else:
body = UserLogoutRequestVO(
userId=user_id, accessCode=access_code,
regionId=region_id, placeId=place_id, clientId=client_id,
loginDateTime=login_date_time, type=logout_type,
)
log_debug("使用简单登出报文(不含 delayLog)")
raw = request_dict(
"UserLogoutApi", body,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
resp = UserLogoutResponseVO(returnCode=int(raw.get("returnCode", 0) or 0))
if resp.returnCode != 1:
msg = "登出失败:returnCode=" + str(resp.returnCode)
log_error(msg)
raise RuntimeError(msg)
log_info("用户登出成功:returnCode=" + str(resp.returnCode))
return resp
if __name__ == "__main__":
import sys
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogout_test")
try:
if len(sys.argv) < 4:
print("用法: python -m api.userlogout <user_id> <token> <loginDateTime> "
"[logout_type] [region_id] [place_id]")
return 1
lt = int(sys.argv[4]) if len(sys.argv) > 4 else LogoutType.TestIn
rid = int(sys.argv[5]) if len(sys.argv) > 5 else REGION_ID
pid = int(sys.argv[6]) if len(sys.argv) > 6 else PLACE_ID
resp = UserLogout(int(sys.argv[1]), sys.argv[2], int(sys.argv[3]),
logout_type=lt, region_id=rid, place_id=pid)
print("")
print("returnCode =", resp.returnCode)
return 0
except Exception as exc:
log_error("用户登出失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
+563
View File
@@ -0,0 +1,563 @@
# -*- coding: utf-8 -*-
"""
api 包 —— 统一导出
================================================================================
主脚本只需要 from api import ... 就能拿到全部功能。
★ 本文件在导入时就给下面 _API_FUNCS 里列出的每个函数套上「日志外壳」,
所以主脚本拿到的已经是带日志的版本 —— 调用时会自动产出:
=== ParseQRCode 开始运行 ===
[接口说明] 解析二维码并换取 userId 与 token。
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
=== ParseQRCode 运行结束 ===
主脚本**不需要**写装饰器,也不需要自己埋点。
--------------------------------------------------------------------------------
目录结构(一个 api 一个脚本)
--------------------------------------------------------------------------------
基础设施
LogSetup.py 统一日志(INFO / DEBUG 双模式)
MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
UserGetterCommon.py 取数脚本公共底座(请求 VO / _get / 翻页)
ChargeGuard.py ★ 买票前置闸门:买票前必须先跑过 GetUserCharge
会话链路
Chime.py 扫码登录 -> userId + token
GetGameSettings.py GetGameSettingApi(游戏设置)
Ping.py 心跳探活
GetUserPreview.py GetUserPreviewApi(用户预览)
UserLogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
UserLogout.py UserLogoutApi(登出)
上传 / 结算(有副作用的接口)
UpsertUserAll.py ★ UpsertUserAllApi(上传成绩 + 解锁歌曲/解禁难度)
UpsertUserChargelog.py UpsertUserChargelogApi(购买功能票)
GetNeedUserData.py ★ 结算快照编排(统一调用下面的取数脚本),
并把结果落盘到 <工程根>/data/<userId>.json
★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get**
(不要模棱两可)。所以下面这些文件全是 get_ 开头。
取数 —— GetUser*(一个接口一个脚本)
GetUserData.py GetUserDataApi
GetUserExtend.py GetUserExtendApi
GetUserOption.py GetUserOptionApi
GetUserCharacter.py GetUserCharacterApi
GetUserGhost.py GetUserGhostApi
GetUserMap.py GetUserMapApi
GetUserLoginBonus.py GetUserLoginBonusApi
GetUserRating.py GetUserRatingApi
GetUserItem.py GetUserItemApi
GetUserMusic.py GetUserMusicApi
GetUserCourse.py GetUserCourseApi
GetUserCharge.py GetUserChargeApi
GetUserFavorite.py GetUserFavoriteApi
GetUserActivity.py GetUserActivityApi
GetUserMission.py GetUserMissionDataApi
GetUserIntimate.py GetUserIntimateApi
GetUserShopStock.py GetUserShopStockApi
GetUserFavoriteItem.py GetUserFavoriteItemApi
GetUserRival.py GetUserRivalDataApi / GetUserRivalMusicApi
GetUserFriendBonus.py GetUserFriendBonusApi
GetUserKaleidxScope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
GetUserRegion.py GetUserRegionApi
GetUserRecommendMusic.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
GetUserNewItem.py GetUserNewItemListApi
取数 —— GetGame*
GetGameSettings.py GetGameSettingApi
GetGameCharge.py GetGameChargeApi
"""
from .LogSetup import (
# 会话
setup_logging,
teardown_logging,
# 自动跟踪
autowrap,
get_called_apis,
# 模式
get_level,
set_level,
is_debug,
LEVEL_INFO,
LEVEL_DEBUG,
# 装饰器 / 兼容
api_log,
get_logger,
# 流程日志(INFO 可见)
log_info,
log_step,
log_result,
log_warning,
log_error,
log_section,
log_field,
log_debug,
# 收发数据日志(DEBUG 专属)
log_request,
log_response,
log_payload,
)
# ---------------------------------------------------------------------------
# 1) 网络核心
# ---------------------------------------------------------------------------
from .MaimaiNet import (
# 协议常量(原在 config.py,已下沉到 MaimaiNet.py)
BASE_URL,
AES_KEY,
AES_IV,
OBFUSCATE_PARAM,
MaimaiNetClient,
MaimaiNetError,
get_default_client,
close_default_client,
request,
request_dict,
request_vo,
obfuscate_api_name,
build_api_url,
build_user_agent,
adler32,
aes_encrypt,
aes_decrypt,
deflate_compress,
deflate_decompress,
vo_to_json,
vo_from_json,
local_utc_offset_seconds,
login_date_time_now,
EmptyRequestVO,
UserIdRequestVO,
ReturnCodeVO,
)
# ---------------------------------------------------------------------------
# 2) 会话链路
# ---------------------------------------------------------------------------
from .Chime import (
ParseQRCode,
# aimedb / QR 常量(原在 config.py,已下沉到 Chime.py)
AIMEDB_HOST,
AIMEDB_PORT,
AIMEDB_PATH,
QR_SIGN_KEY,
QR_OPEN_GAME_ID,
QR_TITLE_KEY,
QR_USER_AGENT,
)
from .GetGameSettings import (
GetGameSettings,
GameSetting,
GameSettingRequestVO,
GameSettingResponseVO,
is_maintenance,
is_ping_disabled,
)
from .Ping import Ping
from .GetUserPreview import (
GetUserPreview,
UserPreviewRequestVO,
UserPreviewResponseVO,
)
from .UserLogin import (
UserLogin,
UserLoginRequestVO,
UserLoginResponseVO,
)
from .UserLogout import (
UserLogout,
LogoutType,
DelayLog,
RequestAPI,
UserLogoutRequestVO,
UserLogoutRequest2VO,
UserLogoutResponseVO,
create_empty_delay_log,
create_delay_log_from_stats,
DELAY_LOG_SLOTS,
)
# ---------------------------------------------------------------------------
# 3) 上传成绩
# ---------------------------------------------------------------------------
from .UpsertUserAll import (
UpsertUserAll,
PlayData,
build_play_from_config,
derive_play_characters,
derive_play_rating,
derive_play_grade,
find_music_detail,
PLAY_SPECIAL_NUM,
PLAY_LOCAL_PLAY_COUNT,
PLAY_AFTER_GRADE_RANK,
DERIVE_GRADE_FROM_MUSIC_DETAIL,
UserAllRequestVO,
UpsertResponseVO,
UpsertUserAllError,
ConfirmRequiredError,
PatchError,
# 节点结构
NODE_TYPES,
NODE_ORDER,
BITS_TO_ARRAY,
ARRAY_TO_BITS,
DIFF_PRIMARY_KEYS,
MAY_BE_EMPTY_NODES,
DEFAULT_EMPTY_NODES,
# 组包辅助
empty_node,
empty_user_all,
coerce_node_value,
sync_bits_to_arrays,
build_delta,
build_favorite_delta,
build_user_all,
build_user_playlog,
build_user_game_playlog,
build_post_play_user_data,
validate_user_playlog,
validate_before_send,
apply_play_effects,
# ★ 额外 JSON 合并 / 落库回查
load_extra_data,
merge_extra_data,
verify_upsert_landing,
# ★ 歌曲解锁(解禁):写进 userItemList 的 itemKind 5/6/7
build_unlock_items,
ITEM_KIND_MUSIC_UNLOCK,
ITEM_KIND_MUSIC_UNLOCK_MASTER,
ITEM_KIND_MUSIC_UNLOCK_REMASTER,
ITEM_KIND_MUSIC_UNLOCK_STRONG,
UNLOCK_MASTER_MIN_MUSIC_ID,
default_user_weekly_data,
default_user_2p_playlog,
calc_play_special,
normalize_snapshot,
wait_send_cooldown,
# 字段表
PLAYLOG_FIELDS,
PLAYLOG_BOOL_FIELDS,
PLAYLOG_STRING_FIELDS,
JUDGE_TYPES,
JUDGE_BOXES,
USER_DATA_DROP_KEYS,
USER_OPTION_DROP_KEYS,
USER_CHARACTER_KEEP_KEYS,
USER_MUSIC_DETAIL_DROP_KEYS,
USER_CHARGE_DROP_KEYS,
USER_MISSION_DROP_KEYS,
)
from .GetNeedUserData import (
GetNeedUserData,
save_snapshot,
load_snapshot,
summarize_snapshot,
DEFAULT_SNAPSHOT_NODES,
# ★ 用户数据落盘(<工程根>/data/<userId>.json)
PROJECT_ROOT,
DATA_DIR,
user_data_path,
save_user_data,
load_user_data,
list_saved_user_ids,
)
# ---------------------------------------------------------------------------
# ★ 买票前置闸门:GetUserCharge 必须先跑过(见 api/ChargeGuard.py)
# ---------------------------------------------------------------------------
from .ChargeGuard import (
CHARGE_READ_FRESH_SEC,
has_fresh_read,
held_charges,
last_charge_list,
last_read_age,
note_charge_purchase,
note_user_charge_read,
purchased_charge_ids,
reset as reset_charge_guard,
)
from .UpsertUserChargelog import (
# 票券主数据(原在 config.py,已下沉到 UpsertUserChargelog.py)
TICKET_CREDIT_NUM,
TICKET_NAMES,
PAID_TICKET_IDS,
TICKET_VALID_HOUR_OFFSET,
TICKET_VALID_DAYS,
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
UserChargeVO,
UserChargelogVO,
UserChargelogRequestVO,
ticket_name,
ticket_price,
calc_valid_date,
parse_wall_time,
now_cst,
)
# ---------------------------------------------------------------------------
# 4) 取数公共底座
# ---------------------------------------------------------------------------
from .UserGetterCommon import (
EmptyRequestVO as GetterEmptyRequestVO,
UserIdRequestVO as GetterUserIdRequestVO,
UserIdPageRequestVO,
UserLoginBonusRequestVO,
UserItemRequestVO,
UserMusicRequestVO,
UserCourseRequestVO,
UserFavoriteRequestVO,
UserFavoriteItemRequestVO,
UserShopStockRequestVO,
UserRivalRequestVO,
UserRivalMusicRequestVO,
get_all_pages,
ITEM_KINDS,
ITEM_NEXT_INDEX_FACTOR,
FAVORITE_ITEM_KINDS,
RIVAL_MUSIC_LEVELS,
)
# ---------------------------------------------------------------------------
# 5) 取数脚本(一个接口一个脚本)
# ---------------------------------------------------------------------------
from .GetUserData import GetUserData
from .GetUserExtend import GetUserExtend
from .GetUserOption import GetUserOption
from .GetUserCharacter import GetUserCharacter
from .GetUserGhost import GetUserGhost
from .GetUserMap import GetUserMap, GetUserMapAll
from .GetUserLoginBonus import GetUserLoginBonus, GetUserLoginBonusAll
from .GetUserRating import GetUserRating
from .GetUserItem import GetUserItem, GetUserItemAll
from .GetUserMusic import GetUserMusicDetail, GetUserMusicDetailAll
from .GetUserCourse import GetUserCourse, GetUserCourseAll
from .GetUserCharge import GetUserCharge
from .GetUserFavorite import GetUserFavorite, GetUserFavoriteAll
from .GetUserActivity import GetUserActivity
from .GetUserMission import GetUserMissionData
from .GetUserIntimate import GetUserIntimate
from .GetUserShopStock import GetUserShopStock
from .GetUserFavoriteItem import (
GetUserFavoriteItem,
GetUserFavoriteItemAll,
FAVORITE_ITEM_KIND_MUSIC,
FAVORITE_ITEM_KIND_RIVAL,
)
from .GetUserRival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll
from .GetUserFriendBonus import GetUserFriendBonus, extract_gift_points
from .GetUserKaleidxScope import GetUserKaleidxScope, GetGameKaleidxScope
from .GetUserRegion import GetUserRegion
from .GetUserRecommendMusic import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
from .GetUserNewItem import GetUserNewItemList, UserNewItemListRequestVO
from .GetGameCharge import GetGameCharge, GameChargeRequestVO
# ---------------------------------------------------------------------------
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
# ---------------------------------------------------------------------------
_API_FUNCS = (
# --- 会话链路 ---
"ParseQRCode",
"GetGameSettings",
"Ping",
"GetUserPreview",
"UserLogin",
"UserLogout",
# --- 上传成绩 ---
"UpsertUserAll",
"build_play_from_config",
"derive_play_characters",
"merge_extra_data",
"verify_upsert_landing",
"build_user_all",
"build_unlock_items",
# --- 购买功能票 ---
"UpsertUserChargelog",
"ticket_name",
"ticket_price",
"calc_valid_date",
"build_user_playlog",
"build_user_game_playlog",
"validate_user_playlog",
"validate_before_send",
"apply_play_effects",
"build_post_play_user_data",
"build_delta",
"build_favorite_delta",
"normalize_snapshot",
"GetNeedUserData",
"summarize_snapshot",
"GetUserNewItemList",
# --- 取数:单接口 ---
"GetUserData", # GetUserDataApi
"GetUserExtend", # GetUserExtendApi
"GetUserOption", # GetUserOptionApi
"GetUserCharacter", # GetUserCharacterApi
"GetUserGhost", # GetUserGhostApi
"GetUserMap", # GetUserMapApi(单页)
"GetUserMapAll", # GetUserMapApi(全量)
"GetUserLoginBonus", # GetUserLoginBonusApi(单页)
"GetUserLoginBonusAll", # GetUserLoginBonusApi(全量)
"GetUserRating", # GetUserRatingApi
"GetUserItem", # GetUserItemApi(单类)
"GetUserItemAll", # GetUserItemApi(全量)
"GetUserMusicDetail", # GetUserMusicApi(单页)
"GetUserMusicDetailAll", # GetUserMusicApi(全量)
"GetUserCourse", # GetUserCourseApi(单页)
"GetUserCourseAll", # GetUserCourseApi(全量)
"GetUserCharge", # GetUserChargeApi
"GetUserFavorite", # GetUserFavoriteApi(单类)
"GetUserFavoriteAll", # GetUserFavoriteApi(全量)
"GetUserActivity", # GetUserActivityApi
"GetUserMissionData", # GetUserMissionDataApi
"GetUserIntimate", # GetUserIntimateApi
"GetUserShopStock", # GetUserShopStockApi
"GetUserFavoriteItem", # GetUserFavoriteItemApi(单页)
"GetUserFavoriteItemAll", # GetUserFavoriteItemApi(全量)
"GetUserRivalData", # GetUserRivalDataApi
"GetUserRivalMusic", # GetUserRivalMusicApi(单页)
"GetUserRivalMusicAll", # GetUserRivalMusicApi(全量)
"GetUserFriendBonus", # GetUserFriendBonusApi
"GetUserKaleidxScope", # GetUserKaleidxScopeApi
"GetGameKaleidxScope", # GetGameKaleidxScopeApi
"GetUserRegion", # GetUserRegionApi
"GetUserRecommendRateMusic", # GetUserRecommendRateMusicApi
"GetUserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
"GetUserNewItemList", # GetUserNewItemListApi
# --- 取数:游戏侧 ---
"GetGameCharge", # GetGameChargeApi
)
# ---------------------------------------------------------------------------
# 7) 导入即包装:保证 from api import X 拿到的是带日志的版本
# ---------------------------------------------------------------------------
autowrap(globals(), _API_FUNCS)
# ---------------------------------------------------------------------------
# 8) 统一导出
# ---------------------------------------------------------------------------
__all__ = [
# ---- 日志:会话 ----
"setup_logging", "teardown_logging",
# ---- 日志:自动跟踪 ----
"autowrap", "get_called_apis",
# ---- 日志:模式 ----
"get_level", "set_level", "is_debug", "LEVEL_INFO", "LEVEL_DEBUG",
# ---- 日志:装饰器 / 兼容 ----
"api_log", "get_logger",
# ---- 日志:流程 ----
"log_info", "log_step", "log_result", "log_warning", "log_error",
"log_section", "log_field",
# ---- 日志:收发数据 ----
"log_debug", "log_request", "log_response", "log_payload",
# ---- 网络核心 ----
"MaimaiNetClient", "MaimaiNetError", "get_default_client", "close_default_client",
"request", "request_dict", "request_vo",
"obfuscate_api_name", "build_api_url", "build_user_agent", "adler32",
"aes_encrypt", "aes_decrypt", "deflate_compress", "deflate_decompress",
"vo_to_json", "vo_from_json",
"local_utc_offset_seconds", "login_date_time_now",
"EmptyRequestVO", "UserIdRequestVO", "ReturnCodeVO",
# ---- 会话链路 ----
"ParseQRCode",
"GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO",
"is_maintenance", "is_ping_disabled",
"Ping",
"GetUserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO",
"UserLogin", "UserLoginRequestVO", "UserLoginResponseVO",
"UserLogout", "LogoutType", "DelayLog", "RequestAPI",
"UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO",
"create_empty_delay_log", "create_delay_log_from_stats", "DELAY_LOG_SLOTS",
# ---- 上传成绩 ----
"UpsertUserAll", "PlayData", "build_play_from_config", "derive_play_characters",
"load_extra_data", "merge_extra_data", "verify_upsert_landing",
# ---- 歌曲解锁(解禁)----
"build_unlock_items", "UNLOCK_MASTER_MIN_MUSIC_ID",
"ITEM_KIND_MUSIC_UNLOCK", "ITEM_KIND_MUSIC_UNLOCK_MASTER",
"ITEM_KIND_MUSIC_UNLOCK_REMASTER", "ITEM_KIND_MUSIC_UNLOCK_STRONG",
# ---- 购买功能票 ----
"UpsertUserChargelog", "ChargelogConfirmError", "ChargelogGuardError",
# ---- 买票前置闸门(GetUserCharge 必须先跑过)----
"CHARGE_READ_FRESH_SEC", "has_fresh_read", "held_charges", "last_charge_list",
"last_read_age", "note_charge_purchase", "note_user_charge_read",
"purchased_charge_ids", "reset_charge_guard",
"UserChargeVO", "UserChargelogVO", "UserChargelogRequestVO",
"ticket_name", "ticket_price", "calc_valid_date", "parse_wall_time", "now_cst",
"UserAllRequestVO", "UpsertResponseVO",
"UpsertUserAllError", "ConfirmRequiredError", "PatchError",
"NODE_TYPES", "NODE_ORDER", "BITS_TO_ARRAY", "ARRAY_TO_BITS",
"DIFF_PRIMARY_KEYS", "MAY_BE_EMPTY_NODES", "DEFAULT_EMPTY_NODES",
"empty_node", "empty_user_all", "coerce_node_value", "sync_bits_to_arrays",
"build_delta", "build_favorite_delta", "build_user_all",
"build_user_playlog", "build_user_game_playlog", "build_post_play_user_data",
"validate_user_playlog", "validate_before_send", "apply_play_effects",
"default_user_weekly_data", "default_user_2p_playlog", "calc_play_special",
"normalize_snapshot", "wait_send_cooldown",
"PLAYLOG_FIELDS", "PLAYLOG_BOOL_FIELDS", "PLAYLOG_STRING_FIELDS",
"JUDGE_TYPES", "JUDGE_BOXES",
"USER_DATA_DROP_KEYS", "USER_OPTION_DROP_KEYS", "USER_CHARACTER_KEEP_KEYS",
"USER_MUSIC_DETAIL_DROP_KEYS", "USER_CHARGE_DROP_KEYS", "USER_MISSION_DROP_KEYS",
# ---- 快照编排 ----
"GetNeedUserData", "save_snapshot", "load_snapshot",
"summarize_snapshot", "DEFAULT_SNAPSHOT_NODES",
"PROJECT_ROOT", "DATA_DIR", "user_data_path",
"save_user_data", "load_user_data", "list_saved_user_ids",
# ---- 取数公共底座 ----
"GetterEmptyRequestVO", "GetterUserIdRequestVO", "UserIdPageRequestVO",
"UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO",
"UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO",
"UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO",
"get_all_pages",
"ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS",
# ---- 取数脚本 ----
"GetUserData", "GetUserExtend", "GetUserOption", "GetUserCharacter", "GetUserGhost",
"GetUserMap", "GetUserMapAll",
"GetUserLoginBonus", "GetUserLoginBonusAll",
"GetUserRating",
"GetUserItem", "GetUserItemAll",
"GetUserMusicDetail", "GetUserMusicDetailAll",
"GetUserCourse", "GetUserCourseAll",
"GetUserCharge",
"GetUserFavorite", "GetUserFavoriteAll",
"GetUserActivity", "GetUserMissionData", "GetUserIntimate", "GetUserShopStock",
"GetUserFavoriteItem", "GetUserFavoriteItemAll",
"FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL",
"GetUserRivalData", "GetUserRivalMusic", "GetUserRivalMusicAll",
"GetUserFriendBonus", "extract_gift_points",
"GetUserKaleidxScope", "GetGameKaleidxScope",
"GetUserRegion",
"GetUserRecommendRateMusic", "GetUserRecommendSelectMusic",
"GetUserNewItemList", "UserNewItemListRequestVO",
"GetGameCharge", "GameChargeRequestVO",
# --- 协议常量(原在 config.py,已下沉到各 api 模块)---
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM",
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH",
"QR_SIGN_KEY", "QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
"TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
]
+563
View File
@@ -0,0 +1,563 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
authv1.py —— ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId / regionId / placeId / placeName / regionName
==========================================================================================================
【解决什么问题】
config.py 第二节那六个值
KEYCHIP_ID / CLIENT_ID / REGION_ID / PLACE_ID / PLACE_NAME / REGION_NAME
全部来自 **AMDaemon 开机时向 ALL.Net 认证服务器要的结果**。
游戏服务器(<REDACTED>)**没有任何接口**会返回它们 ——
所以本工程此前只能手工从真机报文里抄(见 config.py 里那段吐槽)。
本脚本把 AMDaemon 的这一步原样重做了一遍。
【协议出处】—— 全部能在 source/amdaemon.exe 的字符串里对上(数字是文件内偏移)
端点 http://at.sys-all.cn/net/initialize @0x788170
备用 http://at.sys-allnet.cn/net/initialize 实测同样可用
库 alAbEx_lite Ver 4.00.01 (src/lib/alabex_pv4_lite) @0x784a31
请求体 "title_id=%s&title_ver=%s&client_id=%s" @0x7882b8
请求头 Pragma: DFI @0x7883cc
User-Agent: <...>Windows/Lite @0x788508
响应字段 result= uri1= uri2= name= nickname= region0=
region_name0..3= place_id= setting= country=
location_type= client_timezone= res_ver= uri=
utc_time= token= @0x784ae8-0x788fd0
信封 AES-128-CBC + 16 字节前缀块 + PKCS7
(exe @0x63a100 提到 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY))
【信封细节】实测确认
请求 = AES-CBC( 16 字节全 0 前缀 || 明文 ),IV = 16 字节全 0
响应 = 16 字节 IV || AES-CBC 密文
【响应码】
result= 1 成功
result=-2 机台不存在(client_id 未被收录;小写也会走到这里)
result=-3 参数格式错(例如把 15 位完整 Keychip 当 client_id 发出去)
【用法】
python authv1.py A63E01E9580 # 查(打印结果)
python authv1.py A63E-01C30471007 # 带连字符的完整 Keychip 也行
python authv1.py A63E01E9580 --json # 机器可读
python authv1.py A63E01E9580 --write # 强制写回 config.py
python authv1.py A63E01E9580 --no-write # 强制不写
python authv1.py --batch keychip.csv # 批量查(CSV 第一列是 Keychip)
★ 是否写回 config.py,默认看 config.py 里的 AUTHV1_WRITE_CONFIG 开关。
【也当协议库用】
from authv1 import allnet_post, initialize, delivery_instruction, parse_keychip
netdelivery.py 就是这么用它的。
"""
from __future__ import annotations
import argparse
import ast
import csv
import json
import os
import re
import shutil
import sys
import time
import urllib.parse
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
# =============================================================================
# 协议常量
# =============================================================================
# ALL.Net 认证服务器(国服 SDGB)。两个域名指向不同机房,实测都能用;
# 默认用 sys-allnet.cn(与 <REDACTED> 同机房,链路更短)。
ALLNET_HOSTS = (
"http://at.sys-allnet.cn",
"http://at.sys-all.cn",
)
DEFAULT_HOST = ALLNET_HOSTS[0]
PATH_INITIALIZE = "/net/initialize"
PATH_DELIVERY_INSTRUCTION = "/net/delivery/instruction"
TITLE_ID = "SDGB"
# ALE_ABEX_OPTION_AES_KEY 对应的 16 字节密钥。
# 出处:exe @0x63a100 有 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY);
# 密钥本体在 exe 里不是明文串,此处取值经真实请求验证有效。
AES_KEY = bytes([47, 63, 106, 111, 43, 34, 76, 38, 92, 67, 114, 57, 40, 61, 107, 71])
AES_IV = bytes(16)
# 请求头。依据:exe 里 "Pragma" + "DFI"(@0x7883cc) 与 "Windows/Lite"(@0x788508)。
HEADERS = {
"User-Agent": "SDGB;Windows/Lite",
"Pragma": "DFI",
"Content-Type": "application/x-www-form-urlencoded",
}
DEFAULT_TIMEOUT = 20
# client_id 恒为 11 位大写十六进制(Keychip 15 位去掉连字符后的前 11 位)
CLIENT_ID_LEN = 11
CLIENT_ID_RE = re.compile(r"^[0-9A-F]{" + str(CLIENT_ID_LEN) + r"}$")
# 上报给 ALL.Net 的版本号。真机 1.56 ROM 实测仍报 1.55(与 Mai-Encoding 一致);
# 默认取 config.MAI_ENCODING,取不到就退回这个字面量。
DEFAULT_TITLE_VER = "1.55"
RESULT_TEXT = {
"1": "成功",
"-1": "通用错误",
"-2": "机台不存在(client_id 未被 ALL.Net 收录)",
"-3": "参数格式错误(client_id 必须是 11 位短值)",
}
class AllnetError(RuntimeError):
"""ALL.Net 交互失败(网络 / 信封 / 业务码非 1)。"""
# =============================================================================
# 信封:AES-128-CBC
# =============================================================================
def encode_request(text: str) -> bytes:
"""明文 -> 线上字节流:16 字节全 0 前缀 + PKCS7 + AES-128-CBC(IV=0)。"""
body = AES_IV + text.encode("utf-8")
return AES.new(AES_KEY, AES.MODE_CBC, AES_IV).encrypt(pad(body, AES.block_size))
def decode_response(raw: bytes) -> str:
"""线上字节流 -> 明文:前 16 字节是 IV,其后是 AES-128-CBC 密文。"""
if len(raw) < 32 or len(raw) % AES.block_size:
raise AllnetError("响应长度异常:%d 字节(应 >=32 且为 16 的倍数)" % len(raw))
plain = AES.new(AES_KEY, AES.MODE_CBC, raw[:16]).decrypt(raw[16:])
n = plain[-1]
if 1 <= n <= AES.block_size:
plain = plain[:-n]
return plain.decode("utf-8", "replace")
def allnet_post(path, body, *, host=DEFAULT_HOST, timeout=DEFAULT_TIMEOUT, retries=3):
"""
POST 一条加密请求,返回解密后的明文(形如 "result=1&place_id=...")。
★ 实测 at.sys-allnet.cn 偶尔会直接掐断连接(RemoteDisconnected),
所以这里对**网络层**失败做退避重试;业务码非 1 不重试。
"""
url = host.rstrip("/") + path
payload = encode_request(body)
last_error = None
for attempt in range(1, retries + 1):
try:
resp = requests.post(url, data=payload, headers=HEADERS, timeout=timeout)
except requests.RequestException as exc:
last_error = exc
if attempt < retries:
time.sleep(1.5 * attempt)
continue
raise AllnetError("请求 %s 失败(已重试 %d 次):%s" % (url, retries, exc)) from exc
if resp.status_code != 200:
if resp.status_code >= 500 and attempt < retries:
last_error = "HTTP %d" % resp.status_code
time.sleep(1.5 * attempt)
continue
raise AllnetError("请求 %s 返回 HTTP %d" % (url, resp.status_code))
return decode_response(resp.content)
raise AllnetError("请求 %s 失败:%s" % (url, last_error))
def parse_kv(text: str) -> dict:
"""把 "result=1&place_id=146b&name=%E5%AE%89..." 解析成 dict(值做 URL 解码)。"""
out = {}
for part in text.strip().split("&"):
if not part or "=" not in part:
continue
key, val = part.split("=", 1)
out[key.strip()] = urllib.parse.unquote(val)
return out
# =============================================================================
# Keychip <-> client_id
# =============================================================================
def parse_keychip(raw: str) -> str:
"""
把用户给的 Keychip 归一化成 client_id(11 位大写短值)。
接受三种写法:
完整带连字符 "A63E-01C30471007"
完整连写 "A63E01C30471007"
短值 "A63E01C3047"
★ 为什么一定要截前 11 位:实测把完整 15 位当 client_id 发出去,
ALL.Net 回 result=-3(格式错)。
"""
s = re.sub(r"[\s\-]", "", str(raw)).upper()
if len(s) < CLIENT_ID_LEN:
raise ValueError("Keychip 太短:%r(至少 %d 位)" % (raw, CLIENT_ID_LEN))
cid = s[:CLIENT_ID_LEN]
if not CLIENT_ID_RE.match(cid):
raise ValueError("Keychip 非法:%r(应为十六进制大写)" % raw)
return cid
def make_keychip(client_id: str, place_id) -> str:
"""
按本工程约定拼回完整 Keychip:
前 4 位 + "-" + 第 5 位起 + placeId
例:A63E01C3047 + 1007 -> A63E-01C30471007
"""
cid = str(client_id).upper()
return "%s-%s%d" % (cid[:4], cid[4:], int(place_id))
# =============================================================================
# 两个接口
# =============================================================================
def initialize(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
timeout=DEFAULT_TIMEOUT):
"""POST /net/initialize —— 机台认证,返回原始明文。"""
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
return allnet_post(PATH_INITIALIZE, body, host=host, timeout=timeout)
def delivery_instruction(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
timeout=DEFAULT_TIMEOUT):
"""POST /net/delivery/instruction —— DLI,返回原始明文(含 uri= 配信指示書地址)。"""
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
return allnet_post(PATH_DELIVERY_INSTRUCTION, body, host=host, timeout=timeout)
def query_keychip(keychip, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
timeout=DEFAULT_TIMEOUT, raw_ok=False):
"""
查一台机台的身份。返回 dict(含 ok / result / 各字段)。
★ raw_ok=False 时,result != 1 直接抛 AllnetError(CLI 用);
raw_ok=True 时照样返回,由调用方自己看 result(批量用)。
"""
cid = parse_keychip(keychip)
text = initialize(cid, title_ver=title_ver, host=host, timeout=timeout)
kv = parse_kv(text)
result = kv.get("result", "")
place_hex = kv.get("place_id", "") or ""
try:
place_id = int(place_hex, 16) if place_hex else None
except ValueError:
place_id = None
region_names = [kv.get("region_name%d" % i, "") for i in range(4)]
region_names = [n for n in region_names if n]
try:
region_id = int(kv.get("region0", "") or 0)
except ValueError:
region_id = None
info = {
"ok": result == "1",
"result": result,
"result_text": RESULT_TEXT.get(result, "未知返回码 %s" % result),
"title_id": TITLE_ID,
"title_ver": title_ver,
"host": host,
# --- 六个值里的五个(KEYCHIP_ID 是推导值)---
"client_id": cid,
"region_id": region_id,
"place_id": place_id,
"place_name": kv.get("name", ""),
"region_name": region_names[0] if region_names else "",
# --- 附送 ---
"nickname": kv.get("nickname", ""),
"region_names": region_names,
"country": kv.get("country", ""),
"setting": kv.get("setting", ""),
"location_type": kv.get("location_type", ""),
"game_server_uri": kv.get("uri1", ""),
"auth_token": kv.get("uri2", ""),
"token": kv.get("token", ""),
"utc_time": kv.get("utc_time", ""),
"client_timezone": kv.get("client_timezone", ""),
"res_ver": kv.get("res_ver", ""),
"raw": text.strip(),
}
if info["place_id"] is not None:
info["keychip"] = make_keychip(cid, info["place_id"])
else:
info["keychip"] = ""
if not info["ok"] and not raw_ok:
raise AllnetError("ALL.Net 返回 result=%s(%s)" % (result, info["result_text"]))
return info
# =============================================================================
# 写回 config.py
# =============================================================================
CONFIG_FILENAME = "config.py"
# 写回哪些键(KEYCHIP_ID 是推导值,不写)
WRITEBACK_KEYS = ("CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME")
def _replace_assignment(text, name, literal):
"""把 config.py 里唯一一行 NAME = ... 换成 NAME = literal。"""
pattern = re.compile(r"^(%s[ \t]*=[ \t]*).*$" % re.escape(name), re.M)
new_text, count = pattern.subn(lambda m: m.group(1) + literal, text, count=1)
if count != 1:
raise AllnetError("config.py 里找不到唯一的 %s = ... 赋值行(找到 %d 处)" % (name, count))
return new_text
def write_back_config(info, *, config_path=None, quiet=False):
"""
把查询结果写回 config.py。
安全措施:
1. 先 ast.parse 语法自检,写坏了就不落盘
2. 落盘前把原文件备份成 config.py.bak
3. 保留原文件的换行风格(LF / CRLF)
"""
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
CONFIG_FILENAME)
if not os.path.isfile(path):
raise AllnetError("找不到 config.py:%s" % path)
with open(path, "rb") as fp:
raw = fp.read()
newline = "\r\n" if b"\r\n" in raw else "\n"
text = raw.decode("utf-8").replace("\r\n", "\n")
pairs = [
("CLIENT_ID", json.dumps(info["client_id"], ensure_ascii=False)),
("REGION_ID", str(info["region_id"])),
("PLACE_ID", str(info["place_id"])),
("PLACE_NAME", json.dumps(info["place_name"], ensure_ascii=False)),
("REGION_NAME", json.dumps(info["region_name"], ensure_ascii=False)),
]
for name, literal in pairs:
text = _replace_assignment(text, name, literal)
try:
ast.parse(text)
except SyntaxError as exc:
raise AllnetError("改写后 config.py 语法不合法,已放弃写入:%s" % exc) from exc
shutil.copy2(path, path + ".bak")
with open(path, "wb") as fp:
fp.write(text.replace("\n", newline).encode("utf-8"))
if not quiet:
print(" [已写回] %s" % path)
print(" [已备份] %s.bak" % path)
return path
def config_switch_enabled(config_path=None):
"""读 config.py 里的 AUTHV1_WRITE_CONFIG 开关。读不到就当作关。"""
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
CONFIG_FILENAME)
try:
with open(path, "rb") as fp:
tree = ast.parse(fp.read().decode("utf-8"))
except Exception:
return False
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "AUTHV1_WRITE_CONFIG":
try:
return bool(ast.literal_eval(node.value))
except Exception:
return False
return False
# =============================================================================
# 输出
# =============================================================================
def format_report(info) -> str:
"""人类可读的结果块。"""
lines = []
lines.append("=== authv1 机台身份查询 ===")
lines.append(" Keychip (完整) : %s" % (info["keychip"] or "(查不到)"))
lines.append(" clientId (短值) : %s <- 游戏服务器 clientId" % info["client_id"])
lines.append(" placeId : %s" % info["place_id"])
lines.append(" placeName : %s" % info["place_name"])
lines.append(" regionId : %s" % info["region_id"])
lines.append(" regionName : %s" % info["region_name"])
lines.append(" --- 附送 ---")
lines.append(" 游戏服务器 : %s" % info["game_server_uri"])
lines.append(" 机台昵称 : %s" % info["nickname"])
lines.append(" 国家/地区 : %s" % info["country"])
lines.append(" 认证时间(UTC) : %s 时区 %s" % (info["utc_time"], info["client_timezone"]))
lines.append(" ALL.Net 返回码 : %s(%s)" % (info["result"], info["result_text"]))
return "\n".join(lines)
# =============================================================================
# CLI
# =============================================================================
def build_parser():
p = argparse.ArgumentParser(
prog="authv1.py",
description="ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId/regionId/placeId/placeName/regionName",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"示例:\n"
" python authv1.py A63E01E9580\n"
" python authv1.py A63E-01C30471007 --json\n"
" python authv1.py A63E01E9580 --write\n"
" python authv1.py --batch source/keychip.csv --limit 10\n"
),
)
p.add_argument("keychip", nargs="?", help="Keychip(完整带连字符 / 完整连写 / 11 位短值都行)")
p.add_argument("--batch", metavar="CSV",
help="批量查询:CSV 文件,默认读第一列(表头含 Keychip 时自动跳过)")
p.add_argument("--limit", type=int, default=0, help="批量模式最多查多少条(0 = 不限)")
p.add_argument("--ver", default=None, help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
p.add_argument("--host", default=DEFAULT_HOST, help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="单次请求超时秒数")
p.add_argument("--json", action="store_true", help="输出 JSON")
g = p.add_mutually_exclusive_group()
g.add_argument("--write", action="store_true", help="强制把结果写回 config.py")
g.add_argument("--no-write", action="store_true", help="强制不写回 config.py")
return p
def resolve_title_ver(cli_value):
if cli_value:
return str(cli_value)
try:
import config # noqa: F401 同目录
return str(getattr(config, "MAI_ENCODING", DEFAULT_TITLE_VER))
except Exception:
return DEFAULT_TITLE_VER
def main(argv=None):
try:
sys.stdout.reconfigure(errors="replace")
except Exception:
pass
args = build_parser().parse_args(argv)
title_ver = resolve_title_ver(args.ver)
if args.batch:
return run_batch(args, title_ver)
if not args.keychip:
build_parser().print_help()
print("\n错误:请给一个 Keychip,或用 --batch 指定 CSV。")
return 2
try:
info = query_keychip(args.keychip, title_ver=title_ver,
host=args.host, timeout=args.timeout)
except (AllnetError, ValueError) as exc:
print("查询失败:%s" % exc)
return 1
if args.json:
print(json.dumps(info, ensure_ascii=False, indent=1))
else:
print(format_report(info))
do_write = decide_write(args)
if do_write:
try:
write_back_config(info)
except AllnetError as exc:
print("写回失败:%s" % exc)
return 1
elif not args.json:
print(" [未写回] 想让它自动写回:把 config.py 的 AUTHV1_WRITE_CONFIG 改成 True,"
"或加 --write")
return 0
def decide_write(args):
"""--write / --no-write 优先,否则看 config.py 的开关。"""
if args.write:
return True
if args.no_write:
return False
return config_switch_enabled()
def _disp_width(text):
"""按终端显示宽度算长度:中日韩全角字符占 2 列。"""
import unicodedata
width = 0
for ch in str(text):
width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
return width
def _pad(text, width):
text = str(text)
return text + " " * max(0, width - _disp_width(text))
def run_batch(args, title_ver):
path = args.batch
if not os.path.isfile(path):
print("找不到 CSV:%s" % path)
return 2
rows = []
with open(path, "r", encoding="utf-8-sig", newline="") as fp:
reader = csv.reader(fp)
for row in reader:
if not row or not row[0].strip():
continue
if row[0].strip().lower() in ("keychip", "keychip id", "keychipid"):
continue
rows.append(row)
if args.limit > 0:
rows = rows[: args.limit]
print("=== authv1 批量查询(%d 台,版本 %s)===" % (len(rows), title_ver))
header = " ".join([_pad("clientId", 13), _pad("result", 6), _pad("placeId", 8),
_pad("regionId", 8), _pad("区域", 8), "店铺名"])
print(header)
ok = 0
results = []
for row in rows:
try:
info = query_keychip(row[0], title_ver=title_ver, host=args.host,
timeout=args.timeout, raw_ok=True)
except Exception as exc:
print("%s ERROR %s" % (_pad(row[0].strip()[:13], 13), exc))
continue
results.append(info)
if info["ok"]:
ok += 1
print(" ".join([
_pad(info["client_id"], 13),
_pad(info["result"], 6),
_pad(info["place_id"], 8),
_pad(info["region_id"], 8),
_pad(info["region_name"], 8),
str(info["place_name"]),
]))
print("--- 成功 %d / %d ---" % (ok, len(rows)))
return 0
if __name__ == "__main__":
sys.exit(main())
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登出
================================================================================
一条完整的「买票」链路:
ParseQRCode -> GetUserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
★ 本脚本判断「能买什么票」**只看 GetUserChargeApi**(账号自己的票券表),
不再调 GetGameChargeApi(那是机台本地的上架清单,跟账号状态无关)。
UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」)
2. 调 GetUserDataApi 取 playCount / playerRating
3. 按 TicketManager.cs:564 算 validDate(现在 -4h +90d,取日期补 04:00:00)
4. 查 TICKET_CREDIT_NUM(已下沉到 api/UpsertUserChargelog.py)得到
userChargelog.price(= ticket.creditNum)
5. 闸门 → 发包
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python buy_ticket.py <二维码> # 预检:只看报文,不发包
python buy_ticket.py <二维码> --list # 登录后只列当前票券,不购买
python buy_ticket.py <二维码> --charge-id 3 # 指定票(默认 5 = 5倍券)
python buy_ticket.py <二维码> --charge-id 2 --send # ★ 真实购买
python buy_ticket.py <二维码> --charge-id 2 --send --debug
python buy_ticket.py <二维码> --charge-id 2 --send --skip-entry # 跳过入场数据下载
付费票(api.PAID_TICKET_IDS):
2 = 2倍チケット creditNum 1
3 = 3倍チケット creditNum 2
4 = 4倍チケット creditNum 3
5 = 5倍チケット creditNum 4
6 = 6倍チケット creditNum 5
★ 真实购买会写入一条购买记录(不可撤销)。不传 --send 时只构造报文。
★ 二维码有**时效性**(**有效期 10 分钟**),过期后 aimedb 返回 errorID=2
—— **不是按次数计量的**,重新生成即可。
★ 购买前会先查一次功能票;**账号已持有功能票时直接拒绝购买**(重复购买有封号风险)。
"""
from __future__ import annotations
import sys
import time
from api import (
setup_logging,
teardown_logging,
log_error,
log_info,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
GetUserCharge,
ticket_name,
GetNeedUserData,
summarize_snapshot,
Ping,
)
from config import PLACE_ID, REGION_ID
def show_charges(user_id, label):
"""
★ 调 GetUserChargeApi 打印当前票券状态,并返回 stock > 0 的那些。
★★ 为什么跑前跑后都要查:
真机只在「本局要用这张票」时才购买,买到手会立刻被用掉(stock--),
所以正常状态 stock 恒为 0。
**已经持票还去买,服务端看到的是重复购买,有封号风险。**
所以买之前必须确认没有持票;买之后要确认状态符合预期。
"""
raw = GetUserCharge(user_id)
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
if not lst:
log_info(" (空)")
for c in lst:
cid = c.get("chargeId")
stock = int(c.get("stock", 0) or 0)
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
+ " stock=" + str(stock)
+ " purchaseDate=" + str(c.get("purchaseDate"))
+ " validDate=" + str(c.get("validDate"))
+ (" ← ★ 持有中" if stock > 0 else ""))
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
return held
def parse_args(argv):
opts = {"qr": "", "charge_id": 5, "send": False, "list": False,
"debug": False, "skip_entry": False, "wait_before": 15.0}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--list":
opts["list"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--skip-entry":
opts["skip_entry"] = True
elif a == "--wait-before" and i + 1 < len(argv):
opts["wait_before"] = float(argv[i + 1])
i += 1
elif a == "--charge-id" and i + 1 < len(argv):
opts["charge_id"] = int(argv[i + 1])
i += 1
elif not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["qr"]:
print(__doc__)
return 1
if opts["debug"]:
import config as _cfg
_cfg.LOG_LEVEL = "debug"
setup_logging("buy_ticket")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---- ★ 心跳:决定后续所有请求的 number 头 ----
# 真机序列里 Ping 在 [11],紧接着才是 GetUserPreviewApi [12]。
# 不跑的话 number 恒为 "0"(真机是 75)。
Ping()
user_id, token = ParseQRCode(opts["qr"])
session.update(user_id=user_id, token=token)
preview = GetUserPreview(user_id, token)
if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ---- ★ 复刻真机的「入场」序列:登录后先把用户数据全量拉一遍 ----
# 真机 13:19:45 登录 -> 13:19:46~13:19:51 拉 47 个节点
# -> 13:20:10 才发 UpsertUserChargelogApi
# 本脚本原来 login 后直接买,跳过了这一段。
if not opts["skip_entry"]:
snap = GetNeedUserData(user_id, token=token,
place_id=PLACE_ID, region_id=REGION_ID)
_sum = summarize_snapshot(snap)
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
+ str(len(snap.get("_failed") or [])) + " 个")
# ---- ★★ 跑前:必须确认账号当前**没有**持有功能票 ----
held_before = show_charges(user_id, "购买前")
if opts["list"]:
log_info("[--list] 只查询票券状态,不购买。")
return 0
aborted = False
if held_before:
log_error("账号当前已持有功能票,**拒绝购买**(重复购买有封号风险):")
for c in held_before:
log_error(" chargeId=" + str(c.get("chargeId"))
+ " " + ticket_name(c.get("chargeId"))
+ " stock=" + str(c.get("stock"))
+ " validDate=" + str(c.get("validDate")))
log_error("请先在机台把票用掉,或等它过期后再来。")
aborted = True
else:
# ---- ★ 买票前等待(贴近真机节奏:真机 login 13:19:45 -> 买票 13:20:10,间隔 25s)----
# 默认 15 秒(与 UpsertUserAll 的发包冷却一致)
if opts["wait_before"] > 0:
log_info("买票前等待 " + str(opts["wait_before"]) + " 秒 ...")
time.sleep(opts["wait_before"])
log_info("等待结束,开始购买")
# ---- 购买 ----
# ★ 一个函数搞定:取当前票券/档案 -> 安全检查 -> 算 validDate/price -> 闸门 -> 发包
UpsertUserChargelog(
user_id, opts["charge_id"], login.loginDateTime,
dry_run=not opts["send"],
confirm=opts["send"],
guard_no_stock=True,
guard_purchasable=False, # ★ 不再按 GetGameCharge 上架清单拦截
place_id=PLACE_ID, region_id=REGION_ID,
)
# ---- ★★ 跑后:无论有没有真发包,都再查一次 ----
# (中止时也查,确认状态确实没被动过)
held_after = show_charges(user_id, "购买后")
if aborted:
log_info("已按安全检查中止,**未发包**;上面是中止后的复查结果")
if len(held_after) == len(held_before):
log_info("复查确认:票券状态未发生变化 ✅")
else:
log_warning("复查发现票券数量变了 —— 请人工确认")
return 3
if not opts["send"]:
log_info("[dry-run] 未发包。想真实购买请加 --send(★ 会写入购买记录)")
return 0
if held_after:
log_info("购买后持有 " + str(len(held_after)) + " 张票(购买成功)")
else:
log_warning("购买后没有查到持有中的票 —— 服务端可能没落库,"
"或票被立刻消耗了")
return 0
except ChargelogGuardError:
return 3
except SystemExit:
raise
except ChargelogConfirmError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
+405
View File
@@ -0,0 +1,405 @@
# -*- coding: utf-8 -*-
"""
config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项
================================================================================
★★ 这是**模板文件**。第一次使用请先复制一份:
cp config.py.example config.py # Linux / macOS
copy config.py.example config.py # Windows
然后**必须**填好第二节「机台与店铺标识」的 6 个值,否则脚本发出去的请求
带的是别人的机台身份,服务端会拒绝。
★ config.py 已在 .gitignore 里 —— 它属于你的私有配置,不要提交。
================================================================================
本文件集中存放**用户可能需要修改**的内容,其余协议常量写死在各自脚本内。
分六块:
1. 网络与加密常量(AES 密钥、协议版本、服务器地址)
2. 机台与店铺标识(Keychip、区域、店铺)
3. 扫码登录常量(aimedb)
4. ★ UpsertUserAll 即将上传的歌曲数据(默认取 source/2026-10-05.log 真机样本)
5. UpsertUserAll 安全闸门
6. 日志级别
权威来源全部标注在注释里,改之前请先看依据。
"""
# =============================================================================
# 一、网络与加密常量
# =============================================================================
# ★ BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM 已下沉到 api/MaimaiNet.py
# --- 报文版本标识 ---
# 依据:source/Assembly-CSharp/Net/NetHttpClient.cs:69 DefaultHttpHeaderEncryption = "1.55"
# (请求头 Mai-Encoding 的值;1.56 ROM 实测仍发 1.55)
MAI_ENCODING = "1.55"
# --- 超时(毫秒)---
# 依据:source/Assembly-CSharp/Net/NetConfig.cs:7-11
TIMEOUT_MS = 10000 # 普通接口
TIMEOUT_LONG_MS = 60000 # 登录 / 结算上传
# =============================================================================
# 二、机台与店铺标识
# =============================================================================
# 客户端 ID(Keychip 短值)
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
# clientId = AMDaemon.System.KeychipId.ShortValue
# 实测:真机日志里 clientId 恒等于 Keychip 短值
# ★ 形态 = 15 位 Keychip 去掉连字符后的**前 11 位**(大写十六进制)。
CLIENT_ID = "" # ★★ 必填:形如 "A63E01C3047"
# 区域 ID / 店铺 ID
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:24-25
# regionId = Auth.RegionCode ; placeId = Auth.LocationId
# 实测:真机日志里的 regionId / placeId 与机台所在地一致
REGION_ID = 0 # ★★ 必填:你所在区域 ID
PLACE_ID = 0 # ★★ 必填:你所在店铺 ID
# Keychip ID(机台唯一标识,完整格式带连字符)
# ★★ 这是你自己的机台身份,不要外传;填错服务端会拒。
# 依据:source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
# KeychipID = AMDaemon.System.KeychipId.Value
# ★ 组成 = CLIENT_ID 的前 4 位 + "-" + CLIENT_ID 第 5 位起 + PLACE_ID
# 例:CLIENT_ID "A63E01C3047" + PLACE_ID 1007 -> "A63E-01C30471007"
# 所以它由上面两个值**推导**,不要手改。
KEYCHIP_ID = (CLIENT_ID[:4] + "-" + CLIENT_ID[4:] + str(PLACE_ID)) if CLIENT_ID else ""
# 店铺名 / 区域名
#
# ★ 为什么以前必须手工填:**游戏服务器没有任何接口返回店铺名**。
# 逐行扫过 source/2026-10-05.log 全部响应:
# - GetGameSettingApi 只回 gameSetting,无店名
# - GetUserDataApi 的 lastPlaceName 回的是 null
# - GetUserRegionApi 只回 regionId/playCount/created
# 店铺名来自机台本地 AMDaemon:
# source/AMDaemon.NET/AMDaemon/Allnet/Auth.cs:33 LocationName <- allnet_Auth_getLocationName()
# 是开机时 ALL.Net 认证下发的,客户端本地就有,不会向游戏服务器索取。
#
# ★★ 现在不用手工抄了:python authv1.py <你的Keychip> 会直接问 ALL.Net
# 认证服务器要回 name / region_name0。把下面的 AUTHV1_WRITE_CONFIG 打开,
# 它还能把结果自动写回 config.py。
PLACE_NAME = "" # ★★ 必填:店铺名
REGION_NAME = "" # ★★ 必填:区域名
# --- authv1 回写开关 ---
#
# authv1.py(Keychip -> 机台身份)查到结果后,是否**自动写回 config.py**。
#
# True : 每次查询成功后,把 CLIENT_ID / REGION_ID / PLACE_ID /
# PLACE_NAME / REGION_NAME 五个值改写进 config.py。
# (KEYCHIP_ID 是推导值,会自动跟着变,不用写。)
# 改写前会把原文件备份成 config.py.bak,写坏不落盘。
# False : 只打印结果,不动 config.py。(默认,安全)
#
# 命令行可临时覆盖:--write 强制写 / --no-write 强制不写。
AUTHV1_WRITE_CONFIG = False
# =============================================================================
# 三、扫码登录常量(aimedb)
# =============================================================================
#
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
# + docs/舞萌DX_扫码登录协议技术规范.md
# ★ aimedb / QR 常量已下沉到 api/Chime.py
# =============================================================================
# 三之二、★ 功能票(Ticket)主数据
# =============================================================================
#
# 依据:游戏本地主数据
# D:\SDGB2026\Package\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
# 源码侧 Manager.MaiStudio.TicketData.creditNum
#
# ★ 为什么脚本需要它:
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
# result.price = ticket.creditNum;
#
# ticketKind 含义(Ticket.xml 里的枚举):
# 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
# ★ TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
# TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS
# 已下沉到 api/UpsertUserChargelog.py
#: 买票闸门(和 UpsertUserAll 一样,默认不放行)
UPSERT_CHARGELOG_CONFIRM = False
# =============================================================================
# 四、★ UpsertUserAll 即将上传的歌曲数据
# =============================================================================
#
# ★ 为什么放在 config:
# 一局游玩的成绩是**唯一无法由脚本自行产生**的输入 ——
# 纯网络脚本复刻不出游玩过程,服务端要的就是这份原始成绩。
# 换歌 / 换成绩只改这里,不用动任何 api 脚本。
#
# ★ 默认值 = source/2026-10-05.log:123 的**真机跑通报文**(UpsertUserAllApi 请求)
# 即 userPlaylogList[0] 逐字段照抄。
#
# ★ 两个"歌曲数据"的区别(别混):
# userPlaylogList 本局**原始判定输入** —— 服务端据此算分、写战绩表
# userMusicDetailList 按 (musicId, level) 的**历史归档** —— 本局那条 playCount +1
# 下面 PLAY_* 同时喂给这两处,保证一致。
# --- 曲目与难度 ---
PLAY_MUSIC_ID = 143 # 曲目 ID
PLAY_MUSIC_LEVEL = 4 # 0=Basic 1=Advanced 2=Expert 3=Master 4=Re:Master
PLAY_TRACK_NO = 1 # 本局第几首
# --- 版本号(同一版本两种写法,两处都要)---
PLAY_VERSION_INT = 1056000 # 整数形式,用于 userPlaylogList[].version(1.56.00)
PLAY_VERSION_STR = "1.56.00" # 字符串形式,用于 userGamePlaylogList[].version
# --- 成绩 ---
PLAY_ACHIEVEMENT = 0 # 达成率 ×10000(0 = 本局失败)
PLAY_DELUXSCORE = 0 # DX 分数
PLAY_SCORE_RANK = 0 # 评级档(0 = 无)
PLAY_IS_CLEAR = False # 是否 Clear
PLAY_MAX_COMBO = 0
PLAY_TOTAL_COMBO = 655 # 该谱面 note 总数(= 各类型判定数之和)
PLAY_MAX_SYNC = 0
PLAY_TOTAL_SYNC = 0
PLAY_COMBO_STATUS = 0 # 0=无 1=全连
PLAY_SYNC_STATUS = 0
PLAY_FAST_COUNT = 0
PLAY_LATE_COUNT = 0
# --- 判定明细 {类型: {判定位: 次数}} ---
# 真机样本是一局**失败**的成绩(全 Miss)
PLAY_JUDGE_COUNTS = {
"tap": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 441},
"hold": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 19},
"slide": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 192},
"touch": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 0},
"break": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 3},
}
# --- 评级(本局前后)---
# ★ PLAY_BEFORE/AFTER_RATING 等已改为运行时从服务器快照取(derive_play_rating)
# --- 曲种索引 ---
# ★ 必须与 userExtend.categoryIndex 一致(真机两处都是 2022)
PLAY_CATEGORY_INDEX = 2022
PLAY_MUSIC_INDEX = 10 # userExtend.musicIndex
PLAY_EXTRA_FLAG = 1721 # userExtend.extraFlag
# --- 本局使用的角色快照(characterId1..5 / Level / Awakening)---
#
# ★ 语义:**进入游玩时**的快照值,不要加经验 —— 这一点极易搞错。
# (结算后的等级在 upsertUserAll.userCharacterList 里,那处才 +经验)
# 真机实证(source/2026-10-05.log:123 同一份报文):
# userPlaylogList[0].characterLevel1..5 = 3800/4455/134/134/134 ← 快照(游玩前)
# upsertUserAll.userCharacterList = 3801/4456/135/135/135 ← 结算后
#
# ★★ 通常**不用改这里**:main.py 会调用 derive_play_characters(snapshot),
# 直接从服务器刚下发的 userData.charaSlot + userCharacterList 推出游玩前等级,
# 并覆盖本配置。只有拿不到快照(离线组包)时才回退到这里。
#
# 默认值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
# --- 本局消耗 ---
# ★ playCredit 语义(VOExtensions.cs:838-845):
# playCredit = Credit.GameCostEnoughPlay()(投币成本) + TicketManager.GetTicketCredit(useTicketId)
# 即"这一局总共折合多少 credit",不是余额、不是曲目数。真机样本 = 1。
PLAY_CREDIT = 1
# ★ -1 = 不使用票。依据 TicketManager.cs:47 初始化 _useTicketId[i] = -1;
# GetTicketCredit(-1) 里 GetTicket(-1) 返回 null -> 返回 0,即不用票不折算 credit。
PLAY_USE_TICKET_ID = -1
PLAY_MODE = 0 # 0=通常
PLAY_IS_TUTORIAL = False
PLAY_IS_EVENT_MODE = False
PLAY_IS_NEW_FREE = False
PLAY_IS_FREEDOM_MODE = False
# --- 机台本地计数器(★ 无法从服务器推导,只能手填)---
#
# 依据:source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:881
# result.playCount = Backup.localParameter.playCount
# 这是机台**本地**的游玩计数器,任何 Get 接口都不会返回它,
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。
# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/UpsertUserAll.py)
# --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
#
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:846-855
#
# playSpecial = 位反转32( Random.Range(1,1037933) * 2069 + CalcSpecialNum() )
#
# CalcSpecialNum() 是**按位或**出来的机台特征掩码,逐位含义(源码原文):
# 0x001 SpecialAP (测试/特殊 AP)
# 0x002 IsAutoPlay()
# 0x004 Win32.getRawInputDeviceCount 的 keyboard > 0
# 0x008 同上 mouse > 0
# 0x010 config.IsAutoPlay / IsDebugDisp / IsAllOpen
# 0x020 AMDaemon.System.IsDevelop
# 0x040 !SystemInfo.deviceName.Contains("ACA") ← 真机是 ACA,故该位为 0
# 0x080 !graphicsDeviceName.Contains("GeForce GTX") ← 真机是 GTX,故该位为 0
# 0x100 !processorType.Contains("Core(TM) i3-8100") ← 真机是 i3-8100,故该位为 0
# 0x400 无条件置位(源码 GameManager.cs:375) ← ★ 恒为 1
#
# ★ 真机实测(gamehook 抓到 CalcSpecialNum 调用):
# [CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
# 1024 = 0x400,即除"无条件置位"那一位外全部为 0 —— 正好是一台标准 ACA 机台。
#
# ★ 反推验证:真机 playSpecial = 892014272
# 位反转32(892014272) = 57726124 = 27900 * 2069 + 1024
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
#
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/UpsertUserAll.py
# --- 用户总游玩次数增量 ---
#
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
# 想复刻真机的「+1」就改成 1。
#
# 为什么默认改成 0:
# playCount 是账号的**总游玩次数**,是玩家可见的统计值;
# 本工程做「成绩上传」时不需要改动它,保持与上传前一致更安全。
# ★ 改成 0 之后的副作用:「落库回查」不能再靠 playCount 是否 +1 判断,
# 会自动改用 lastPlayDate 是否更新来判断(见 verify_upsert_landing)。
PLAY_USER_PLAY_COUNT_GAIN = 0
# --- 曲目历史游玩次数增量 ---
#
# ★ 默认 0 = 本局**不改动**该曲目的历史游玩次数。
# ★★ 注意语义:是「条目照传、次数不变」,**不是**「不回传这个节点」。
# 实测(同一份代码、同一账号,唯一差别就是这个节点):
# userMusicDetailList 回传 1 条 -> returnCode=1 且 playCount 真的 +1(落库)
# userMusicDetailList 留空 -> returnCode=1 但 playCount 纹丝不动(**没落库**)
# 所以「不想让乐曲 playCount 增加」必须靠把**增量**设成 0,
# 而不是把整个节点清空 —— 后者会让整包被服务端静默丢弃。
#
# 想复刻真机的「该谱面 playCount +1」就改成 1。
PLAY_MUSIC_PLAY_COUNT_GAIN = 0
# --- 地图进度增量 ---
#
# ★ 默认 0 = **本局不推进地图**,userMapList 回传空数组。
# 想复刻真机的"地图 +11000"就改成 11000(真机样本:mapId=550003 的
# distance 1424000 -> 1435000)。
#
# 依据:source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs 的地图导出逻辑
# —— 没变化的条目不会进差分结果。
PLAY_MAP_DISTANCE_GAIN = 0
# --- 组队角色等级增量 ---
#
# ★ 默认 0 = **本局不给角色加经验**,userCharacterList 回传空数组。
# 想复刻真机的"组队 5 只各 +1 级"就改成 1(真机样本 4455->4456 等)。
#
# ★ 注意:设成 0 时 userCharacterList 差集为空 -> 该节点不回传,
# 这是**预期的**,不是错误;validate_before_send 也只在你把增量设成非 0
# 却又没产出条目时才告警。
PLAY_CHARACTER_EXP_GAIN = 0
# --- ★ 关于 userGetPointList / userLoginBonusList(留空即可)---
#
# 这两个节点是**登录奖励发放**才产生的,普通游玩结算不需要回传:
#
# userGetPointList(里程/点数明细)
# 真机 39 次 UpsertUserAll 样本里,**25 次为空(64%)**;
# 只有发生登录奖励/任务奖励时才非空(如 getKind=1 point=200)。
# 留空 => 服务端不累加 point,账户点数保持不变。
#
# userLoginBonusList(签到奖励)
# 同理,只有本次登录触发新签到才回传那一条(isCurrent=true)。
# 留空 => isNewLoginBonusList 也是空串。
#
# ★ 连带影响(正常现象):留空后 userData.point 保持基线值不变。
#
# 源码参考(如需还原发放行为):
# Manager/UserData.cs:57-62 enum MaiMileGetKind { Mission=1, FriendBonus=2, Present=3 }
# Manager/UserData.cs:352-367 AddMissionPointLog / AddFriendBonusPointLog
# --- 店铺库存查询清单(不填则跳过该节点)---
PLAY_SHOP_ITEM_IDS = []
# =============================================================================
# 五、UpsertUserAll 安全闸门
# =============================================================================
#
# ★ 这是**唯一有副作用**的接口:会把本局成绩真实写进服务器,不可撤销。
# 保持 False(默认),调用时必须显式传 confirm=True 才真正发包。
# 改成 True 会永久放行(不建议)。
UPSERT_CONFIRM = False
# 真实发包前的冷却等待(秒)
#
# ★ 语义:**每次**真实发送 UpsertUserAll 之前,都固定等待这么多秒(含首次)。
# 目的:让上报节奏贴近真机 —— 真机从登录到结算上报约 80 秒,
# 不会登录后立刻上报(source/2026-10-05.log:25 登录 → :123 上报,间隔 81 秒)。
#
# ★ 只作用于**真实发送**(confirm=True);dry_run 不等待。
# ★ 冷却发生在「发包前自检」之后,所以自检有问题时会先看到告警,不必白等。
# ★ 调试时想跳过等待:把这里改成 0,或调用时传 cooldown_sec=0。
UPSERT_SEND_COOLDOWN_SEC = 60
# =============================================================================
# 六、日志级别
# =============================================================================
#
# "info" 只输出 api 脚本的运行情况与结果:
# === XxxApi 开始运行 ===
# === XxxApi 运行结束 ===
#
# "debug" 在上面基础上,额外输出向 api 发送的数据与 api 返回的数据:
# → 请求 XxxApi URL / 请求头 / 请求体 JSON 全文
# ← 响应 XxxApi 状态码 / 耗时 / 响应头 / 响应体 JSON 全文
#
LOG_LEVEL = "info"
# 单条日志里内嵌长文本的截断长度(0 = 不截断)
LOG_VALUE_MAX = 0
# =============================================================================
# 导出
# =============================================================================
__all__ = [
# 网络与加密
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM", "MAI_ENCODING",
"TIMEOUT_MS", "TIMEOUT_LONG_MS",
# 机台与店铺
"KEYCHIP_ID", "CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME",
"AUTHV1_WRITE_CONFIG",
# 功能票
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
"UPSERT_CHARGELOG_CONFIRM", "TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
# 扫码登录
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH", "QR_SIGN_KEY",
"QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
# 歌曲数据
"PLAY_MUSIC_ID", "PLAY_MUSIC_LEVEL", "PLAY_TRACK_NO",
"PLAY_VERSION_INT", "PLAY_VERSION_STR",
"PLAY_ACHIEVEMENT", "PLAY_DELUXSCORE", "PLAY_SCORE_RANK", "PLAY_IS_CLEAR",
"PLAY_MAX_COMBO", "PLAY_TOTAL_COMBO", "PLAY_MAX_SYNC", "PLAY_TOTAL_SYNC",
"PLAY_COMBO_STATUS", "PLAY_SYNC_STATUS", "PLAY_FAST_COUNT", "PLAY_LATE_COUNT",
"PLAY_JUDGE_COUNTS",
"PLAY_BEFORE_RATING", "PLAY_AFTER_RATING",
"PLAY_BEFORE_GRADE", "PLAY_AFTER_GRADE", "PLAY_AFTER_GRADE_RANK",
"PLAY_BEFORE_DELUX_RATING", "PLAY_AFTER_DELUX_RATING",
"PLAY_CATEGORY_INDEX", "PLAY_MUSIC_INDEX", "PLAY_EXTRA_FLAG",
"PLAY_CHARACTERS", "PLAY_CREDIT", "PLAY_USE_TICKET_ID", "PLAY_MODE",
"PLAY_IS_TUTORIAL", "PLAY_IS_EVENT_MODE", "PLAY_IS_NEW_FREE", "PLAY_IS_FREEDOM_MODE",
"PLAY_LOCAL_PLAY_COUNT", "PLAY_SPECIAL_NUM",
"PLAY_USER_PLAY_COUNT_GAIN",
"PLAY_MUSIC_PLAY_COUNT_GAIN", "PLAY_MAP_DISTANCE_GAIN", "PLAY_CHARACTER_EXP_GAIN",
"PLAY_SHOP_ITEM_IDS",
# 闸门
"UPSERT_CONFIRM", "UPSERT_SEND_COOLDOWN_SEC",
# 日志
"LOG_LEVEL", "LOG_VALUE_MAX",
]
+703
View File
@@ -0,0 +1,703 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
netdelivery.py —— ALL.Net DLI(Download Instruction):查 / 下 .app 与 .opt 更新包
================================================================================
【DLI 是什么】
DLI = **Download Instruction**(配信指示書)。
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」:
POST http://at.sys-allnet.cn/net/delivery/instruction
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
服务器回:
result=1&uri=<patch 指示書 URL>|<option 指示書 URL>
指示書本身是个 INI(日文注释),真正要下的包写在 INSTALLn= 里:
[COMMON] INSTALL1=https://.../SDGB_1.56.00_..._1_1.55.01.app
[OPTIONAL] INSTALL1=https://.../SDGB_A031_..._0.opt
INSTALL2=https://.../SDGB_A005_..._0.opt
★ 只有 INSTALLn= 才是**要下载的包**;EXISTn= 是「本机应该已经有的文件」,
只是文件名,不是下载项。
【协议出处】—— source/amdaemon.exe 字符串(数字是文件内偏移)
端点 http://at.sys-all.cn/net/delivery/instruction @0x788198
库 alw_lite/src/system39/alw_api_netdeliver.c @0x63bf80
阶段 ALWE_NETDELIVER_PHASE_GET_DLINST_INFO @0x63bde0
ALWE_NETDELIVER_PHASE_APP_IMAGE_INFO @0x63be08
ALWE_NETDELIVER_PHASE_OPT_IMAGE_INFO @0x63be30
日志 "Net deliver param - dli uri : %s." @0x63c3f8
状态 ALE_ABEX_STAT_DLINSTR_OK / _ERR_DLINSTR_NG ... @0x638600
【实测结论】(2026-10-06)
title_ver=1.55 -> uri = patch(SDGB_patch_1.56...info) | option(SDGB_option_1.55...A005.info)
title_ver=1.56 -> uri = | option(A031) (patch 槽为空 = 本体已最新)
title_ver<=1.54 -> uri = null (服务器不认这个版本)
指示書里的 INSTALL 直链可以带 Range 断点续传(返回 206 + Content-Range)。
★ 已被下架的历史包,源站会回 504 —— 这是服务器状态,不是脚本的问题。
【用法】
python netdelivery.py # 用 config.py 里的 Keychip 查,然后**多选下载**
python netdelivery.py A63E01E9580 # 指定 Keychip
python netdelivery.py --list # 只列清单,不下载
python netdelivery.py --all # 全下(不交互)
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
python netdelivery.py --out D:\SDGB2026\update
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
★ 交互选择支持:逗号/空格分隔、区间(如 1-3)、a=全选、n=全不选、q=退出。
"""
from __future__ import annotations
import argparse
import os
import re
import sys
import time
import requests
import urllib3
from authv1 import (
DEFAULT_HOST,
DEFAULT_TIMEOUT,
AllnetError,
delivery_instruction,
parse_keychip,
)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 下载器用的 UA(实测有效)
DOWNLOAD_HEADERS = {"User-Agent": "SDGB;Windows/Lite"}
DEFAULT_OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "download")
CHUNK = 1 << 16
PROBE_TIMEOUT = 30
DOWNLOAD_TIMEOUT = 60
# 包类型判定(按扩展名)
KIND_BY_EXT = {
".app": "app",
".opt": "opt",
".pack": "pack",
}
KIND_TEXT = {
"app": "本体/补丁",
"opt": "选项包",
"pack": "资源包",
"?": "未知",
}
# =============================================================================
# HTTP
# =============================================================================
def http_get(url, *, headers=None, stream=False, timeout=PROBE_TIMEOUT, insecure=False):
"""
GET,顺带处理 CDN 证书问题。
先按正常校验发;如果因为证书失败,再自动退回 verify=False 重发一次
(只影响这一次请求,不改变全局设置)。
"""
merged = dict(DOWNLOAD_HEADERS)
if headers:
merged.update(headers)
try:
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
verify=not insecure)
except requests.exceptions.SSLError:
if insecure:
raise
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
verify=False)
# =============================================================================
# 配信指示書解析
# =============================================================================
def parse_instruction(text):
"""
解析配信指示書 INI。
返回 (common, installs):
common = {GAME_DESC: ..., DOWNLOAD_ID: ..., ...}([COMMON] 段)
installs = [{"section": "COMMON"/"OPTIONAL", "key": "INSTALL1", "url": ...}, ...]
"""
common = {}
installs = []
section = ""
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith(";") or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1].strip().upper()
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip().upper()
value = value.strip().strip('"')
if section in ("", "COMMON"):
common.setdefault(key, value)
if key.startswith("INSTALL") and value:
installs.append({"section": section or "COMMON", "key": key, "url": value})
return common, installs
def guess_kind(url):
"""按扩展名猜包类型。"""
name = url.split("?")[0].rsplit("/", 1)[-1]
ext = os.path.splitext(name)[1].lower()
return KIND_BY_EXT.get(ext, "?")
def package_name(url):
return url.split("?")[0].rsplit("/", 1)[-1]
def human_size(num):
if num is None:
return "?"
value = float(num)
for unit in ("B", "KB", "MB", "GB", "TB"):
if value < 1024 or unit == "TB":
return ("%d B" % int(value)) if unit == "B" else ("%.2f %s" % (value, unit))
value /= 1024.0
return "?"
# =============================================================================
# 采集:DLI -> 指示書 -> 包清单
# =============================================================================
def fetch_instruction_urls(client_id, *, title_ver, host, timeout):
"""问 DLI,返回 (patch_info_url, option_info_url, raw_text)。"""
text = delivery_instruction(client_id, title_ver=title_ver, host=host, timeout=timeout)
kv = {}
for part in text.strip().split("&"):
if "=" in part:
k, v = part.split("=", 1)
kv[k.strip()] = v
result = kv.get("result", "")
if result != "1":
raise AllnetError("DLI 返回 result=%s(原始:%s)" % (result, text.strip()[:200]))
uri = kv.get("uri", "").strip()
if uri in ("", "null"):
raise AllnetError("DLI 返回 uri=%s:这个 title_ver 没有配信指示書"
"(换 --ver 试试,例如 1.55)" % (uri or "(空)"))
parts = uri.split("|")
patch_url = parts[0].strip() if len(parts) > 0 else ""
option_url = parts[1].strip() if len(parts) > 1 else ""
for bad in ("null", "none"):
if patch_url.lower() == bad:
patch_url = ""
if option_url.lower() == bad:
option_url = ""
return patch_url, option_url, text.strip()
def collect_packages(client_id, *, title_ver, host, timeout, insecure=False):
"""
走完整条链路,返回 (items, meta)。
items 每一项:
{"idx":1, "kind":"app", "section":"COMMON", "key":"INSTALL1",
"url":..., "name":..., "size":int|None, "note":"", "info_name":...}
"""
patch_url, option_url, dli_raw = fetch_instruction_urls(
client_id, title_ver=title_ver, host=host, timeout=timeout)
items = []
meta = {"dli_raw": dli_raw, "title_ver": title_ver, "client_id": client_id,
"instructions": [], "infos": {}}
for info_url in (patch_url, option_url):
if not info_url:
continue
info_name = package_name(info_url)
resp = None
for attempt in range(1, 4):
try:
resp = http_get(info_url, timeout=timeout, insecure=insecure)
if resp.status_code < 500:
break
except Exception:
resp = None
time.sleep(1.5 * attempt)
if resp is None:
meta["instructions"].append({"url": info_url,
"error": "拉取失败(已重试 3 次)"})
continue
if resp.status_code != 200:
meta["instructions"].append({"url": info_url, "status": resp.status_code,
"error": "HTTP %d" % resp.status_code})
continue
text = resp.content.decode("utf-8", "replace")
common, installs = parse_instruction(text)
meta["instructions"].append({"url": info_url, "status": 200,
"name": info_name, "common": common})
meta["infos"][info_name] = text
seen = set()
for entry in installs:
if entry["url"] in seen:
continue
seen.add(entry["url"])
items.append({
"kind": guess_kind(entry["url"]),
"section": entry["section"],
"key": entry["key"],
"url": entry["url"],
"name": package_name(entry["url"]),
"size": None,
"note": "",
"info_name": info_name,
})
for i, item in enumerate(items, 1):
item["idx"] = i
return items, meta
def probe_all(items, *, timeout=PROBE_TIMEOUT, insecure=False):
"""探每个包的大小 / 可用性(就地写回 item)。"""
for item in items:
for attempt in range(1, 3):
try:
resp = http_get(item["url"], headers={"Range": "bytes=0-0"},
stream=True, timeout=timeout, insecure=insecure)
status = resp.status_code
content_range = resp.headers.get("Content-Range", "")
content_length = resp.headers.get("Content-Length")
resp.close()
if status == 206 and "/" in content_range:
item["size"] = int(content_range.rsplit("/", 1)[-1])
item["note"] = ""
elif status == 200 and content_length and content_length.isdigit():
item["size"] = int(content_length)
item["note"] = ""
elif status in (404, 504):
item["note"] = "HTTP %d(源站不可用 / 该包已下架)" % status
else:
item["note"] = "HTTP %d" % status
break
except Exception as exc:
item["note"] = "探测失败:%s" % exc
if attempt < 2:
time.sleep(1.5)
continue
# =============================================================================
# 展示与选择
# =============================================================================
def print_packages(items):
if not items:
print(" (指示書里没有任何 INSTALL 项)")
return
print("")
print(" 序号 类型 大小 来源段/键 文件名")
print(" " + "-" * 92)
for item in items:
size = human_size(item["size"]) if item["size"] is not None else (item["note"] or "?")
print(" [%2d] %-4s %-12s %-20s %s" % (
item["idx"], item["kind"], size,
item["section"] + "/" + item["key"], item["name"]))
if item["note"] and item["size"] is None:
print(" %s" % item["note"])
def prompt_selection(items):
"""
交互多选。返回选中的 0 基下标列表;用户 q 退出时返回 None。
"""
print("")
print(" 可多选:输入序号,用逗号或空格分隔;支持区间(如 1-3)。")
print(" a = 全选 n = 都不选 q = 退出")
while True:
try:
raw = input(" 请选择要下载的包 > ").strip()
except (EOFError, KeyboardInterrupt):
print("")
return None
if not raw:
continue
low = raw.lower()
if low in ("q", "quit", "exit"):
return None
if low in ("a", "all"):
return list(range(len(items)))
if low in ("n", "none"):
return []
picked, bad = [], []
for token in re.split(r"[,,、\s]+", raw):
if not token:
continue
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
if span:
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
for i in range(low_i, high_i + 1):
if 1 <= i <= len(items):
picked.append(i - 1)
else:
bad.append(str(i))
continue
if token.isdigit():
i = int(token)
if 1 <= i <= len(items):
picked.append(i - 1)
else:
bad.append(token)
else:
bad.append(token)
if bad:
print(" [x] 无效输入:%s(范围是 1-%d)" % (", ".join(bad), len(items)))
continue
ordered, seen = [], set()
for i in picked:
if i not in seen:
seen.add(i)
ordered.append(i)
if not ordered:
print(" [x] 没有选中任何项")
continue
return ordered
# =============================================================================
# 下载
# =============================================================================
def download_file(url, dest, *, timeout=DOWNLOAD_TIMEOUT, retries=3, insecure=False):
"""
带断点续传的下载。返回 (ok, 说明)。
★ 先写 dest + ".part",下完再改名 —— 中断了下次能接着下。
"""
part = dest + ".part"
total = None
for attempt in range(1, retries + 1):
done = os.path.getsize(part) if os.path.exists(part) else 0
headers = {}
if done:
headers["Range"] = "bytes=%d-" % done
try:
resp = http_get(url, headers=headers, stream=True, timeout=timeout,
insecure=insecure)
except Exception as exc:
if attempt == retries:
return False, "连接失败:%s" % exc
time.sleep(2)
continue
if resp.status_code not in (200, 206):
resp.close()
if resp.status_code in (404, 504):
return False, "HTTP %d(源站不可用 / 该包已下架)" % resp.status_code
if attempt == retries:
return False, "HTTP %d" % resp.status_code
time.sleep(2)
continue
# 服务器忽略 Range 直接给了全量 -> 从头写
if done and resp.status_code == 200:
done = 0
content_range = resp.headers.get("Content-Range", "")
if "/" in content_range:
try:
total = int(content_range.rsplit("/", 1)[-1])
except ValueError:
total = None
elif resp.headers.get("Content-Length", "").isdigit():
total = done + int(resp.headers["Content-Length"])
mode = "ab" if done else "wb"
started = time.time()
written = done
last_tick = 0.0
last_shown = -1
try:
with open(part, mode) as fp:
for chunk in resp.iter_content(CHUNK):
if not chunk:
continue
fp.write(chunk)
written += len(chunk)
now = time.time()
if now - last_tick >= 0.3:
last_tick = now
last_shown = written
show_progress(written, total, now - started)
except Exception as exc:
resp.close()
if attempt == retries:
return False, "写入中断:%s" % exc
time.sleep(2)
continue
finally:
resp.close()
if written == last_shown:
sys.stdout.write("\n") # 收尾换行,避免重复打一行
else:
show_progress(written, total, max(time.time() - started, 0.001), final=True)
if total is not None and written != total:
if attempt == retries:
return False, "长度不符:期望 %d 字节,实际 %d 字节" % (total, written)
time.sleep(2)
continue
if os.path.exists(dest):
os.remove(dest)
os.replace(part, dest)
return True, "%s(%s)" % (human_size(written), dest)
return False, "重试 %d 次仍失败" % retries
def show_progress(done, total, elapsed, final=False):
speed = done / elapsed if elapsed > 0 else 0
if total:
percent = done * 100.0 / total
line = " %6.2f%% %s / %s %s/s" % (percent, human_size(done),
human_size(total), human_size(speed))
else:
line = " %s %s/s" % (human_size(done), human_size(speed))
sys.stdout.write("\r" + line + ("\n" if final else ""))
sys.stdout.flush()
# =============================================================================
# CLI
# =============================================================================
def build_parser():
p = argparse.ArgumentParser(
prog="netdelivery.py",
description="ALL.Net DLI:查 / 下 .app 与 .opt 更新包",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"示例:\n"
" python netdelivery.py # 查完让你多选\n"
" python netdelivery.py --list # 只列清单\n"
" python netdelivery.py --all # 全下\n"
" python netdelivery.py --only 1,3 # 只下第 1、3 项\n"
" python netdelivery.py --ver 1.55 # 指定上报版本\n"
),
)
p.add_argument("keychip", nargs="?",
help="Keychip(默认取 config.py 的 KEYCHIP_ID)")
p.add_argument("--ver", default=None,
help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
p.add_argument("--host", default=DEFAULT_HOST,
help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
p.add_argument("--out", default=DEFAULT_OUT_DIR,
help="下载目录(默认 <脚本目录>/download)")
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="接口超时秒数")
p.add_argument("--list", action="store_true", help="只列清单与直链,不下载")
p.add_argument("--all", action="store_true", help="全选(不交互)")
p.add_argument("--only", default=None, help="只下指定序号,如 1,3 或 1-3(不交互)")
p.add_argument("--no-probe", action="store_true", help="跳过包大小探测(更快)")
p.add_argument("--insecure", action="store_true", help="关掉 TLS 校验")
return p
def resolve_keychip(cli_value):
if cli_value:
return cli_value
try:
import config # noqa: F401
value = getattr(config, "KEYCHIP_ID", "")
if value:
return value
except Exception:
pass
return ""
def resolve_title_ver(cli_value):
if cli_value:
return str(cli_value)
try:
import config # noqa: F401
return str(getattr(config, "MAI_ENCODING", "1.55"))
except Exception:
return "1.55"
def parse_only(text, count):
"""把 --only 的文本解析成 0 基下标列表。"""
picked, seen = [], set()
for token in re.split(r"[,,、\s]+", text or ""):
if not token:
continue
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
if span:
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
candidates = range(low_i, high_i + 1)
elif token.isdigit():
candidates = [int(token)]
else:
raise ValueError("--only 里有无效项:%r" % token)
for i in candidates:
if not (1 <= i <= count):
raise ValueError("--only 序号 %d 超出范围(1-%d)" % (i, count))
if i - 1 not in seen:
seen.add(i - 1)
picked.append(i - 1)
return picked
def save_instructions(meta, out_dir):
"""把配信指示書原样存一份,便于事后核对。"""
os.makedirs(out_dir, exist_ok=True)
saved = []
for name, text in meta["infos"].items():
path = os.path.join(out_dir, name)
with open(path, "w", encoding="utf-8", newline="") as fp:
fp.write(text)
saved.append(path)
return saved
def main(argv=None):
try:
sys.stdout.reconfigure(errors="replace")
except Exception:
pass
args = build_parser().parse_args(argv)
title_ver = resolve_title_ver(args.ver)
raw_keychip = resolve_keychip(args.keychip)
if not raw_keychip:
print("错误:没给 Keychip,config.py 里的 KEYCHIP_ID 也是空的。")
print(" 先跑一次 python authv1.py <你的Keychip> 把 config.py 填好。")
return 2
try:
client_id = parse_keychip(raw_keychip)
except ValueError as exc:
print("Keychip 解析失败:%s" % exc)
return 2
print("=== netdelivery (ALL.Net DLI) ===")
print(" clientId : %s" % client_id)
print(" title_ver : %s" % title_ver)
print(" server : %s" % args.host)
print(" 正在查询配信指示書 ...")
try:
items, meta = collect_packages(client_id, title_ver=title_ver, host=args.host,
timeout=args.timeout, insecure=args.insecure)
except (AllnetError, ValueError) as exc:
print("查询失败:%s" % exc)
return 1
for entry in meta["instructions"]:
if entry.get("status") == 200:
common = entry.get("common", {})
print(" 指示書 %s" % entry["name"])
for key in ("GAME_DESC", "DLFORMAT", "GAME_ID", "DOWNLOAD_ID",
"RELEASE_TIME", "RELEASE_TYPE"):
if key in common:
print(" %-14s %s" % (key + ":", common[key]))
else:
print(" 指示書 %s -> %s" % (entry["url"], entry.get("error", "?")))
if not items:
print("没有可下载的包。")
return 0
if not args.no_probe:
print(" 正在探测包大小 ...")
probe_all(items, insecure=args.insecure)
print_packages(items)
if args.list:
print("")
print(" 直链:")
for item in items:
print(" [%2d] %s" % (item["idx"], item["url"]))
return 0
# ---- 决定下哪些 ----
if args.only:
try:
chosen = parse_only(args.only, len(items))
except ValueError as exc:
print("参数错误:%s" % exc)
return 2
elif args.all:
chosen = list(range(len(items)))
elif not sys.stdin or not sys.stdin.isatty():
print("")
print(" 当前不是交互终端,未指定 --all / --only,已只列清单。")
return 0
else:
chosen = prompt_selection(items)
if chosen is None:
print(" 已取消。")
return 0
if not chosen:
print(" 没有选中任何项,退出。")
return 0
out_dir = args.out
os.makedirs(out_dir, exist_ok=True)
for path in save_instructions(meta, out_dir):
print(" [指示書] %s" % path)
print("")
print(" 下载目录:%s" % out_dir)
failed = []
for index in chosen:
item = items[index]
dest = os.path.join(out_dir, item["name"])
print("")
print(" [%d/%d] %s(%s)" % (chosen.index(index) + 1, len(chosen),
item["name"], KIND_TEXT.get(item["kind"], "?")))
if item["size"] is None and item["note"]:
print(" 跳过:%s" % item["note"])
failed.append((item, item["note"]))
continue
started = time.time()
ok, message = download_file(item["url"], dest, insecure=args.insecure)
if ok:
print(" 完成,用时 %.1f 秒 -> %s" % (time.time() - started, message))
else:
print(" 失败:%s" % message)
failed.append((item, message))
print("")
if failed:
print(" --- 结束:%d 个成功,%d 个失败 ---" % (len(chosen) - len(failed), len(failed)))
for item, message in failed:
print(" [%2d] %s -> %s" % (item["idx"], item["name"], message))
return 1
print(" --- 结束:%d 个全部成功 ---" % len(chosen))
return 0
if __name__ == "__main__":
sys.exit(main())
+512
View File
@@ -0,0 +1,512 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克隆到账号 B
================================================================================
┌─ 源账号 A ─────────────────────────────────────────────┐
│ 二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData │
│ 或:直接读 data/<userId>.json(离线复用) │
└────────────────────────────────────────────────────────┘
↓ 节点数据
┌─ 目标账号 B ───────────────────────────────────────────┐
│ 二维码 -> ParseQRCode -> UserPreview -> UserLogin │
│ 循环: │
│ 等待 -> UpsertUserAll(一包曲目) │
│ -> UserLogout(type=1) │
│ -> UserLogin(isContinue=true) 续关,拿新 loginId │
│ 收尾:最后一包登出后结束(不再续关) │
└────────────────────────────────────────────────────────┘
--------------------------------------------------------------------------------
★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
--------------------------------------------------------------------------------
真机一局(一个 credit)最多打 4 首,所以一条 UpsertUserAll 报文里的
userPlaylogList / userGamePlaylogList **最多 4 条**。
★ 但实测:**每包恰好 2 首会被服务端拒绝**(1 / 3 / 4 首都正常)。
所以本脚本的分批器会**主动避开 2 首包**:
10 首 -> [4, 4, 2] -> [4, 3, 3]
6 首 -> [4, 2] -> [3, 3]
2 首 -> [2] -> [1, 1]
★ 每包都必须带**非空**的 userPlaylogList —— 踩坑清单第 2 条实测:
留空会被服务端当成无效包**静默丢弃**(returnCode=1 但不落库)。
--------------------------------------------------------------------------------
★ 为什么需要「续关登录」
--------------------------------------------------------------------------------
UpsertUserAll 的 playlogId 必须等于**本次登录**的 loginId
(PacketUpsertUserAll.cs:31 / UserAllRequestVO)。
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 ——
真机走的就是「续关」:
Process/ContinueProcess.cs:403
new PacketUserLogin(userID, accessCode, isContinue: true, ...)
对应本工程:UserLogin(user_id, token, is_continue=True)
它和普通登录的唯一区别就是 UserLoginRequestVO.isContinue = true,
会返回**新的 loginId / loginDateTime**,供下一局 UpsertUserAll 使用。
★ 登出必须用 **type=1(LogoutType.Logout)**。
真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog):
{"userId":...,"type":1,"delayLog":{...}}
续关场景下用 4(TestIn)会出问题 —— 那是「测试登入 / 顶号」的语义。
--------------------------------------------------------------------------------
★ playlog 从哪来
--------------------------------------------------------------------------------
GetNeedUserData 抓不到 userPlaylogList(那是逐局记录,没有对应的 Get 接口),
所以本脚本**从曲目记录反推合成**:
UserMusicDetail(9 字段) -> UserPlaylog(110 字段)
───────────────────────────── ─────────────────────────
musicId musicId
level level
achievement achievement
deluxscoreMax deluxscore
scoreRank scoreRank / isClear
comboStatus comboStatus
syncStatus syncStatus
其余字段 取自 config.PLAY_*(合理默认值)
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/<A>.json)
python reincarnate.py --dst-qr <B的二维码> --src-qr <A的二维码>
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json
# ★ 真实上传
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
# 只看计划,不发包
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
# 常用开关
--limit N 只迁前 N 首(试跑用)
--per-packet N 每包曲目数,只能是 1/3/4(默认 4;★ 2 首会被服务端拒绝)
--wait N 每次上传前的等待秒数(默认 5.0,0 = 不等)
--clone-identity 连 userName / accessCode 一起克隆(默认**保留 B 自己的**)
--no-continue 不续关(只发一包,调试用)
★ 迁移会**真实写入**账号 B(不可撤销)。不传 --send 时只构造报文。
★ 二维码**有效期 10 分钟**。
"""
from __future__ import annotations
import json
import sys
import time
from api import (
setup_logging,
teardown_logging,
log_error,
log_info,
log_warning,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
GetNeedUserData,
normalize_snapshot,
NODE_ORDER,
NODE_TYPES,
ARRAY_TO_BITS,
DIFF_PRIMARY_KEYS,
PlayData,
build_user_playlog,
build_user_game_playlog,
UpsertUserAll,
ConfirmRequiredError,
Ping,
load_user_data,
list_saved_user_ids,
DATA_DIR,
)
from config import PLACE_ID, REGION_ID
#: 真机一局最多 4 首
MAX_TRACKS_PER_CREDIT = 4
#: ★ 被服务端禁止的每包曲目数(实测:**恰好 2 首会被拒**)
#: 1 / 3 / 4 首都正常,只有 2 首不行 —— 所以分批时要避开它。
FORBIDDEN_TRACK_COUNTS = (2,)
#: 允许的 --per-packet 取值
ALLOWED_PER_PACKET = (1, 3, 4)
def split_batches(total, per):
"""
把 total 首切成「每包 <= per」的批次,**并且不产生被禁止的 2 首包**。
做法:先贪心按 per 切,再修正大小为 2 的批次:
① 前一批 >= 4 -> 从它借 1 首过来(前一批变 >=3,本批变 3)
② 借不到(前一批只有 3,或自己就是第一批)-> 把这个 2 拆成 1 + 1
★ 只借位是不够的 —— 前一批是 3 时借完自己变 2,等于把 2 挪了个位置。
例:
10 首 per=4 -> [4, 4, 2] -> [4, 3, 3]
6 首 per=4 -> [4, 2] -> [3, 3]
2 首 per=4 -> [2] -> [1, 1]
5 首 per=3 -> [3, 2] -> [3, 1, 1]
8 首 per=3 -> [3, 3, 2] -> [3, 3, 1, 1]
5 首 per=4 -> [4, 1] (本来就合法,不动)
"""
if total <= 0:
return []
per = max(1, int(per))
out = []
left = total
while left > 0:
n = min(per, left)
out.append(n)
left -= n
i = 0
while i < len(out):
if out[i] not in FORBIDDEN_TRACK_COUNTS:
i += 1
continue
# ① 前一批 >= 4 才能借(借完前一批 >= 3,本批 = 3)
if i > 0 and out[i - 1] >= 4:
out[i - 1] -= 1
out[i] += 1
i += 1
continue
# ② 借不到 -> 把这个 2 拆成 1 + 1
out[i] = 1
out.insert(i + 1, 1)
i += 2
return out
#: 默认保留目标账号自己的身份字段(不克隆过去,避免把 B 改名)
IDENTITY_KEYS = ("userName", "accessCode")
#: 迁移时**不**逐条传的节点(要么是位串、要么由 UpsertUserAll 自己算)
SKIP_NODES = (
"userMusicDetailList", # 单独分批
"userGamePlaylogList", # 由本脚本按曲目合成
"user2pPlaylog", # 2P 记录,迁移无意义
"userGetPointList", # 里程/奖励,迁移会凭空发点
"userTradeItemList", # 无 Get 来源
"userFriendSeasonRankingList", # 无 Get 来源
)
def parse_args(argv):
opts = {"dst_qr": "", "src_qr": "", "src_json": "", "send": False,
"plan": False, "debug": False, "limit": 0,
"per_packet": MAX_TRACKS_PER_CREDIT,
"clone_identity": False, "no_continue": False,
"wait": 5.0}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--plan":
opts["plan"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--clone-identity":
opts["clone_identity"] = True
elif a == "--no-continue":
opts["no_continue"] = True
elif a in ("--dst-qr", "--src-qr", "--src-json", "--limit",
"--per-packet", "--wait") and i + 1 < len(argv):
key = a[2:].replace("-", "_")
opts[key] = (float(argv[i + 1]) if key == "wait"
else int(argv[i + 1]) if key in ("limit", "per_packet")
else argv[i + 1])
i += 1
i += 1
return opts
# =============================================================================
# 工具
# =============================================================================
def _bits_for(node, items, base_items):
"""
按目标账号 B 的基线算 isNew* 位串:
B 里没有该条目 -> "1"(新增)
B 里已有 -> "0"(更新)
主键表见 upsert_user_all.DIFF_PRIMARY_KEYS。
"""
keys = DIFF_PRIMARY_KEYS.get(node)
if not keys:
return None
have = set()
for b in (base_items or []):
if isinstance(b, dict):
have.add(tuple(str(b.get(k)) for k in keys))
return "".join(
"0" if tuple(str(it.get(k)) for k in keys) in have else "1"
for it in (items or []) if isinstance(it, dict)
)
def play_from_detail(detail, base_play=None):
"""
从一条 UserMusicDetail(9 字段)反推一份 PlayData,用来合成 userPlaylog。
对不上的字段保留 config.PLAY_* 的默认值(它们不在曲目记录里)。
"""
p = PlayData() if base_play is None else base_play
p.music_id = int(detail.get("musicId", p.music_id) or 0)
p.music_level = int(detail.get("level", p.music_level) or 0)
p.achievement = int(detail.get("achievement", p.achievement) or 0)
p.deluxscore = int(detail.get("deluxscoreMax", p.deluxscore) or 0)
rank = int(detail.get("scoreRank", 0) or 0)
p.score_rank = rank
p.is_clear = rank > 0
p.combo_status = int(detail.get("comboStatus", p.combo_status) or 0)
p.sync_status = int(detail.get("syncStatus", p.sync_status) or 0)
return p
def collect_nodes(src_nodes, base_nodes, clone_identity):
"""把源账号 A 的节点整理成 extra_data(曲目/2P/奖励类除外)。"""
out = {}
for node in NODE_ORDER:
if node in SKIP_NODES:
continue
if node.startswith("isNew"):
continue # 位串单独算
kind = NODE_TYPES.get(node)
if kind not in ("singleton", "array", "object"):
continue
val = src_nodes.get(node)
if val in (None, [], {}):
continue
out[node] = json.loads(json.dumps(val)) # 深拷贝
# 身份字段:默认保留 B 自己的
if not clone_identity and out.get("userData"):
for item in out["userData"]:
if isinstance(item, dict):
for k in IDENTITY_KEYS:
item.pop(k, None)
# 位串:按 B 的基线算
for arr, bits in ARRAY_TO_BITS.items():
if arr not in out:
continue
b = _bits_for(arr, out[arr], base_nodes.get(arr))
if b:
out[bits] = b
return out
# =============================================================================
# 主流程
# =============================================================================
def fetch_source_by_qr(qr):
"""用源账号 A 的二维码联网抓一份数据(并自动落盘到 data/<A>.json)。"""
log_info("=== 源账号:联网抓取 ===")
uid, token = ParseQRCode(qr)
preview = GetUserPreview(uid, token)
if preview.isLogin:
raise RuntimeError("源账号处于已登录状态,请先在机台登出")
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
try:
raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
finally:
try:
UserLogout(uid, token, login.loginDateTime, logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_warning("源账号登出失败:" + str(exc))
return raw
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["dst_qr"] or not (opts["src_qr"] or opts["src_json"]):
print(__doc__)
return 1
if opts["debug"]:
import config as _cfg
_cfg.LOG_LEVEL = "debug"
if opts["per_packet"] not in ALLOWED_PER_PACKET:
print("--per-packet 只能是 " + str(list(ALLOWED_PER_PACKET))
+ " 之一(真机一局最多 4 首;★ 实测恰好 2 首会被服务端拒绝)")
return 1
setup_logging("reincarnate")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---------------- 1. 源数据 ----------------
if opts["src_json"]:
src_raw = json.load(open(opts["src_json"], "r", encoding="utf-8"))
log_info("=== 源账号:从文件读取 " + opts["src_json"] + " ===")
else:
src_raw = fetch_source_by_qr(opts["src_qr"])
src_nodes = normalize_snapshot(src_raw)
musics = src_nodes.get("userMusicDetailList") or []
if opts["limit"] > 0:
musics = musics[:opts["limit"]]
log_info("源数据:节点 " + str(len(src_nodes)) + " 个,曲目记录 " + str(len(musics)) + " 条")
if not musics:
log_error("源数据里没有 userMusicDetailList,没什么可迁的。")
return 4
# ---------------- 2. 目标账号 ----------------
Ping()
uid, token = ParseQRCode(opts["dst_qr"])
session.update(user_id=uid, token=token)
preview = GetUserPreview(uid, token)
if preview.isLogin:
log_error("目标账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
base_raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
base_nodes = normalize_snapshot(base_raw)
log_info("目标账号基线已抓取")
# ---------------- 3. 分批 ----------------
per = int(opts["per_packet"])
sizes = split_batches(len(musics), per)
batches = []
_pos = 0
for _n in sizes:
batches.append(musics[_pos:_pos + _n])
_pos += _n
log_info("计划:共 " + str(len(musics)) + " 首 -> " + str(len(batches))
+ " 包,每包 " + "/".join(str(n) for n in sizes[:12])
+ ("..." if len(sizes) > 12 else "") + " 首")
log_info(" ★ 已避开 2 首包(实测恰好 2 首会被服务端拒绝;1/3/4 首都正常)")
if opts["plan"]:
for i, b in enumerate(batches):
log_info(" 第 " + str(i + 1) + " 包(" + str(len(b)) + " 首):" + ", ".join(
str(x.get("musicId")) + "/L" + str(x.get("level")) for x in b))
log_info("--plan:只列计划,不发包")
return 0
# 第一包才带非曲目节点(整账号克隆)
shared_nodes = collect_nodes(src_nodes, base_nodes, opts["clone_identity"])
log_info("非曲目节点 " + str(len([k for k in shared_nodes if not k.startswith('isNew')]))
+ " 个将在第 1 包上传")
# ---------------- 4. 逐包上传 ----------------
# ★ 流程(按使用方口径):
# 登录 -> 等待 -> 上传 -> 登出(type=1) -> 续关登录 -> 等待 -> 上传 -> 登出 -> ...
#
# ★ 登出必须用 type=1(LogoutType.Logout)。
# 真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog);
# 续关场景下用 4(TestIn)会出问题 —— 那是「测试登入/顶号」的语义。
sent = 0
for idx, batch in enumerate(batches):
log_info("")
log_info("========== 第 " + str(idx + 1) + "/" + str(len(batches))
+ " 包(" + str(len(batch)) + " 首)==========")
# ---- 等待 ----
if opts["wait"] > 0:
log_info("上传前等待 " + str(opts["wait"]) + " 秒 ...")
time.sleep(opts["wait"])
nodes = {}
if idx == 0:
nodes.update(json.loads(json.dumps(shared_nodes)))
nodes["userMusicDetailList"] = json.loads(json.dumps(batch))
b = _bits_for("userMusicDetailList", batch,
base_nodes.get("userMusicDetailList"))
if b:
nodes["isNewMusicDetailList"] = b
log_info(" isNewMusicDetailList = " + b)
# 合成 playlog(每首一条)与 game playlog
playlogs = []
game_playlogs = []
for d in batch:
p = play_from_detail(d)
playlogs.append(build_user_playlog(
login.loginId, login.loginDateTime, p, place_id=PLACE_ID))
game_playlogs.append(build_user_game_playlog(
login.loginId, login.loginDateTime, p))
nodes["userGamePlaylogList"] = game_playlogs
UpsertUserAll(
uid, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
snapshot=base_raw,
auto_characters=False,
play=play_from_detail(batch[0]),
extra_data=nodes,
user_playlog_list=playlogs,
pre_new_item_list=False,
verify_landing=False,
dry_run=not opts["send"],
confirm=opts["send"],
place_id=PLACE_ID, region_id=REGION_ID,
)
sent += 1
# ---- ★ 登出(type=1 Logout)----
log_info("--- 登出(type=1 Logout)---")
try:
UserLogout(uid, token, login.loginDateTime,
logout_type=LogoutType.Logout,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_warning("登出失败(继续下一包):" + str(exc))
session["logged_in"] = False
# ---- ★ 续关登录:拿下一包的 loginId ----
if idx < len(batches) - 1:
if opts["no_continue"]:
log_warning("--no-continue:不再续关,提前结束(只发了 1 包)")
break
log_info("--- 续关登录(isContinue=true)---")
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID,
is_continue=True)
session.update(login_dt=login.loginDateTime, logged_in=True)
if not opts["send"]:
log_info("")
log_info("[dry-run] 未发包。想真实迁移请加 --send(★ 会写入账号 B)")
return 0
log_info("")
log_info("★ 迁移完成:共上传 " + str(sent) + " 包 / "
+ str(min(len(musics), sent * per)) + " 首")
return 0
except ConfirmRequiredError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
+30
View File
@@ -0,0 +1,30 @@
# tools/ —— 独立小工具
这个目录放**与游戏协议无关的独立小工具**:纯计算、纯文件处理。
它们**不联网**、**不碰游戏服务器**、也**不依赖 `api/` 与 `config.py`**。
## 约定
| 约定 | 说明 |
|---|---|
| 一个工具一个子目录 | `tools/<工具名>/` |
| 自包含 | 工具之间不互相依赖,单独拷走也能跑 |
| 纯本地 | 不联网、不写游戏服务器;最多读本机文件 |
| 无第三方依赖 | 只用 Python 标准库(除非工具自己的 README 里写明) |
| 登记 | 新增工具时,在下面的「已有工具」表里加一行 |
## 已有工具
| 工具 | 目录 | 干什么 |
|---|---|---|
| 谱面达成率计算器 | [`chart_achievement/`](chart_achievement/) | 按判定数算谱面达成率与 DX 分;也可由达成率倒算判定组合 |
## 怎么跑
```bash
# 进工具目录,直接 python 跑(各工具的参数见它自己的说明)
cd tools/chart_achievement
python chart_achievement.py --help
```
---
+201
View File
@@ -0,0 +1,201 @@
# chart_achievement —— 舞萌DX 谱面达成率 / DX 分计算器
给定谱面(`.ma2`)和判定数,算出**达成率**与 **DX 分**;反过来,给定目标达成率,
倒算出所有能打出该达成率的判定组合。
纯本地、纯标准库(不需要联网、不写任何游戏文件)。
## 怎么跑
```powershell
cd tools/chart_achievement
python chart_achievement.py --help
python chart_achievement.py # 不给参数 = 交互向导
```
## 谱面从哪来(二选一)
| 参数 | 说明 |
| --- | --- |
| `--chart <路径>` | 直接给 `.ma2` 文件,如 `.../music/music000008/000008_03.ma2` |
| `--game <目录> --music-id <ID>` | 给游戏目录(`Sinmai_Data`、`StreamingAssets`、`A000`、`music` 都能认),按 `musicid` 自动找谱面 |
> 🔎 **不知道 musicId / 定数?** [maimai.lxns.net/songs](https://maimai.lxns.net/songs)
> 可以在线查询谱面信息(曲名、难度、定数等),查到 `musicId` 和难度后再回来跑脚本。
### 难度(--level)
难度用**游戏里规定的数字**表示,也就是谱面文件名后缀 `_00` ~ `_04` 的那个数字
(对应游戏主数据 `musicDifficulty/MusicDifficulty00000X` 的 `<id>`):
| 数字 | 难度 | 谱面文件名 |
| --- | --- | --- |
| 0 | BASIC | `<id>_00.ma2` |
| 1 | ADVANCED | `<id>_01.ma2` |
| 2 | EXPERT | `<id>_02.ma2` |
| 3 | MASTER | `<id>_03.ma2` |
| 4 | RE:MASTER | `<id>_04.ma2` |
`--level 0` ~ `--level 4` 或 `--level BASIC` / `ADVANCED` / `EXPERT` / `MASTER` / `RE:MASTER`
(大小写随意;`--difficulty` 是同一个参数的别名)都行,**默认 3(MASTER)**;
不给时会在 stderr 提示用了默认值。
注意:这里的「等级」指**谱面难度**。谱面的数值等级(如 `12+`)是另一回事,
由 `Music.xml` 自动读出,只用于显示。
## 正向:已知判定数 → 达成率 + DX 分
达成率是**按音符权重加权**的(Tap 500 / Hold 1000 / Slide 1500 / Break 2500),
所以有两种给判定数的方式:
### ① 精确:按音符类别给(推荐,得到确切数值)
```powershell
python chart_achievement.py --game "<...>\Sinmai_Data" --music-id 8 --level 3 --tap 260/0/3 --break 6 # 3 = MASTER
```
每个类别写 `CP/Perfect/Great/Good/Miss` 五个数(`/`、`,` 或空格分隔,后面可以省略),
**没给满的部分按 CP 算**:
| 参数 | 类别 | 权重 |
| --- | --- | --- |
| `--tap` | Tap / Star / Touch / EX | 500 |
| `--hold` | Hold 系 | 1000 |
| `--slide` | Slide 系 | 1500 |
| `--break` | 绝赞(Break 系) | 2500 |
绝赞的 Great / Perfect 本身还有档位,需要时可以再细分(不给就按 Great 失分 20%、Perfect 奖励 75 算):
| 参数 | 含义 |
| --- | --- |
| `--break-great40 N` | 绝赞里 N 个 Great 是「失分 40%」档(Great2nd) |
| `--break-great50 N` | 绝赞里 N 个 Great 是「失分 50%」档(Great3rd) |
| `--break-perfect50 N` | 绝赞里 N 个 Perfect 是「奖励分 50」档(Perfect2nd) |
输出就是一个**确切**的达成率,并附上失分 / 绝赞奖励分的明细:
```
判定输入(按音符类别;Break = 绝赞;没给的部分按 CP 算)
Tap/Star/Touch/EX (500) 263 个:CP 260 · Great 3
Hold (1000) 14 个:CP 14
Slide (1500) 19 个:CP 19
Break (2500) 6 个:CP 6
结果
达成率 100.8413% (确切值)
DX 分 900 / 906 (99.34%)
```
### ② 汇总:只给总判定数(得到估计值 + 区间)
```powershell
python chart_achievement.py --chart "<...>\000008_03.ma2" --cp 290 --perfect 8 --great 3 --good 1
```
`--cp` / `--perfect` / `--great` / `--good` 都给总数,`--miss` 留空时自动补足
(`miss = 总音符数 - CP - Perfect - Great - Good`)。
这时达成率取决于这些 Great/Good/Miss **落在哪类音符上**(落在绝赞上扣得多、落在 Tap 上扣得少),
所以给出的是「按类别比例分摊的估计值」+ 一个区间(下界 = 扣分都落在高权重音符上,
上界 = 都落在低权重音符上)。想要一个确切数值就用上面的 ①。
DX 分在两种方式下都是确定的:`3×CP + 2×Perfect + 1×Great`。
## 反向:已知达成率 → 判定组合 + DX 分
```powershell
python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --level 3 --achievement 100.75 # 3 = MASTER
python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --level 2 --achievement 99 --mode range # 2 = EXPERT
```
两种模式(`--mode`):
| 模式 | 含义 | 什么时候用 |
| --- | --- | --- |
| `exact`(默认) | 只列**真的能打出**该达成率的 (Miss, Good, Great) 组合,每组都做过严格验证 | 数据可信、想知道精确答案 |
| `range` | 列达成率**区间覆盖**该值的组合(更快、更宽松) | 判定数只是估算 / 想快速看个大概 |
`--limit N` 限制最多输出多少组(默认 200)。`--json` 输出 JSON。
输出里的 `CP+P` 是「Critical Perfect + Perfect 的总数」;`CP 范围` 是其中
CP 的物理可能范围(Perfect 只能落在绝赞音符上,所以 CP 不能少于
`CP+P - 绝赞音符数`)。CP 越多 DX 分越高,所以 DX 分也是这个范围。
### 精确模式的限制
精确验证用一个「坏音符背包 DP」,代价随坏音符个数增长,所以:
- 只严格验证 `Miss+Good+Great ≤ 12` 的组合;
- 坏音符更多的组合不验证(输出里会提示「坏音符超过 12 个的候选组合没有验证」);
- 若候选组合全都超过 12 个坏音符(例如 1000+ 绝赞的异常谱面),会自动退回区间模式并说明原因;
- DP 有 8 秒时间预算,超时也退回区间模式。
## 计分模型(与游戏一致)
达成率按**音符权重**加权,不是按个数平均:
```
达成率 = 100 × Σ(单音符得分) / 理论分W + Σ(绝赞奖励分) / 绝赞理论奖励分B
```
- 理论分 W = Σ 每个计分音符的权重(Tap/Star/Touch 500、Hold 系 1000、Slide 系 1500、Break 系 2500)
- 绝赞理论奖励分 B = 100 × 绝赞音符个数(Break / Break(长按判定) / BreakSlide / EXBreak)
- 满分 = **101.0000%**(谱面有绝赞)/ **100.0000%**(无绝赞)
- 接续滑条节点(`CNSI_` 等 ConnectSlide)**不计分、不计入总数**
- DX 分 = `3×CP + 2×Perfect + 1×Great`(与音符类型无关)
### 各类音符的失分 / 奖励分
| 音符组(权重) | CP | Perfect | Great | Good | Miss |
| --- | --- | --- | --- | --- | --- |
| Tap / Star / Touch(500) | 0 | 0 | 100 | 250 | 500 |
| Hold 系(1000) | 0 | 0 | 200 | 500 | 1000 |
| Slide 系(1500) | 0 | 0 | 300 | 750 | 1500 |
| Break(2500,分档) | 0 | 0 | 500 / 1000 / 1250 | 1500 | 2500 |
| Break 长按判定(2500) | 0 | 0 | 500 | 1500 | 2500 |
| BreakSlide(2500,分档) | 0 | 0 | 500 / 1000 / 1250 | 1500 | 2500 |
| EX(500) | 0 | — | — | — | 500 |
| EXBreak(2500) | 0 | — | — | — | 2500 |
(表里是「失分」,即 `权重 - 得分`;CP/Perfect 的失分都是 0,所以
**除绝赞外,Critical Perfect 与 Perfect 加的达成率完全一样**。)
绝赞音符额外有奖励分:
| 判定 | 奖励分 |
| --- | --- |
| Critical Perfect | 100 |
| Perfect | 75(FastPerfect/LatePerfect)/ 50(Perfect2nd) |
| Great | 40 |
| Good | 30 |
| Miss | 0 |
这就是绝赞必须区分 CP / Perfect 的原因:普通分一样,但奖励分不同
(CP 100、Perfect 75 或 50)。
### 音符组的划分依据(反编译源码)
- 走长按结算的 Break(`BRHLD`)判定档位更少(Great 只有一档、Perfect 只有 75 档),所以单独成组。
- 滑条系(`BRSI_`/`BRSCL`…)走 SlideOut 判定表,只有 Great 三档、没有 Perfect 档。
- EX 音符(`XTP`/`XST`/`BXTAP`/`BXSTR`,即 ExTap/ExStar/ExBreakTap/ExBreakStar)
走 ExTap 判定表,非 Critical 的窗口宽度为 0,所以**只能判 Critical 或 Miss**。
- `XHO`/`BXHLD`(ExHold/ExBreakHold)头部用 ExTap 表但结果还要过长按结算,
等价于普通 Hold / Break(长按判定)。
## 文件
| 文件 | 内容 |
| --- | --- |
| `ma2.py` | `.ma2` 解析、指令→音符组映射、`Music.xml` 读取、路径拼接 |
| `score.py` | 计分模型、正向求解、反向求解(区间 / 精确) |
| `chart_achievement.py` | 命令行界面 / 交互向导 |
## 已知局限
- 只按谱面文本计算,不涉及游戏运行时的实际判定时机分布。绝赞的 Great 档位(失分 20% / 40% / 50%)
和 Perfect 档位(奖励 75 / 50)需要自己用 `--break-great40` / `--break-great50` /
`--break-perfect50` 指定,不给就按 20% / 75 算。
- 汇总模式(只给总判定数)只能给区间——这不是精度问题,是信息量不够:同样的判定总数,
落在绝赞上和落在 Tap 上扣的分不同。要确切值就按类别给(`--tap` / `--hold` / `--slide` / `--break`)。
- 精确反向只验证坏音符数 ≤ 12 的组合(见上文)。
- 若 `.ma2` 里的 `T_NUM_ALL` 与解析出的计分音符数不一致,会在输出里给警告
(全部 5419 个游戏谱面实测一致)。
@@ -0,0 +1,651 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""舞萌DX 谱面达成率 / DX 分计算器。
正向:给 谱面 + 各判定数量 -> 算达成率与 DX 分
反向:给 谱面 + 目标达成率 -> 倒算所有可行的判定组合与 DX 分
只依赖 Python 标准库。
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from fractions import Fraction
from typing import Dict, List, Optional
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ma2 import (DIFFICULTY_NAMES, GROUP_LABEL, GROUP_ORDER, Chart,
chart_path, find_music_dir, level_display, parse_difficulty,
parse_ma2, parse_music_xml)
from score import (CLASS_LABEL, CLASS_ORDER, CLASS_WEIGHT, DX_PER, JUDGE_LABEL,
JUDGMENTS, Theory, class_capacity, dx_score,
reference_achievement, solve_exact, solve_forward,
solve_reverse, theory_from_chart)
try: # 中文输出到管道时避免 UnicodeEncodeError
sys.stdout.reconfigure(errors="replace")
sys.stderr.reconfigure(errors="replace")
except Exception: # noqa: BLE001
pass
LINE = "-" * 60
def pct(value) -> str:
return "%.4f%%" % float(value)
def die(msg: str, code: int = 2) -> "None":
print("[错误] " + msg, file=sys.stderr)
raise SystemExit(code)
# --------------------------------------------------------------- 载入谱面
def _enrich(chart: Chart, path: str) -> Chart:
"""从同目录的 Music.xml 补全曲名 / 难度 / 等级(拿不到就跳过)。"""
base = os.path.basename(path)
stem = os.path.splitext(base)[0]
if "_" in stem:
left, right = stem.split("_", 1)
try:
chart.music_id = int(left)
chart.difficulty_index = int(right)
chart.difficulty_name = DIFFICULTY_NAMES[chart.difficulty_index]
except (ValueError, IndexError):
pass
xml_path = os.path.join(os.path.dirname(path), "Music.xml")
if os.path.isfile(xml_path):
info = parse_music_xml(xml_path)
chart.song_name = info.get("name") or chart.song_name
notes = info.get("notes") or []
idx = chart.difficulty_index
if idx is not None and 0 <= idx < len(notes):
entry = notes[idx]
chart.level_text = level_display(entry.get("level"), entry.get("levelDecimal"))
try:
chart.max_notes_xml = int(entry.get("maxNotes") or 0)
except (TypeError, ValueError):
chart.max_notes_xml = None
return chart
def load_from_file(path: str) -> Chart:
path = os.path.abspath(os.path.expanduser(path))
if not os.path.isfile(path):
die("谱面文件不存在:%s" % path)
return _enrich(parse_ma2(path), path)
def load_from_game(game_path: str, music_id: int, difficulty: int) -> Chart:
music_dir = find_music_dir(game_path)
path = chart_path(music_dir, music_id, difficulty)
if not os.path.isfile(path):
alt = os.path.join(music_dir, "music%06d" % music_id)
hint = ""
if os.path.isdir(alt):
have = sorted(f for f in os.listdir(alt) if f.lower().endswith(".ma2"))
hint = ";该曲目现有谱面:" + (", ".join(have) if have else "无")
die("找不到谱面:%s%s" % (path, hint))
chart = parse_ma2(path)
chart.music_id = music_id
chart.difficulty_index = difficulty
chart.difficulty_name = DIFFICULTY_NAMES[difficulty] if 0 <= difficulty <= 4 else "?"
chart = _enrich(chart, path)
chart.difficulty_index = difficulty
chart.difficulty_name = DIFFICULTY_NAMES[difficulty] if 0 <= difficulty <= 4 else "?"
return chart
# ------------------------------------------------------------------ 展示
def print_chart(chart: Chart, th: Theory) -> None:
print(LINE)
print("谱面")
print(" 文件 %s" % chart.path)
if chart.song_name:
print(" 曲目 %s" % chart.song_name)
if chart.music_id is not None:
print(" 编号 %d" % chart.music_id)
if chart.difficulty_name:
print(" 难度 %s (%d)" % (chart.difficulty_name.upper(),
chart.difficulty_index if chart.difficulty_index is not None else 0))
if chart.level_text:
print(" 等级 %s" % chart.level_text)
parts = []
for g in GROUP_ORDER:
n = th.counts.get(g, 0)
if n:
parts.append("%s %d" % (GROUP_LABEL[g], n))
print(" 音符 %s" % " · ".join(parts))
print(" 合计 %d 个计分音符" % th.total_notes)
if chart.connect_count:
print(" (另有 %d 个接续滑条节点,不计分)" % chart.connect_count)
bonus = " + 绝赞奖励 %d" % th.break_bonus_score if th.break_bonus_score else ""
print(" 理论分 %d%s" % (th.all_perfect_score, bonus))
print(" 理论DX %d" % th.max_dx)
print(" 满分 %s" % pct(th.max_achievement))
for w in chart.warnings:
print(" [警告] %s" % w)
declared = chart.totals.get("TTM_SCR_SS")
if declared is not None and declared != th.all_perfect_score:
print(" [警告] 谱面自带 TTM_SCR_SS=%d 与计算出的理论分 %d 不一致"
% (declared, th.all_perfect_score))
def print_forward(chart: Chart, result) -> None:
th = result.theory
print(LINE)
print("判定输入(汇总)")
for j in JUDGMENTS:
print(" %-18s %d" % (JUDGE_LABEL[j], result.judges.get(j, 0)))
print(" %-18s %d / %d" % ("合计", sum(result.judges.values()), th.total_notes))
print(LINE)
print("结果")
exact = result.achievement_min == result.achievement_max
if exact:
print(" 达成率 %s (确切值)" % pct(result.achievement_min))
else:
print(" 达成率 %s (按音符类别比例分摊的估计值)" % pct(result.achievement_ref))
print(" 区间 %s ~ %s"
% (pct(result.achievement_min), pct(result.achievement_max)))
print(" DX 分 %d / %d (%.2f%%)"
% (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0))
if not exact:
print()
print(" 说明:只给汇总判定数时,达成率取决于 Great/Good/Miss 落在哪类音符上——")
print(" 区间下界 = 扣分都落在权重最高的音符上,上界 = 都落在权重最低的音符上。")
print(" 想要一个确切数值:用 --tap / --hold / --slide / --break 按类别给判定数。")
print(LINE)
_EXACT_KEYS = ("cp", "perfect", "perfect50", "great", "great40", "great50", "good", "miss")
_EXACT_KEY_LABEL = {
"cp": "CP", "perfect": "Perfect", "perfect50": "Perfect(50)",
"great": "Great", "great40": "Great(40%)", "great50": "Great(50%)",
"good": "Good", "miss": "Miss",
}
# 打印时用纯 ASCII,避免中文宽字符把表格撑歪
_EXACT_CLASS_TITLE = {
"tap": "Tap/Star/Touch/EX", "hold": "Hold", "slide": "Slide", "break": "Break",
}
def print_exact(chart: Chart, result) -> None:
th = result.theory
print(LINE)
print("判定输入(按音符类别;Break = 绝赞;没给的部分按 CP 算)")
for cls in CLASS_ORDER:
cap = class_capacity(th, cls)
if not cap:
continue
parts = []
for key in _EXACT_KEYS:
n = result.spec.get(cls, {}).get(key, 0)
if n:
parts.append("%s %d" % (_EXACT_KEY_LABEL[key], n))
print(" %-24s %4d 个:%s"
% ("%s (%d)" % (_EXACT_CLASS_TITLE[cls], CLASS_WEIGHT[cls]), cap,
" · ".join(parts) if parts else "-"))
print(LINE)
print("结果")
print(" 达成率 %s (确切值)" % pct(result.achievement))
print(" DX 分 %d / %d (%.2f%%)"
% (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0))
if th.all_perfect_score:
print(" 失分 %d / %d (%.4f%%)"
% (result.loss_total, th.all_perfect_score,
100.0 * result.loss_total / th.all_perfect_score))
if th.break_bonus_score:
print(" 绝赞奖励 %d / %d (%.4f%%)"
% (result.bonus_total, th.break_bonus_score,
100.0 * result.bonus_total / th.break_bonus_score))
print(LINE)
def print_reverse(chart: Chart, outcome) -> None:
th = outcome["theory"]
rows = outcome["rows"]
mode = outcome.get("mode", "exact")
print(LINE)
print("目标达成率 %s [%s]" % (pct(outcome["target"]),
"精确模式" if mode == "exact" else "区间模式"))
note = outcome.get("note")
if note:
print(" [说明] %s" % note)
if not rows:
if mode == "exact":
cap = outcome.get("exact_cap") or 0
print(" (在严格验证范围内没有找到解:坏音符 ≤ %d 的组合里没有能精确打出该值的)" % cap)
if outcome.get("more_unverified"):
print(" [注意] 坏音符超过 %d 个的候选组合没有验证(不保证那里没有解)" % cap)
else:
print(" (无解:该达成率在这个谱面上打不出来)")
print(LINE)
return
if mode == "exact":
print(" Miss Good Great CP+P CP 范围 DX 分范围")
else:
print(" Miss Good Great CP+P CP 范围 达成率范围 DX 分范围")
for r in rows:
cp_range = "%d~%d" % (r.cp_min, r.cp_max) if r.cp_min != r.cp_max else str(r.cp_min)
dx_range = "%d~%d" % (r.dx_min, r.dx_max) if r.dx_min != r.dx_max else str(r.dx_min)
if mode == "exact":
print(" %4d %4d %5d %4d %-11s %s"
% (r.miss, r.good, r.great, r.cp_plus_perfect, cp_range, dx_range))
else:
ach = "%s ~ %s" % (pct(r.achievement_min), pct(r.achievement_max))
print(" %4d %4d %5d %4d %-11s %-24s %s"
% (r.miss, r.good, r.great, r.cp_plus_perfect, cp_range, ach, dx_range))
print()
if mode == "exact":
print(" 共 %d 组能精确打出 %s 的判定组合(CP+P = Critical Perfect 与 Perfect 之和)"
% (len(rows), pct(outcome["target"])))
cap = outcome.get("exact_cap") or 0
print(" 已逐个严格验证 Miss+Good+Great ≤ %d 的组合;每个组合都真的能打出该达成率" % cap)
if outcome.get("unverified"):
print(" [注意] 另有 %d 组候选(坏音符超过 %d 个)未做精确验证,未列入结果"
% (outcome["unverified"], cap))
if outcome.get("more_unverified"):
print(" [注意] 坏音符超过 %d 个的候选组合没有验证(不保证那里没有解)" % cap)
else:
print(" 共 %d 组可行组合(CP+P = Critical Perfect 与 Perfect 之和)" % len(rows))
print(" 区间模式:只保证达成率落在这个范围内,不保证能恰好打出目标值")
if outcome["truncated"]:
print(" [注意] 结果被 --limit / 扫描上限截断,可能还有更多组合")
print(" DX 分 = 3*CP + 2*Perfect + 1*Great;CP 越靠范围上端,DX 分越高")
print(LINE)
# ------------------------------------------------------------------ JSON
def to_json(chart: Chart, th: Theory, payload: Dict) -> str:
data = {
"chart": {
"path": chart.path,
"music_id": chart.music_id,
"song_name": chart.song_name,
"difficulty": chart.difficulty_name,
"difficulty_index": chart.difficulty_index,
"level_text": chart.level_text,
"notes": th.counts,
"total_notes": th.total_notes,
"connect_slide": chart.connect_count,
"all_perfect_score": th.all_perfect_score,
"break_bonus_score": th.break_bonus_score,
"max_dx": th.max_dx,
"max_achievement": float(th.max_achievement),
"warnings": chart.warnings,
},
}
data.update(payload)
return json.dumps(data, ensure_ascii=False, indent=2)
# ------------------------------------------------------------------ 交互
def ask(prompt: str, default: str = "") -> str:
try:
text = input(prompt).strip()
except EOFError:
text = ""
return text or default
def ask_int(prompt: str, default: Optional[int] = None) -> Optional[int]:
while True:
raw = ask(prompt, "" if default is None else str(default))
if raw == "" and default is None:
return None
try:
return int(raw)
except ValueError:
print(" 请输入整数")
def interactive(args) -> None:
print("=" * 60)
print("舞萌DX 谱面达成率 / DX 分 计算器")
print("=" * 60)
chart = None
while chart is None:
mode = ask("谱面来源:1) .ma2 文件路径 2) 游戏路径 + musicid [1]: ", "1")
if mode.startswith("2"):
game = ask("游戏路径(Sinmai_Data 目录): ")
if not game:
continue
mid = ask_int("musicid: ")
if mid is None:
continue
diff = ask("难度 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER [3]: ", "3")
try:
chart = load_from_game(game, mid, parse_difficulty(diff))
except SystemExit:
chart = None
else:
path = ask("谱面文件路径: ")
if not path:
continue
try:
chart = load_from_file(path)
except SystemExit:
chart = None
th = theory_from_chart(chart)
print_chart(chart, th)
mode = ask("计算模式:1) 汇总判定数(估计值 + 区间) 2) 按音符类别给判定数(确切值)"
" 3) 由达成率倒算判定数 [1]: ", "1")
if mode.startswith("3"):
while True:
raw = ask("目标达成率(如 99.5 或 100.5): ")
if not raw:
return
try:
target = Fraction(raw.rstrip("%"))
break
except (ValueError, ZeroDivisionError):
print(" 请输入数字")
mode = ask("反算方式:1) 精确(只列真能打出该达成率的组合) 2) 区间(判定数只是估算时用) [1]: ", "1")
args.mode = "range" if mode.startswith("2") else "exact"
run_reverse(chart, th, target, args)
elif mode.startswith("2"):
spec = {}
for cls in CLASS_ORDER:
cap = class_capacity(th, cls)
if not cap:
continue
raw = ask(" %s(%d 个)CP/Perfect/Great/Good/Miss [%d/0/0/0/0]: "
% (CLASS_LABEL[cls], cap, cap))
if not raw:
continue
try:
spec[cls] = parse_class_counts(raw, cls)
except ValueError as exc:
print(" [错误] %s" % exc)
run_exact(chart, th, spec, args)
else:
values = {}
for j in JUDGMENTS:
if j == "miss":
continue
values[j] = ask_int(" %s 数 [0]: " % JUDGE_LABEL[j], 0) or 0
values["miss"] = ask_int(" Miss 数(留空 = 总数减去以上判定): ")
run_forward(chart, th, values, args)
# ------------------------------------------------------------------ 执行
def normalize_judges(th: Theory, raw: Dict[str, Optional[int]]) -> Dict[str, int]:
given = {j: int(raw.get(j) or 0) for j in JUDGMENTS}
if raw.get("miss") is None:
given["miss"] = th.total_notes - sum(given[j] for j in JUDGMENTS if j != "miss")
if given["miss"] < 0:
die("CP+Perfect+Great+Good 合计 %d 超过了音符总数 %d"
% (sum(given[j] for j in JUDGMENTS if j != "miss"), th.total_notes))
total = sum(given.values())
if total != th.total_notes:
die("判定数之和 %d 与音符总数 %d 不一致(Miss 请留空自动补足)" % (total, th.total_notes))
return given
_CLASS_KEYS = ("cp", "perfect", "great", "good", "miss")
def parse_class_counts(text: str, cls: str) -> Dict[str, int]:
"""把 "CP/Perfect/Great/Good/Miss"(斜杠、逗号或空格分隔)解析成判定数字典。"""
parts = [p for p in re.split(r"[\/,\s]+", str(text).strip()) if p]
if len(parts) > len(_CLASS_KEYS):
raise ValueError("%s 最多 %d 个数(CP/Perfect/Great/Good/Miss):%s"
% (CLASS_LABEL[cls], len(_CLASS_KEYS), text))
out: Dict[str, int] = {}
for key, raw in zip(_CLASS_KEYS, parts):
try:
n = int(raw)
except ValueError:
raise ValueError("%s 里的「%s」不是整数:%s" % (CLASS_LABEL[cls], raw, text))
if n < 0:
raise ValueError("%s 里不能有负数:%s" % (CLASS_LABEL[cls], text))
if n:
out[key] = n
return out
def build_spec(args) -> Dict[str, Dict[str, int]]:
"""把 --tap/--hold/--slide/--break 以及绝赞档位参数拼成 solve_exact 的输入。"""
spec: Dict[str, Dict[str, int]] = {}
for cls in CLASS_ORDER:
text = getattr(args, cls, None)
if text:
spec[cls] = parse_class_counts(text, cls)
brk = dict(spec.get("break") or {})
perfect_total = brk.get("perfect", 0)
great_total = brk.get("great", 0)
p50 = int(args.break_perfect50 or 0)
g40 = int(args.break_great40 or 0)
g50 = int(args.break_great50 or 0)
if p50 or g40 or g50:
if p50 > perfect_total:
raise ValueError("--break-perfect50 %d 超过绝赞 Perfect 总数 %d"
"(先在 --break 里写 CP/Perfect/Great/Good/Miss)" % (p50, perfect_total))
if g40 + g50 > great_total:
raise ValueError("--break-great40 + --break-great50 = %d 超过绝赞 Great 总数 %d"
% (g40 + g50, great_total))
if p50:
brk["perfect"] = perfect_total - p50
brk["perfect50"] = p50
if g40:
brk["great40"] = g40
if g50:
brk["great50"] = g50
if great_total - g40 - g50:
brk["great"] = great_total - g40 - g50
else:
brk.pop("great", None)
brk = {k: v for k, v in brk.items() if v}
spec["break"] = brk
return spec
def run_exact(chart: Chart, th: Theory, spec: Dict[str, Dict[str, int]], args) -> None:
try:
result = solve_exact(th, spec)
except ValueError as exc:
die(str(exc))
if args.json:
print(to_json(chart, th, {
"mode": "forward_exact",
"judges_by_class": result.spec,
"achievement": float(result.achievement),
"dx_score": result.dx,
"loss_total": result.loss_total,
"bonus_total": result.bonus_total,
}))
return
print_chart(chart, th)
print_exact(chart, result)
def run_forward(chart: Chart, th: Theory,
raw: Dict[str, Optional[int]], args) -> None:
judges = normalize_judges(th, raw)
result = solve_forward(th, judges)
if args.json:
print(to_json(chart, th, {
"mode": "forward",
"judges": judges,
"achievement_min": float(result.achievement_min),
"achievement_max": float(result.achievement_max),
"achievement_reference": float(result.achievement_ref),
"dx_score": result.dx,
}))
return
print_chart(chart, th)
print_forward(chart, result)
def run_reverse(chart: Chart, th: Theory,
target: Fraction, args) -> None:
outcome = solve_reverse(th, target, limit=args.limit, mode=args.mode)
if args.json:
print(to_json(chart, th, {
"mode": "reverse",
"search_mode": outcome["mode"],
"target": float(target),
"truncated": outcome["truncated"],
"exact_cap": outcome.get("exact_cap"),
"unverified": outcome.get("unverified", 0),
"more_unverified": outcome.get("more_unverified", False),
"note": outcome.get("note"),
"solutions": [{
"miss": r.miss, "good": r.good, "great": r.great,
"critical_perfect_plus_perfect": r.cp_plus_perfect,
"critical_perfect_min": r.cp_min, "critical_perfect_max": r.cp_max,
"achievement_min": float(r.achievement_min),
"achievement_max": float(r.achievement_max),
"dx_min": r.dx_min, "dx_max": r.dx_max,
} for r in outcome["rows"]],
}))
return
print_chart(chart, th)
print_reverse(chart, outcome)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="chart_achievement.py",
description="舞萌DX 谱面达成率 / DX 分计算器(正向计算 + 由达成率倒算判定数)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""示例:
# 直接给谱面文件,正向计算(汇总判定数 -> 估计值 + 区间)
python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --cp 290 --perfect 10 --great 2
# 按音符类别给判定数 -> 确切的达成率(CP/Perfect/Great/Good/Miss,可省略后面的)
python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --tap 260/0/3 --break 6
# 给游戏路径 + musicid + 难度(难度用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER)
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --cp 300 --perfect 2
# 由达成率倒算判定组合(精确模式:只列真能打出该值的组合)
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --achievement 99.5
# 判定数只是估算时,用区间模式
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --achievement 99.5 --mode range
""")
src = p.add_argument_group("谱面来源(二选一)")
src.add_argument("--chart", metavar="PATH", help=".ma2 谱面文件路径")
src.add_argument("--game", metavar="PATH", help="游戏目录(Sinmai_Data / StreamingAssets / A000 / music 均可)")
src.add_argument("--music-id", type=int, metavar="ID", help="曲目编号(配合 --game 使用)")
src.add_argument("--level", "--difficulty", dest="level", default=None, metavar="D",
help="谱面难度,用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT "
"3=MASTER 4=RE:MASTER(也可直接写名字;默认 3=MASTER)")
fw = p.add_argument_group("正向计算(汇总):只给总判定数,得到估计值 + 区间")
fw.add_argument("--cp", type=int, metavar="N", help="Critical Perfect 个数")
fw.add_argument("--perfect", type=int, metavar="N", help="Perfect 个数")
fw.add_argument("--great", type=int, metavar="N", help="Great 个数")
fw.add_argument("--good", type=int, metavar="N", help="Good 个数")
fw.add_argument("--miss", type=int, metavar="N", help="Miss 个数(留空 = 总数减去其它判定)")
ex = p.add_argument_group(
"正向计算(精确):按音符类别给判定数,得到确切的达成率",
"每个类别写 CP/Perfect/Great/Good/Miss 五个数(斜杠或空格分隔,后面可以省略),"
"没给满的部分按 CP 算。")
ex.add_argument("--tap", metavar="CP/P/G/GD/M",
help="Tap/Star/Touch/EX 类(权重 500)")
ex.add_argument("--hold", metavar="CP/P/G/GD/M", help="Hold 系(权重 1000)")
ex.add_argument("--slide", metavar="CP/P/G/GD/M", help="Slide 系(权重 1500)")
ex.add_argument("--break", dest="break", metavar="CP/P/G/GD/M",
help="绝赞(权重 2500;Great/Perfect 档位见下面三个参数)")
ex.add_argument("--break-perfect50", type=int, metavar="N",
help="绝赞 Perfect 里奖励分只有 50 的个数(Perfect2nd)")
ex.add_argument("--break-great40", type=int, metavar="N",
help="绝赞 Great 里失分 40%% 的个数(Great2nd)")
ex.add_argument("--break-great50", type=int, metavar="N",
help="绝赞 Great 里失分 50%% 的个数(Great3rd)")
rv = p.add_argument_group("反向计算:已知达成率")
rv.add_argument("--achievement", metavar="A", help="目标达成率(如 99.5 / 100.5)")
rv.add_argument("--mode", choices=("exact", "range"), default="exact",
help="exact(默认):只列真的能打出该达成率的组合;"
"range:列出达成率区间覆盖该值的组合(更快,输入不精确时用)")
rv.add_argument("--limit", type=int, default=200, metavar="N",
help="反向计算最多输出多少组解(默认 200)")
p.add_argument("--json", action="store_true", help="以 JSON 输出结果")
return p
def main(argv: Optional[List[str]] = None) -> int:
args = build_parser().parse_args(argv)
if args.chart and args.game:
die("--chart 与 --game 只能给一个")
if not args.chart and not args.game:
interactive(args)
return 0
if args.chart:
chart = load_from_file(args.chart)
if args.level is not None:
try:
want = parse_difficulty(args.level)
except ValueError as exc:
die(str(exc))
if chart.difficulty_index is not None and want != chart.difficulty_index:
print("[注意] --level 给的是 %s,但谱面文件名里是 %s,按文件名算"
% (DIFFICULTY_NAMES[want], DIFFICULTY_NAMES[chart.difficulty_index]),
file=sys.stderr)
else:
if args.music_id is None:
die("使用 --game 时必须同时给 --music-id")
if args.level is None:
print("[提示] 没有给 --level,按默认难度 3(MASTER)计算"
"(0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER)", file=sys.stderr)
try:
difficulty = parse_difficulty(args.level if args.level is not None else "master")
except ValueError as exc:
die(str(exc))
chart = load_from_game(args.game, args.music_id, difficulty)
th = theory_from_chart(chart)
if th.total_notes == 0:
die("谱面里没有解析到任何计分音符:%s" % chart.path)
if args.achievement:
try:
target = Fraction(str(args.achievement).rstrip("%"))
except (ValueError, ZeroDivisionError):
die("--achievement 需要是数字,如 99.5")
if target > th.max_achievement:
die("目标达成率 %s 超过该谱面上限 %s" % (pct(target), pct(th.max_achievement)))
if target < 0:
die("目标达成率不能为负")
run_reverse(chart, th, target, args)
return 0
class_flags = {"tap": args.tap, "hold": args.hold, "slide": args.slide,
"break": getattr(args, "break", None),
"perfect50": args.break_perfect50,
"great40": args.break_great40, "great50": args.break_great50}
if any(v for v in class_flags.values()):
if any(v is not None for v in (args.cp, args.perfect, args.great, args.good, args.miss)):
die("汇总判定数(--cp/--perfect/--great/--good/--miss)"
"和按类别判定数(--tap/--hold/--slide/--break)只能给一种")
try:
spec = build_spec(args)
except ValueError as exc:
die(str(exc))
run_exact(chart, th, spec, args)
return 0
raw = {"cp": args.cp, "perfect": args.perfect, "great": args.great,
"good": args.good, "miss": args.miss}
if all(v is None for v in raw.values()):
die("请给判定数:汇总用 --cp/--perfect/--great/--good,"
"精确用 --tap/--hold/--slide/--break;或用 --achievement 做反向计算")
run_forward(chart, th, raw, args)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+367
View File
@@ -0,0 +1,367 @@
# -*- coding: utf-8 -*-
"""舞萌DX (maimai DX) .ma2 谱面解析。
指令 -> 音符类型 的对应表来自反编译源码
source/Assembly-CSharp/Manager/Ma2fileRecordID.cs
(s_Ma2fileRecord_Data 里 Ma2Category.MA2_Note 的 102 条记录)
计分类别的划分复刻
source/Assembly-CSharp/Manager/GamePlayManager.cs NoteType2ScoreType()
理论分权重来自
source/Assembly-CSharp/Manager/NotesReader.cs calcTotal()
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Dict, List, Optional
# ---------------------------------------------------------------- 计分权重
WEIGHT_TAP = 500 # TAP / XTP / STR / XST / TTP(Touch 与 Tap 同为 500)
WEIGHT_HOLD = 1000 # HLD / XHO / THO
WEIGHT_SLIDE = 1500 # SI_ SCL SCR SUL SUR SSL SSR SV_ SXL SXR SLL SLR SF_
WEIGHT_BREAK = 2500 # BRK / BST / BRTAP / BRHLD / BRSTR / BRSI_ ... 以及 ExBreak 系
BREAK_BONUS_THEORY = 100 # 每个绝赞(Break)的理论奖励分
CATEGORY_TAP = "Tap"
CATEGORY_HOLD = "Hold"
CATEGORY_SLIDE = "Slide"
CATEGORY_BREAK = "Break"
CATEGORY_CONNECT = "ConnectSlide"
CATEGORY_ORDER = (CATEGORY_TAP, CATEGORY_HOLD, CATEGORY_SLIDE, CATEGORY_BREAK)
CATEGORY_WEIGHT = {
CATEGORY_TAP: WEIGHT_TAP,
CATEGORY_HOLD: WEIGHT_HOLD,
CATEGORY_SLIDE: WEIGHT_SLIDE,
CATEGORY_BREAK: WEIGHT_BREAK,
}
CATEGORY_LABEL = {
CATEGORY_TAP: "Tap/Touch",
CATEGORY_HOLD: "Hold",
CATEGORY_SLIDE: "Slide",
CATEGORY_BREAK: "Break",
}
# ------------------------------------------------- ma2 指令 -> NotesTypeID.Def
NOTE_COMMAND_TYPES: Dict[str, str] = {
# --- 现行指令 ---
"TAP": "Begin", "BRK": "Break", "XTP": "ExTap", "HLD": "Hold", "XHO": "ExHold",
"STR": "Star", "BST": "BreakStar", "XST": "ExStar",
"TTP": "TouchTap", "THO": "TouchHold",
"SI_": "Slide", "SCL": "Slide", "SCR": "Slide", "SUL": "Slide", "SUR": "Slide",
"SSL": "Slide", "SSR": "Slide", "SV_": "Slide", "SXL": "Slide", "SXR": "Slide",
"SLL": "Slide", "SLR": "Slide", "SF_": "Slide",
# --- 旧式别名(游戏数据里真实存在)---
"NMTAP": "Begin", "BRTAP": "Break", "EXTAP": "ExTap", "BXTAP": "ExBreakTap",
"NMHLD": "Hold", "BRHLD": "BreakHold", "EXHLD": "ExHold", "BXHLD": "ExBreakHold",
"NMSTR": "Star", "BRSTR": "BreakStar", "EXSTR": "ExStar", "BXSTR": "ExBreakStar",
"NMTTP": "TouchTap", "NMTHO": "TouchHold",
"NMSI_": "Slide", "BRSI_": "BreakSlide", "EXSI_": "ExSlide", "BXSI_": "ExBreakSlide", "CNSI_": "ConnectSlide",
"NMSCL": "Slide", "BRSCL": "BreakSlide", "EXSCL": "ExSlide", "BXSCL": "ExBreakSlide", "CNSCL": "ConnectSlide",
"NMSCR": "Slide", "BRSCR": "BreakSlide", "EXSCR": "ExSlide", "BXSCR": "ExBreakSlide", "CNSCR": "ConnectSlide",
"NMSUL": "Slide", "BRSUL": "BreakSlide", "EXSUL": "ExSlide", "BXSUL": "ExBreakSlide", "CNSUL": "ConnectSlide",
"NMSUR": "Slide", "BRSUR": "BreakSlide", "EXSUR": "ExSlide", "BXSUR": "ExBreakSlide", "CNSUR": "ConnectSlide",
"NMSSL": "Slide", "BRSSL": "BreakSlide", "EXSSL": "ExSlide", "BXSSL": "ExBreakSlide", "CNSSL": "ConnectSlide",
"NMSSR": "Slide", "BRSSR": "BreakSlide", "EXSSR": "ExSlide", "BXSSR": "ExBreakSlide", "CNSSR": "ConnectSlide",
"NMSV_": "Slide", "BRSV_": "BreakSlide", "EXSV_": "ExSlide", "BXSV_": "ExBreakSlide", "CNSV_": "ConnectSlide",
"NMSXL": "Slide", "BRSXL": "BreakSlide", "EXSXL": "ExSlide", "BXSXL": "ExBreakSlide", "CNSXL": "ConnectSlide",
"NMSXR": "Slide", "BRSXR": "BreakSlide", "EXSXR": "ExSlide", "BXSXR": "ExBreakSlide", "CNSXR": "ConnectSlide",
"NMSLL": "Slide", "BRSLL": "BreakSlide", "EXSLL": "ExSlide", "BXSLL": "ExBreakSlide", "CNSLL": "ConnectSlide",
"NMSLR": "Slide", "BRSLR": "BreakSlide", "EXSLR": "ExSlide", "BXSLR": "ExBreakSlide", "CNSLR": "ConnectSlide",
"NMSF_": "Slide", "BRSF_": "BreakSlide", "EXSF_": "ExSlide", "BXSF_": "ExBreakSlide", "CNSF_": "ConnectSlide",
}
_BREAK_DEFS = frozenset((
"Break", "BreakHold", "BreakStar", "BreakSlide",
"ExBreakTap", "ExBreakHold", "ExBreakStar", "ExBreakSlide",
))
_SLIDE_DEFS = frozenset(("Slide", "BreakSlide", "ExSlide", "ExBreakSlide"))
_HOLD_DEFS = frozenset(("Hold", "BreakHold", "ExHold", "ExBreakHold", "TouchHold"))
_TOUCH_DEFS = frozenset(("TouchTap", "TouchHold"))
_CONNECT_DEFS = frozenset(("ConnectSlide",))
# 判定时机只有 Critical / TooLate 的 EX 音符(ExTap/ExStar/ExBreakTap/ExBreakStar)
EX_DEFS = frozenset(("ExTap", "ExStar", "ExBreakTap", "ExBreakStar"))
# ---------------------------------------------------------------- 音符组
# 「音符组」= 计分行为完全相同的一类音符:权重、可选判定、失分档位、绝赞奖励档位都一样。
# 划分依据(反编译源码):
# * 计分类型 NoteScore.EScoreType 由 GamePlayManager.NoteType2ScoreType() 决定
# (Break 系 -> Break,滑条 -> Slide,长按 -> Hold,其余 -> Tap/Touch)
# * 判定时机表 NoteJudge.EJudgeType 由各音符类决定:
# TapNote/StarNote -> Tap(或 ExNote 时 ExTap)
# HoldNote/BreakHoldNote/TouchHoldC -> 长按结算 JudgeHoldTotal(不分 2nd/3rd 档)
# SlideRoot/SlideFan -> SlideOut(不分 2nd/3rd 档,也没有非 Critical 的 Perfect)
# BreakNote/BreakStarNote -> Break 表(Great 有 1250/1500/2000 三档)
# * ExTap 判定表 [-9,-9,-9,-9,-9,-9,-9,0,9,9,9,9,9,9,9] 使非 Critical 的档位全部不可达
# -> ExTap/ExStar(含 ExBreak 版)只能 Critical 或 Miss
# * ExHold/ExBreakHold 的头部用 ExTap 表,但结果还要过 JudgeHoldTotal 重映射,
# 最终仍可得到 Critical/Perfect/Great/Good/Miss -> 与普通 Hold / BreakHold 相同
# * ExSlide / ExBreakSlide 的 JudgeType 恒为 SlideOut,且 isBreak() 不含 ExBreakSlide
# -> 计分与普通 Slide 完全一致(游戏数据里这两种指令出现次数为 0)
GROUP_TAP = "tap" # TAP NMTAP STR NMSTR TTP NMTTP 权重 500
GROUP_HOLD = "hold" # HLD NMHLD XHO EXHLD THO NMTHO 权重 1000
GROUP_SLIDE = "slide" # SI_ SCL SCR SUL SUR SSL SSR SV_ SXL SXR SLL SLR SF_ + NM/EX/CN 变体 权重 1500
GROUP_BREAK = "break" # BRK BRTAP BST BRSTR 权重 2500,Great 三档、Perfect 奖励两档
GROUP_BREAK_FLAT = "break_flat" # BRHLD BXHLD 权重 2500,长按判定,不分档
GROUP_BREAK_SLIDE = "break_slide" # BRSI_ BRSCL BRSCR BRSUL BRSUR BRSSL BRSSR BRSV_ BRSXL BRSXR BRSLL BRSLR BRSF_
GROUP_EX = "ex" # XTP EXTAP XST EXSTR 权重 500,只能 Critical / Miss
GROUP_EX_BREAK = "ex_break" # BXTAP BXSTR 权重 2500,只能 Critical / Miss
GROUP_ORDER = (GROUP_TAP, GROUP_HOLD, GROUP_SLIDE, GROUP_BREAK,
GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX, GROUP_EX_BREAK)
GROUP_LABEL = {
GROUP_TAP: "Tap/Star/Touch",
GROUP_HOLD: "Hold",
GROUP_SLIDE: "Slide",
GROUP_BREAK: "Break",
GROUP_BREAK_FLAT: "Break(长按判定)",
GROUP_BREAK_SLIDE: "Break(滑条判定)",
GROUP_EX: "EX Tap/Star",
GROUP_EX_BREAK: "EX Break",
}
GROUP_WEIGHT = {
GROUP_TAP: WEIGHT_TAP,
GROUP_HOLD: WEIGHT_HOLD,
GROUP_SLIDE: WEIGHT_SLIDE,
GROUP_BREAK: WEIGHT_BREAK,
GROUP_BREAK_FLAT: WEIGHT_BREAK,
GROUP_BREAK_SLIDE: WEIGHT_BREAK,
GROUP_EX: WEIGHT_TAP,
GROUP_EX_BREAK: WEIGHT_BREAK,
}
GROUP_IS_BREAK = frozenset((GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX_BREAK))
_GROUP_BY_DEF = {
"Begin": GROUP_TAP, "Star": GROUP_TAP, "TouchTap": GROUP_TAP,
"Hold": GROUP_HOLD, "ExHold": GROUP_HOLD, "TouchHold": GROUP_HOLD,
"Slide": GROUP_SLIDE, "ExSlide": GROUP_SLIDE, "ExBreakSlide": GROUP_SLIDE,
"Break": GROUP_BREAK, "BreakStar": GROUP_BREAK,
"BreakHold": GROUP_BREAK_FLAT, "ExBreakHold": GROUP_BREAK_FLAT,
"BreakSlide": GROUP_BREAK_SLIDE,
"ExTap": GROUP_EX, "ExStar": GROUP_EX,
"ExBreakTap": GROUP_EX_BREAK, "ExBreakStar": GROUP_EX_BREAK,
}
_CATEGORY_BY_GROUP = {
GROUP_TAP: CATEGORY_TAP, GROUP_HOLD: CATEGORY_HOLD, GROUP_SLIDE: CATEGORY_SLIDE,
GROUP_BREAK: CATEGORY_BREAK, GROUP_BREAK_FLAT: CATEGORY_BREAK,
GROUP_BREAK_SLIDE: CATEGORY_BREAK, GROUP_EX: CATEGORY_TAP, GROUP_EX_BREAK: CATEGORY_BREAK,
}
def note_group(def_name: str) -> Optional[str]:
"""NotesTypeID.Def 名 -> 音符组;接续滑条等不计分音符返回 None。"""
return _GROUP_BY_DEF.get(def_name)
def score_category(def_name: str) -> Optional[str]:
"""复刻 GamePlayManager.NoteType2ScoreType() 的粗分类(Tap/Hold/Slide/Break)。"""
grp = note_group(def_name)
if grp is None:
return None
return _CATEGORY_BY_GROUP[grp]
# ------------------------------------------------------------------ 谱面对象
@dataclass
class Chart:
path: str = "<memory>"
command_counts: Dict[str, int] = field(default_factory=dict)
category_counts: Dict[str, int] = field(default_factory=dict)
group_counts: Dict[str, int] = field(default_factory=dict)
connect_count: int = 0
header: Dict[str, str] = field(default_factory=dict)
totals: Dict[str, int] = field(default_factory=dict)
warnings: List[str] = field(default_factory=list)
# 外部补充的信息(游戏路径模式)
music_id: Optional[int] = None
difficulty_index: Optional[int] = None
difficulty_name: Optional[str] = None
song_name: Optional[str] = None
level_text: Optional[str] = None
max_notes_xml: Optional[int] = None
@property
def total_notes(self) -> int:
"""计分音符总数 N(不含接续滑条)。"""
return sum(self.group_counts.get(g, 0) for g in GROUP_ORDER)
@property
def declared_total(self) -> Optional[int]:
return self.totals.get("T_NUM_ALL")
def counts(self) -> Dict[str, int]:
"""按音符组统计(供计分模型使用)。"""
return {g: self.group_counts.get(g, 0) for g in GROUP_ORDER}
def category_summary(self) -> Dict[str, int]:
return {c: self.category_counts.get(c, 0) for c in CATEGORY_ORDER}
def parse_ma2(path: str) -> Chart:
with open(path, "rb") as fp:
raw = fp.read()
chart = parse_ma2_bytes(raw, path)
return chart
def parse_ma2_bytes(raw: bytes, path: str = "<memory>") -> Chart:
chart = Chart(path=path)
text = raw.decode("latin1")
unknown = set()
for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) == 1:
parts = line.split()
if not parts:
continue
cmd = parts[0]
args = parts[1:]
def_name = NOTE_COMMAND_TYPES.get(cmd)
if def_name is not None:
chart.command_counts[cmd] = chart.command_counts.get(cmd, 0) + 1
grp = note_group(def_name)
if grp is None:
chart.connect_count += 1
else:
chart.group_counts[grp] = chart.group_counts.get(grp, 0) + 1
cat = score_category(def_name)
chart.category_counts[cat] = chart.category_counts.get(cat, 0) + 1
continue
if cmd.startswith("T_") or cmd.startswith("TTM_"):
if args:
try:
chart.totals[cmd] = int(args[0])
except ValueError:
pass
continue
if cmd not in chart.header and args:
chart.header[cmd] = "\t".join(args)
elif not args and cmd.isupper() and len(cmd) > 2:
unknown.add(cmd)
declared = chart.declared_total
if declared is not None and declared != chart.total_notes:
chart.warnings.append(
"T_NUM_ALL=%d 与解析出的计分音符数 %d 不一致" % (declared, chart.total_notes)
)
return chart
# ------------------------------------------------------------ 游戏路径解析
DIFFICULTY_NAMES = ("Basic", "Advanced", "Expert", "Master", "Re:Master")
_DIFFICULTY_ALIASES = {
"basic": 0, "bas": 0, "bsc": 0, "0": 0, "00": 0,
"advanced": 1, "adv": 1, "1": 1, "01": 1,
"expert": 2, "exp": 2, "2": 2, "02": 2,
"master": 3, "mas": 3, "mst": 3, "3": 3, "03": 3,
"remaster": 4, "re:master": 4, "remas": 4, "re": 4, "4": 4, "04": 4,
}
def parse_difficulty(text) -> int:
if isinstance(text, int):
if 0 <= text <= 4:
return text
raise ValueError("难度序号必须是 0~4")
key = str(text).strip().lower()
if key in _DIFFICULTY_ALIASES:
return _DIFFICULTY_ALIASES[key]
raise ValueError("无法识别的难度:%s(可用 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER,"
"或直接写名字)" % text)
def find_music_dir(game_path: str) -> str:
"""把「游戏根目录 / Sinmai_Data / StreamingAssets / A000」都归一化到 A000/music。"""
p = os.path.abspath(os.path.expanduser(game_path))
if not os.path.isdir(p):
raise FileNotFoundError("路径不存在:%s" % p)
if os.path.basename(p).lower() == "music" and os.path.basename(os.path.dirname(p)).upper() == "A000":
return p
candidates = [
p,
os.path.join(p, "music"),
os.path.join(p, "A000", "music"),
os.path.join(p, "StreamingAssets", "A000", "music"),
os.path.join(p, "Sinmai_Data", "StreamingAssets", "A000", "music"),
os.path.join(p, "Package", "Sinmai_Data", "StreamingAssets", "A000", "music"),
]
for c in candidates:
if os.path.isdir(c) and os.path.basename(c).lower() == "music":
return c
raise FileNotFoundError("在 %s 下找不到 A000/music 目录" % p)
def music_dir_path(music_dir: str, music_id: int) -> str:
return os.path.join(music_dir, "music%06d" % music_id)
def chart_path(music_dir: str, music_id: int, difficulty: int) -> str:
return os.path.join(music_dir_path(music_dir, music_id), "%06d_%02d.ma2" % (music_id, difficulty))
def parse_music_xml(path: str) -> Dict[str, object]:
"""读取 Music.xml(只用标准库),取出曲名与各难度信息。"""
import xml.etree.ElementTree as ET
info: Dict[str, object] = {"notes": []}
try:
root = ET.parse(path).getroot()
except Exception as exc: # noqa: BLE001
return {"error": str(exc), "notes": []}
def text_of(node, tag):
child = node.find(tag)
if child is None:
return None
if child.text and child.text.strip():
return child.text.strip()
sub = child.find("str")
if sub is not None and sub.text and sub.text.strip():
return sub.text.strip()
return None
info["name"] = text_of(root, "name")
info["artist"] = text_of(root, "artistName")
info["genre"] = text_of(root, "genreName")
info["bpm"] = text_of(root, "bpm")
notes_data = root.find("notesData")
notes = []
if notes_data is not None:
for node in notes_data.findall("Notes"):
f = node.find("file/path")
notes.append({
"file": f.text.strip() if f is not None and f.text else None,
"level": text_of(node, "level"),
"levelDecimal": text_of(node, "levelDecimal"),
"musicLevelID": text_of(node, "musicLevelID"),
"maxNotes": text_of(node, "maxNotes"),
"designer": text_of(node, "notesDesigner"),
"isEnable": text_of(node, "isEnable"),
})
info["notes"] = notes
return info
def level_display(level, level_decimal) -> str:
"""12 + levelDecimal 4 -> '12+';10 + 2 -> '10+';7 + 0 -> '7'。"""
try:
lv = int(level)
except (TypeError, ValueError):
return ""
try:
dec = int(level_decimal or 0)
except (TypeError, ValueError):
dec = 0
if dec >= 3:
return "%d+" % lv
if dec > 0:
return "%d.%d" % (lv, dec)
return "%d" % lv
+949
View File
@@ -0,0 +1,949 @@
# -*- coding: utf-8 -*-
"""舞萌DX 达成率 / DX 分计算模型(按音符组精确建模)。
达成率(source/Assembly-CSharp/Manager/GameScoreList.cs:1238-1310 CalcUpdate())::
Achivement = num / _allPerfectScore * 100 + num2 / _breakBonusScore
其中 num = Σ 普通得分 NoteScore.GetJudgeScore(timing, 计分类型)
num2 = Σ 绝赞奖励分(只有绝赞音符有)NoteScore.GetJudgeScore(timing, BreakBonus)
_allPerfectScore = Σ 每个音符的满权重(NotesReader.calcTotal())
_breakBonusScore = 100 × 绝赞音符数
满分 = 101.0000%(有绝赞)/ 100.0000%(无绝赞)。
DX 分(JudgeResultSt.UpdateScore + NoteJudge.ConvertJudge)::
DX = 3×CriticalPerfect + 2×Perfect + 1×Great(Good/Miss 不加分)
与音符类型无关,理论 DX = 3 × 计分音符数。
模型要点
--------
* 音符按「组」划分,同一组内计分行为完全一致(权重、可选判定、失分档位、绝赞奖励档位)。
* 绝赞(Break 类)的 Great 有 3 档失分、Perfect 奖励有 2 档(75 / 50),滑条判定的绝赞
没有 Perfect 档,长按判定的绝赞不分档;EX 判定(ExTap 表)只能 Critical 或 Miss。
* 因此「给定各判定的总个数」并不能唯一确定达成率:达成率依赖这些判定落在哪些音符上。
本模块用最小费用流求达成率的精确上下界,并可严格判定「能否恰好打出某个达成率」。
"""
from __future__ import annotations
import math
import time
from collections import deque
from dataclasses import dataclass, field
from fractions import Fraction
from typing import Dict, List, Optional, Sequence, Tuple
from ma2 import (GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX,
GROUP_EX_BREAK, GROUP_HOLD, GROUP_LABEL, GROUP_ORDER,
GROUP_SLIDE, GROUP_TAP, GROUP_WEIGHT, Chart)
# ------------------------------------------------------------------ 常量
JUDGMENTS = ("cp", "perfect", "great", "good", "miss")
JUDGE_LABEL = {
"cp": "Critical Perfect",
"perfect": "Perfect",
"great": "Great",
"good": "Good",
"miss": "Miss",
}
DX_PER = {"cp": 3, "perfect": 2, "great": 1, "good": 0, "miss": 0}
#: 每个音符组里,某判定实际可能出现的 (失分, 绝赞奖励分) 组合。
#: 失分 = 该音符满权重 - 实际得分;奖励分只对绝赞(计分类型 Break)音符存在。
GROUP_OPTIONS: Dict[str, Dict[str, Tuple[Tuple[int, int], ...]]] = {
GROUP_TAP: {
"cp": ((0, 0),),
"perfect": ((0, 0),),
"great": ((100, 0),), # 400
"good": ((250, 0),), # 250
"miss": ((500, 0),), # 0
},
GROUP_HOLD: {
"cp": ((0, 0),),
"perfect": ((0, 0),),
"great": ((200, 0),), # 800
"good": ((500, 0),), # 500
"miss": ((1000, 0),), # 0
},
GROUP_SLIDE: {
"cp": ((0, 0),),
"perfect": ((0, 0),),
"great": ((300, 0),), # 1200
"good": ((750, 0),), # 750
"miss": ((1500, 0),), # 0
},
GROUP_BREAK: {
"cp": ((0, 100),),
"perfect": ((0, 75), (0, 50)), # FastPerfect/LatePerfect 与 2nd 档
"great": ((500, 40), (1000, 40), (1250, 40)), # 2000 / 1500 / 1250
"good": ((1500, 30),), # 1000
"miss": ((2500, 0),), # 0
},
GROUP_BREAK_FLAT: {
"cp": ((0, 100),),
"perfect": ((0, 75),), # 长按判定没有 2nd 档
"great": ((500, 40),), # 只有 2000 一档
"good": ((1500, 30),),
"miss": ((2500, 0),),
},
GROUP_BREAK_SLIDE: {
"cp": ((0, 100),),
# 滑条判定没有非 Critical 的 Perfect
"great": ((500, 40), (1000, 40), (1250, 40)),
"good": ((1500, 30),),
"miss": ((2500, 0),),
},
GROUP_EX: {
"cp": ((0, 0),),
"miss": ((500, 0),), # ExTap 判定表只有 Critical / TooFast / TooLate
},
GROUP_EX_BREAK: {
"cp": ((0, 100),),
"miss": ((2500, 0),),
},
}
_BAD_JUDGMENTS = ("great", "good", "miss")
#: 坏音符落在该组时,对 (可出现的绝赞音符数 bad_b, 加权和 slo) 的贡献。
#: slo = 2×bad_break + 3×bad_break_flat + 4×bad_break_slide + 4×bad_ex_break,
#: 用于还原「绝赞音符里 cp/perfect 的奖励分取值范围」。
_GROUP_BAD_STEP = {
GROUP_BREAK: (1, 2),
GROUP_BREAK_FLAT: (1, 3),
GROUP_BREAK_SLIDE: (1, 4),
GROUP_EX_BREAK: (1, 4),
}
#: 精确模式严格验证的最大坏音符数(Miss+Good+Great 总和)。超过则只做区间判定。
EXACT_MAX_BAD = 12 # 精确验证时最多考虑多少个坏音符(Miss+Good+Great)
EXACT_TIME_BUDGET = 8.0 # 精确验证 DP 的时间预算(秒),超时退回区间模式
# ------------------------------------------------------------------ 理论值
@dataclass
class Theory:
"""一个谱面的计分理论值。"""
counts: Dict[str, int] # 音符组 -> 音符数
weights: Dict[str, int] # 音符组 -> 单音符满权重
all_perfect_score: int # _allPerfectScore
break_bonus_score: int # _breakBonusScore = 100 × 绝赞音符数
total_notes: int # 计分音符总数 N(不含接续滑条)
max_dx: int # 理论 DX = 3N
lattice: int # 所有单音符失分-奖励组合的最大公约数
@property
def has_break(self) -> bool:
return self.break_bonus_score > 0
@property
def break_notes(self) -> int:
"""绝赞音符总数(计分类型为 Break 的音符)。"""
return sum(self.counts.get(g, 0) for g in _GROUP_BAD_STEP)
@property
def denominator(self) -> int:
"""达成率公式的分母:100 - Σc/D 中的 D。"""
if self.break_bonus_score:
return self.all_perfect_score * self.break_bonus_score
return self.all_perfect_score
@property
def max_achievement(self) -> Fraction:
return Fraction(101 if self.break_bonus_score else 100)
@property
def perfect_capacity(self) -> int:
"""允许出现 Perfect 的绝赞音符数(Break 与 Break(长按判定))。"""
return (self.counts.get(GROUP_BREAK, 0)
+ self.counts.get(GROUP_BREAK_FLAT, 0))
def _lattice_of(th: "Theory") -> int:
g = 0
for group in GROUP_ORDER:
for opts in GROUP_OPTIONS[group].values():
for loss, bonus in opts:
g = math.gcd(g, _cost(th, loss, bonus))
return g
def theory_from_counts(counts: Dict[str, int]) -> Theory:
"""由「音符组 -> 音符数」构造理论值。"""
clean: Dict[str, int] = {}
for key, value in (counts or {}).items():
if key not in GROUP_OPTIONS:
raise ValueError("未知的音符组:%r" % (key,))
value = int(value or 0)
if value < 0:
raise ValueError("音符数不能为负:%s=%d" % (key, value))
if value:
clean[key] = value
all_perfect = 0
break_notes = 0
total = 0
for group in GROUP_ORDER:
n = clean.get(group, 0)
if not n:
continue
weight = GROUP_WEIGHT[group]
all_perfect += weight * n
total += n
if group in _GROUP_BAD_STEP:
break_notes += n
th = Theory(
counts={g: clean.get(g, 0) for g in GROUP_ORDER},
weights={g: GROUP_WEIGHT[g] for g in GROUP_ORDER},
all_perfect_score=all_perfect,
break_bonus_score=100 * break_notes,
total_notes=total,
max_dx=3 * total,
lattice=0,
)
th.lattice = _lattice_of(th)
return th
def theory_from_chart(chart: Chart) -> Theory:
return theory_from_counts(chart.counts())
# ------------------------------------------------------------------ 单音符代价
def _cost(th: Theory, loss: int, bonus: int) -> int:
"""把 (失分, 奖励分) 折算成一个整数代价 c,使达成率 = 100 - Σc / D。"""
if th.break_bonus_score:
return 100 * th.break_bonus_score * loss - th.all_perfect_score * bonus
return 100 * loss
def _edge_cost(th: Theory, group: str, judge: str, want_max: bool) -> Optional[int]:
opts = GROUP_OPTIONS.get(group, {}).get(judge)
if not opts:
return None
values = [_cost(th, loss, bonus) for loss, bonus in opts]
# want_max 表示「追求最大达成率」,而达成率 = 100 - Σc/D,所以要取最小代价。
return min(values) if want_max else max(values)
def _flex_edge_cost(th: Theory, group: str, judge: str, want_max: bool) -> Optional[int]:
"""与 _edge_cost 相同,但「好音符」(CP/Perfect)可以在 CP 与 Perfect 之间自由选择。
只在反向求解的上下界里用:用户没给 CP/Perfect 拆分时,这个选择会影响达成率
(绝赞的 CP 奖励 100、Perfect 只有 75/50)。
"""
if judge == "cp":
# 「其余音符」这一档:既可以是 Critical Perfect,也可以是 Perfect
# (Perfect 只能落在绝赞音符上,交给流自己挑)
opts = (tuple(GROUP_OPTIONS.get(group, {}).get("cp", ()))
+ tuple(GROUP_OPTIONS.get(group, {}).get("perfect", ())))
else:
opts = GROUP_OPTIONS.get(group, {}).get(judge)
if not opts:
return None
values = [_cost(th, loss, bonus) for loss, bonus in opts]
return min(values) if want_max else max(values)
# ------------------------------------------------------------------ 最小费用流
class _MinCostFlow:
"""连续最短路 + 整条增广(边数很少,够快)。"""
def __init__(self, n: int) -> None:
self.n = n
self.graph: List[List[List[int]]] = [[] for _ in range(n)]
def add_edge(self, u: int, v: int, cap: int, cost: int) -> None:
if cap <= 0:
return
self.graph[u].append([v, cap, cost, len(self.graph[v])])
self.graph[v].append([u, 0, -cost, len(self.graph[u]) - 1])
def min_cost(self, s: int, t: int, need: int) -> Optional[int]:
if need <= 0:
return 0
total = 0
flow = 0
INF = float("inf")
while flow < need:
dist = [INF] * self.n
prev_v = [-1] * self.n
prev_e = [-1] * self.n
in_queue = [False] * self.n
dist[s] = 0
queue = deque([s])
in_queue[s] = True
while queue:
u = queue.popleft()
in_queue[u] = False
du = dist[u]
for i, edge in enumerate(self.graph[u]):
v, cap, cost, _rev = edge
if cap > 0 and du + cost < dist[v]:
dist[v] = du + cost
prev_v[v] = u
prev_e[v] = i
if not in_queue[v]:
in_queue[v] = True
queue.append(v)
if dist[t] == INF:
return None
add = need - flow
v = t
while v != s:
add = min(add, self.graph[prev_v[v]][prev_e[v]][1])
v = prev_v[v]
v = t
while v != s:
edge = self.graph[prev_v[v]][prev_e[v]]
edge[1] -= add
self.graph[v][edge[3]][1] += add
v = prev_v[v]
flow += add
total += add * dist[t]
return total
def _extreme(th: Theory, judges: Dict[str, int], want_max: bool,
flex_good: bool = False) -> Optional[Fraction]:
"""在给定各判定总数的前提下,求达成率的极大值 / 极小值。
flex_good=True 时,「good」这一类里的 CP/Perfect 拆分由流自己挑
(反向求解用:只给 Miss/Good/Great 时,CP 与 Perfect 的拆分是自由的)。
"""
total = sum(int(judges.get(j, 0)) for j in JUDGMENTS)
if total != th.total_notes:
raise ValueError("判定总数 %d 与谱面音符数 %d 不一致" % (total, th.total_notes))
if total == 0:
return Fraction(0)
groups = [g for g in GROUP_ORDER if th.counts.get(g, 0)]
src = 0
gbase = 1
jbase = gbase + len(groups)
sink = jbase + len(JUDGMENTS)
mcf = _MinCostFlow(sink + 1)
for i, group in enumerate(groups):
mcf.add_edge(src, gbase + i, th.counts[group], 0)
for j, judge in enumerate(JUDGMENTS):
count = int(judges.get(judge, 0))
if count:
mcf.add_edge(jbase + j, sink, count, 0)
for i, group in enumerate(groups):
for j, judge in enumerate(JUDGMENTS):
count = int(judges.get(judge, 0))
if not count:
continue
cost = (_flex_edge_cost(th, group, judge, want_max) if flex_good
else _edge_cost(th, group, judge, want_max))
if cost is None:
continue # 该判定不可能落在这类音符上:不连边,交给流去判定可行性
# 最小费用流只会最小化总代价:求最小达成率时把代价取负,最后再翻回来。
mcf.add_edge(gbase + i, jbase + j, min(th.counts[group], count),
cost if want_max else -cost)
total_cost = mcf.min_cost(src, sink, total)
if total_cost is None:
return None
if not want_max:
total_cost = -total_cost
return Fraction(100) - Fraction(total_cost, th.denominator)
def achievement_bounds(th: Theory, judges: Dict[str, int]) -> Tuple[Fraction, Fraction]:
"""返回 (达成率下界, 达成率上界)。"""
lo = _extreme(th, judges, want_max=False)
hi = _extreme(th, judges, want_max=True)
if lo is None or hi is None:
raise ValueError("这些判定数无法落在该谱面上(例如把 Perfect 给了 EX 音符)")
return lo, hi
def reference_achievement(th: Theory, judges: Dict[str, int]) -> Fraction:
"""按比例分摊的参考达成率:各判定按总数比例摊到每个音符组,档位取中值。"""
n = th.total_notes
if n == 0:
return Fraction(0)
loss = Fraction(0)
bonus = Fraction(0)
for group in GROUP_ORDER:
count = th.counts.get(group, 0)
if not count:
continue
for judge in JUDGMENTS:
given = int(judges.get(judge, 0))
if not given:
continue
opts = GROUP_OPTIONS[group].get(judge)
if not opts:
continue
losses = sorted({o[0] for o in opts})
bonuses = sorted({o[1] for o in opts})
share = Fraction(given * count, n)
loss += share * Fraction(losses[0] + losses[-1], 2)
bonus += share * Fraction(bonuses[0] + bonuses[-1], 2)
result = Fraction(100) - 100 * loss / th.all_perfect_score
if th.break_bonus_score:
result += bonus / th.break_bonus_score
return result
def dx_score(judges: Dict[str, int]) -> int:
return sum(DX_PER[j] * int(judges.get(j, 0)) for j in JUDGMENTS)
# ------------------------------------------------------------------ 正向
@dataclass
class ForwardResult:
theory: Theory
judges: Dict[str, int]
achievement_min: Fraction
achievement_max: Fraction
achievement_ref: Fraction
dx: int
@property
def exact(self) -> bool:
return self.achievement_min == self.achievement_max
@property
def achievement(self) -> Fraction:
return self.achievement_min
def solve_forward(th: Theory, judges: Dict[str, int]) -> ForwardResult:
clean = {j: int(judges.get(j, 0) or 0) for j in JUDGMENTS}
total = sum(clean.values())
if total != th.total_notes:
raise ValueError("判定总数 %d 与谱面音符数 %d 不一致" % (total, th.total_notes))
lo, hi = achievement_bounds(th, clean)
return ForwardResult(
theory=th,
judges=clean,
achievement_min=lo,
achievement_max=hi,
achievement_ref=reference_achievement(th, clean),
dx=dx_score(clean),
)
# ------------------------------------------------------------------ 正向:按音符类别精确计算
# 达成率是按「音符权重」加权的,只给汇总判定数时只能得到区间;
# 把判定数按权重类别分开给(绝赞单独一列),就能算出确切的数值。
CLASS_ORDER = ("tap", "hold", "slide", "break")
CLASS_LABEL = {
"tap": "Tap/Star/Touch/EX",
"hold": "Hold 系",
"slide": "Slide 系",
"break": "绝赞(Break 系)",
}
CLASS_WEIGHT = {"tap": 500, "hold": 1000, "slide": 1500, "break": 2500}
CLASS_GROUPS = {
"tap": (GROUP_TAP, GROUP_EX),
"hold": (GROUP_HOLD,),
"slide": (GROUP_SLIDE,),
"break": (GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX_BREAK),
}
# 每类里每个判定键的 (失分, 奖励分);绝赞的 Great / Perfect 有多个档位
CLASS_OPTIONS = {
"tap": {"cp": (0, 0), "perfect": (0, 0), "great": (100, 0),
"good": (250, 0), "miss": (500, 0)},
"hold": {"cp": (0, 0), "perfect": (0, 0), "great": (200, 0),
"good": (500, 0), "miss": (1000, 0)},
"slide": {"cp": (0, 0), "perfect": (0, 0), "great": (300, 0),
"good": (750, 0), "miss": (1500, 0)},
"break": {"cp": (0, 100), "perfect": (0, 75), "perfect50": (0, 50),
"great": (500, 40), "great40": (1000, 40), "great50": (1250, 40),
"good": (1500, 30), "miss": (2500, 0)},
}
_JUDGE_OF_KEY = {
"cp": "cp", "perfect": "perfect", "perfect50": "perfect",
"great": "great", "great40": "great", "great50": "great",
"good": "good", "miss": "miss",
}
def class_capacity(th: Theory, cls: str) -> int:
"""这一类音符在谱面里有多少个。"""
return sum(th.counts.get(g, 0) for g in CLASS_GROUPS[cls])
@dataclass
class ExactResult:
theory: Theory
spec: Dict[str, Dict[str, int]] # 实际参与计算的判定数(已补足 CP)
achievement: Fraction
dx: int
loss_total: int
bonus_total: int
breakdown: Dict[str, Tuple[int, int, int]] # 类别 -> (音符数, 失分, 奖励分)
@property
def score(self) -> int:
return self.theory.all_perfect_score - self.loss_total
def solve_exact(th: Theory, spec: Dict[str, Dict[str, int]]) -> ExactResult:
"""按音符类别给判定数,算出**确切**的达成率与 DX 分。
spec: {"tap": {"cp": 250, "great": 2}, "break": {"cp": 4, "great": 1}, ...}
每类没给满的部分按 CP 补足(非绝赞 CP 与 Perfect 同分;绝赞按 CP 计奖励 100)。
"""
filled: Dict[str, Dict[str, int]] = {}
loss_total = 0
bonus_total = 0
dx = 0
breakdown: Dict[str, Tuple[int, int, int]] = {}
for cls in CLASS_ORDER:
cap = class_capacity(th, cls)
given = {k: int(v) for k, v in (spec.get(cls) or {}).items() if int(v)}
for key in given:
if key not in CLASS_OPTIONS[cls]:
raise ValueError("「%s」没有「%s」这个判定" % (CLASS_LABEL[cls], key))
used = sum(given.values())
if used > cap:
raise ValueError("%s 只有 %d 个音符,但给了 %d 个判定"
% (CLASS_LABEL[cls], cap, used))
if cap > used:
given["cp"] = given.get("cp", 0) + (cap - used)
filled[cls] = given
cls_loss = 0
cls_bonus = 0
for key, n in given.items():
dl, db = CLASS_OPTIONS[cls][key]
cls_loss += dl * n
cls_bonus += db * n
dx += DX_PER[_JUDGE_OF_KEY[key]] * n
loss_total += cls_loss
bonus_total += cls_bonus
breakdown[cls] = (cap, cls_loss, cls_bonus)
w = th.all_perfect_score
if w <= 0:
raise ValueError("谱面里没有计分音符")
achievement = Fraction(100) - Fraction(100 * loss_total, w)
if th.break_bonus_score:
achievement += Fraction(bonus_total, th.break_bonus_score)
return ExactResult(
theory=th, spec=filled, achievement=achievement, dx=dx,
loss_total=loss_total, bonus_total=bonus_total, breakdown=breakdown,
)
# ------------------------------------------------------------------ 反向:工具函数
def _judges_for(th: Theory, miss: int, good: int, great: int,
cp: int, perfect: int) -> Dict[str, int]:
return {"cp": cp, "perfect": perfect, "great": great, "good": good, "miss": miss}
def _best(th: Theory, miss: int, good: int, great: int) -> Optional[Fraction]:
"""给定 Miss/Good/Great 数时,达成率能达到的最大值(CP/Perfect 拆分自由)。"""
s = th.total_notes - miss - good - great
if s < 0:
return None
return _extreme(th, _judges_for(th, miss, good, great, s, 0), want_max=True,
flex_good=True)
def _worst(th: Theory, miss: int, good: int, great: int) -> Optional[Fraction]:
"""给定 Miss/Good/Great 数时,达成率能达到的最小值(CP/Perfect 拆分自由)。"""
s = th.total_notes - miss - good - great
if s < 0:
return None
return _extreme(th, _judges_for(th, miss, good, great, s, 0), want_max=False,
flex_good=True)
def _max_true(lo: int, hi: int, pred) -> int:
"""pred 在 [lo, hi] 上单调不增时,求满足 pred 的最大值(无则返回 lo-1)。"""
best = lo - 1
while lo <= hi:
mid = (lo + hi) // 2
if pred(mid):
best = mid
lo = mid + 1
else:
hi = mid - 1
return best
def _min_true(lo: int, hi: int, pred) -> int:
"""pred 在 [lo, hi] 上单调不增时,求满足 pred 的最小值(无则返回 hi+1)。"""
best = hi + 1
while lo <= hi:
mid = (lo + hi) // 2
if pred(mid):
best = mid
hi = mid - 1
else:
lo = mid + 1
return best
# ------------------------------------------------------------------ 反向:候选行
def _candidate_rows(th: Theory, target: Fraction, max_scan: int,
bad_limit: Optional[int] = None, stats: Optional[Dict] = None):
"""生成「可能覆盖 target」的 (miss, good, great) 组合,顺序由少到多。
bad_limit 非空时只产出 Miss+Good+Great <= bad_limit 的组合,
并在 stats["clipped"] 里记下「还有更多(坏音符更多的)组合没产出」。
"""
n = th.total_notes
scanned = 0
def best_m(m):
v = _best(th, m, 0, 0)
return v is not None and v >= target
miss_max = _max_true(0, n, best_m)
if miss_max < 0:
return
if bad_limit is not None and miss_max > bad_limit and stats is not None:
stats["clipped"] = True
miss_hi = miss_max if bad_limit is None else min(miss_max, bad_limit)
for miss in range(miss_hi + 1):
def best_g(g):
v = _best(th, miss, g, 0)
return v is not None and v >= target
good_max = _max_true(0, n - miss, best_g)
if good_max < 0:
continue
if bad_limit is not None and good_max > bad_limit - miss:
if stats is not None:
stats["clipped"] = True
good_max = bad_limit - miss
for good in range(good_max + 1):
room = n - miss - good
def best_gr(gr):
v = _best(th, miss, good, gr)
return v is not None and v >= target
def worst_gr(gr):
v = _worst(th, miss, good, gr)
return v is not None and v <= target
# 先求「Great 放得下」的最大个数:超过它整行不可行(例如 EX 音符不能判 Great),
# 否则不可行的高端会让 worst_gr 不再单调,二分就会找错。
feasible_hi = _max_true(0, room, lambda gr: _worst(th, miss, good, gr) is not None)
if feasible_hi < 0:
continue
g_high = _max_true(0, feasible_hi, best_gr)
g_low = _min_true(0, feasible_hi, worst_gr)
if bad_limit is not None and g_high > bad_limit - miss - good:
if stats is not None:
stats["clipped"] = True
g_high = bad_limit - miss - good
if g_low > g_high:
continue
for great in range(g_low, g_high + 1):
scanned += 1
if scanned > max_scan:
if stats is not None:
stats["clipped"] = True
return
yield miss, good, great
@dataclass
class ReverseRow:
miss: int
good: int
great: int
cp_plus_perfect: int
cp_min: int
cp_max: int
achievement_min: Fraction
achievement_max: Fraction
dx_min: int = 0
dx_max: int = 0
verified: bool = True # 精确模式下是否已严格验证
exact: bool = False # 是否恰好等于目标达成率
def summary(self) -> str:
return ("miss %d · good %d · great %d · cp+perfect %d"
% (self.miss, self.good, self.great, self.cp_plus_perfect))
def _row_dx_bounds(th: Theory, row: ReverseRow) -> None:
base = 2 * row.cp_plus_perfect + row.great
row.dx_min = base + row.cp_min
row.dx_max = base + row.cp_max
def _cp_bounds_simple(th: Theory, s: int) -> Tuple[int, int]:
"""cp 的物理取值范围:Perfect 只能出现在 Break / Break(长按判定) 上。"""
cp_min = max(0, s - th.perfect_capacity)
return cp_min, s
# ------------------------------------------------------------------ 反向:区间模式
def solve_reverse_range(th: Theory, target: Fraction, limit: int = 300,
max_scan: int = 200000) -> Dict[str, object]:
rows: List[ReverseRow] = []
scanned = 0
truncated = False
for miss, good, great in _candidate_rows(th, target, max_scan):
scanned += 1
lo = _worst(th, miss, good, great)
hi = _best(th, miss, good, great)
if lo is None or hi is None:
continue
s = th.total_notes - miss - good - great
cp_min, cp_max = _cp_bounds_simple(th, s)
row = ReverseRow(
miss=miss, good=good, great=great, cp_plus_perfect=s,
cp_min=cp_min, cp_max=cp_max,
achievement_min=lo, achievement_max=hi,
)
_row_dx_bounds(th, row)
rows.append(row)
if len(rows) >= limit:
truncated = True
break
return {
"target": target,
"mode": "range",
"theory": th,
"rows": rows,
"truncated": truncated,
"scanned": scanned,
"exact_cap": EXACT_MAX_BAD,
"unverified": 0,
}
# ------------------------------------------------------------------ 反向:精确模式
def _bad_options(group: str) -> Dict[str, Tuple[Tuple[int, int], ...]]:
return {j: GROUP_OPTIONS[group][j] for j in _BAD_JUDGMENTS
if j in GROUP_OPTIONS[group]}
def _dx_delta(th: Theory, loss: int, bonus: int) -> int:
"""一个坏音符对「目标量 X」的贡献;X = rhs 等价于达成率等于目标。"""
if th.break_bonus_score:
return 100 * th.break_bonus_score * loss - th.all_perfect_score * bonus
return 100 * loss
def _exact_suffix(th: Theory, groups: List[str]) -> List[Tuple[int, int, int, int]]:
"""每个后缀(含自身)的 (dX 负向下界, dX 上界, slo 上界, bad 上界),用于剪枝。
注意 dX 下界必须按「最多只有这些音符是坏音符、也可以是 0 个」来算,
所以只累计负的贡献(min(0, ...)·个数),否则会错误剪掉合法解。
"""
suf: List[Tuple[int, int, int, int]] = [(0, 0, 0, 0)] * (len(groups) + 1)
for i in range(len(groups) - 1, -1, -1):
group = groups[i]
n = th.counts.get(group, 0)
opts = _bad_options(group)
dxs = [_dx_delta(th, dl, db)
for choices in opts.values() for dl, db in choices]
d_bad, d_slo = _GROUP_BAD_STEP.get(group, (0, 0))
pl, ph, ps, pn = suf[i + 1]
suf[i] = (pl + n * min(0, min(dxs)), ph + n * max(dxs), ps + n * d_slo,
pn + n * (1 if d_bad else 0))
return suf
def _exact_table(th: Theory, max_bad: int, rhs: Fraction,
set_cap: int = 20000, total_cap: int = 1500000,
deadline: Optional[float] = None):
"""DP:状态 (miss, good, great) -> 可达的 (X, bad_b, slo) 集合。
X = 100*B*失分和 - W*坏音符奖励分(无绝赞时 X = 100*失分和),与达成率一一对应;
只统计「坏音符」(Great/Good/Miss);绝赞音符里 cp/perfect 的奖励分用 slo 还原成区间。
每个候选元组都用「剩余音符最大/最小贡献」做目标剪枝(只对当前 rhs 有效)。
超出预算时返回 None(调用方退回区间判定)。
"""
groups = [g for g in GROUP_ORDER if th.counts.get(g, 0) and _bad_options(g)]
suf = _exact_suffix(th, groups)
w = th.all_perfect_score
b = th.break_bonus_score
klo_const = (2 * th.counts.get(GROUP_BREAK, 0)
+ 3 * th.counts.get(GROUP_BREAK_FLAT, 0)
+ 4 * th.counts.get(GROUP_BREAK_SLIDE, 0)
+ 4 * th.counts.get(GROUP_EX_BREAK, 0))
break_notes = sum(th.counts.get(g, 0) for g in _GROUP_BAD_STEP)
table: Dict[Tuple[int, int, int], set] = {(0, 0, 0): {(0, 0, 0)}}
for gi, group in enumerate(groups):
n = th.counts.get(group, 0)
opts = _bad_options(group)
d_bad, d_slo = _GROUP_BAD_STEP.get(group, (0, 0))
deltas = {j: [_dx_delta(th, dl, db) for dl, db in choices]
for j, choices in opts.items()}
dxmin_g = min(v for vs in deltas.values() for v in vs)
dxmax_g = max(v for vs in deltas.values() for v in vs)
rest_after = suf[gi + 1]
slots = min(n, max_bad)
for k in range(slots):
if deadline is not None and time.monotonic() > deadline:
return None
new: Dict[Tuple[int, int, int], set] = {}
for state, tset in table.items():
carried = new.setdefault(state, set())
carried.update(tset)
if len(carried) > set_cap:
return None
if state[0] + state[1] + state[2] >= max_bad:
continue
for judge, vs in deltas.items():
nxt = (state[0] + (1 if judge == "miss" else 0),
state[1] + (1 if judge == "good" else 0),
state[2] + (1 if judge == "great" else 0))
bucket = new.setdefault(nxt, set())
rem = min(slots - k - 1, max_bad - sum(nxt))
dmin = rem * min(0, dxmin_g) + rest_after[0]
dmax = rem * dxmax_g + rest_after[1]
slomax = rem * d_slo + rest_after[2]
if b:
need_lo = rhs + 25 * w * (klo_const - slomax)
need_hi = rhs + 25 * w * 4 * break_notes
for x, bad_b, slo in tset:
cbb = bad_b + d_bad
cs = slo + d_slo
for dx in vs:
cx = x + dx
if b:
if cx + dmax < need_lo - 25 * w * cs:
continue
if cx + dmin > need_hi - 25 * w * 4 * cbb:
continue
else:
if cx + dmax < rhs or cx + dmin > rhs:
continue
bucket.add((cx, cbb, cs))
if len(bucket) > set_cap:
return None
table = new
if sum(len(s) for s in table.values()) > total_cap:
return None
return table
def _exact_row_ok(th: Theory, table, miss: int, good: int, great: int,
rhs: Fraction) -> bool:
"""严格判定:能否把 (miss, good, great) 判定恰好打出达成率 target。"""
tset = table.get((miss, good, great))
if not tset:
return False
w = th.all_perfect_score
b = th.break_bonus_score
klo_const = (2 * th.counts.get(GROUP_BREAK, 0)
+ 3 * th.counts.get(GROUP_BREAK_FLAT, 0)
+ 4 * th.counts.get(GROUP_BREAK_SLIDE, 0)
+ 4 * th.counts.get(GROUP_EX_BREAK, 0))
break_notes = sum(th.counts.get(g, 0) for g in _GROUP_BAD_STEP)
if not b:
# 没有绝赞:达成率只由失分决定,100 * 失分和 == 分母 * (100 - 目标)
for x, _bad_b, _slo in tset:
if x == rhs:
return True
return False
for x, bad_b, slo in tset:
num = x - rhs
if num % (25 * w):
continue
k = num // (25 * w)
if klo_const - slo <= k <= 4 * (break_notes - bad_b):
return True
return False
def solve_reverse_exact(th: Theory, target: Fraction, limit: int = 300,
max_scan: int = 200000) -> Dict[str, object]:
d = th.denominator
rhs = Fraction(d) * (100 - target)
note = None
table = None
max_bad_used = 0
pending: List[Tuple[int, int, int]] = []
scanned = 0
if rhs.denominator != 1:
note = ("该达成率不是本谱面能精确打出的值(达成率只能取 100 - 整数/分母 的形式),"
"下面给出区间判定结果。")
elif th.lattice and rhs.numerator % th.lattice:
note = ("该达成率不满足谱面的失分格点(失分只能是 %d 的倍数),"
"下面给出区间判定结果。" % th.lattice)
stats: Dict[str, bool] = {}
if note is None:
# 只枚举「坏音符不超过 EXACT_MAX_BAD」的组合:超出的组合没法精确验证,
# 全枚举它们会白花很多时间(大谱面可能有几十万组)。
for miss, good, great in _candidate_rows(th, target, max_scan,
EXACT_MAX_BAD, stats):
scanned += 1
pending.append((miss, good, great))
if pending:
max_bad_used = min(max(sum(p) for p in pending), EXACT_MAX_BAD)
table = _exact_table(th, max_bad_used, rhs,
deadline=time.monotonic() + EXACT_TIME_BUDGET)
if table is None:
note = ("判定数较大,超出精确验证的预算(最多 %d 个 Miss+Good+Great),"
"下面给出区间判定结果。" % EXACT_MAX_BAD)
if note is not None:
fallback = solve_reverse_range(th, target, limit=limit, max_scan=max_scan)
fallback["note"] = note
fallback["exact_cap"] = max_bad_used or EXACT_MAX_BAD
fallback["more_unverified"] = bool(stats.get("clipped"))
return fallback
if not pending and stats.get("clipped"):
# 一组候选都没法精确验证(坏音符都超过上限),退回区间模式并说明原因
fallback = solve_reverse_range(th, target, limit=limit, max_scan=max_scan)
fallback["note"] = ("这个谱面/达成率下,坏音符(Miss+Good+Great)超过 %d 个的组合太多,"
"精确验证代价过高,下面给出区间模式结果。" % EXACT_MAX_BAD)
fallback["exact_cap"] = EXACT_MAX_BAD
fallback["more_unverified"] = True
return fallback
rows: List[ReverseRow] = []
truncated = False
unverified = 0
for miss, good, great in pending:
bad = miss + good + great
if bad > max_bad_used:
unverified += 1
continue
if not _exact_row_ok(th, table, miss, good, great, rhs):
continue
s = th.total_notes - bad
cp_min, cp_max = _cp_bounds_simple(th, s)
row = ReverseRow(
miss=miss, good=good, great=great, cp_plus_perfect=s,
cp_min=cp_min, cp_max=cp_max,
achievement_min=target, achievement_max=target,
verified=True, exact=True,
)
_row_dx_bounds(th, row)
rows.append(row)
if len(rows) >= limit:
truncated = True
break
return {
"target": target,
"mode": "exact",
"theory": th,
"rows": rows,
"truncated": truncated,
"scanned": scanned,
"exact_cap": max_bad_used,
"unverified": unverified,
"more_unverified": bool(stats.get("clipped")),
"note": None,
}
def solve_reverse(th: Theory, target: Fraction, limit: int = 300,
max_scan: int = 200000, mode: str = "exact") -> Dict[str, object]:
target = Fraction(target)
if target > th.max_achievement:
raise ValueError("目标达成率 %s 超过该谱面上限 %s"
% (target, th.max_achievement))
if mode == "range":
return solve_reverse_range(th, target, limit=limit, max_scan=max_scan)
if mode != "exact":
raise ValueError("mode 只能是 exact 或 range")
return solve_reverse_exact(th, target, limit=limit, max_scan=max_scan)
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
unlockmusic.py —— 主脚本:扫码登录 → 解锁(解禁)歌曲 → 登出
================================================================================
**它没有自己的协议实现** —— 整个流程直接复用 `upsert.py` 的 `run()`:
扫码 → 预览 → 登录 → UpsertUserAll → 落库回查 → 登出,一模一样。
唯一多出来的东西是"要解锁哪些歌"这个入参。
--------------------------------------------------------------------------------
为什么解锁不用单独的接口
--------------------------------------------------------------------------------
游戏里解锁不是一次独立的网络请求,而是**往 userItemList 里补几条 UserItem**:
itemKind = 5 歌曲本体解锁(MusicUnlockList)
itemKind = 6 Master 难度解锁(MusicUnlockMasterList)
itemKind = 7 Re:Master 难度解锁(MusicUnlockReMasterList)
依据 source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
(UserData.cs:132-138 那四个解锁列表不是独立节点,是并进 userItemList 的)。
条目长这样:
{"itemKind": 5, "itemId": 11414, "stock": 1, "isValid": true}
★ 标准谱(musicId < 10000)的 Master / Re:Master **天生解锁**,不会写 kind6/7
(source/Assembly-CSharp/Manager/NotesListManager.cs:415/455 开头就是
`if (id < 10000) return true;`);只有 DX 谱(>= 10000)才需要额外解禁。
★ 服务器上已经有的条目**自动跳过** —— 重复插入会让服务端直接 HTTP 500。
★ Re:Master 还受活动开关约束(IsUnlockReMaster 里的 IsOpenEvent),
那是机台侧开关,脚本改不了;写了也可能进游戏仍是灰的。
★ 本功能**没有真机抓包可对照**(机台侧的解锁不上报),是按源码实现的。
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python unlockmusic.py <二维码> --music 11414 # 预检,不发包
python unlockmusic.py <二维码> --music 11414 --send # ★ 真实解锁
python unlockmusic.py <二维码> --music 1085,11414 --send # 多首,逗号分隔
python unlockmusic.py <二维码> --music 11414 --send --no-unlock-remaster
# 只要歌曲本体,不解禁任何难度
python unlockmusic.py <二维码> --music 11414 --send --no-unlock-master --no-unlock-remaster
# 和其它主脚本一样的通用开关
python unlockmusic.py <二维码> --music 11414 --send --debug
python unlockmusic.py <二维码> --music 11414 --send --cooldown 0
python unlockmusic.py <二维码> --music 11414 --send --no-verify
| 参数 | 说明 |
|---|---|
| `--music ID[,ID...]` | 要解锁的 musicId,可重复、可逗号分隔(`--unlock-music` 也认) |
| `--send` | 真正发包(不加就是预检,零副作用) |
| `--no-unlock-master` | 不解禁 Master |
| `--no-unlock-remaster` | 不解禁 Re:Master |
| `--cooldown N` | 发包前冷却秒数,默认取 config 的 15 秒 |
| `--no-verify` | 跳过上传后的落库回查 |
| `--debug` | 本次运行打印完整收发报文 |
日志同样是 logs/latest.log(覆盖)+ logs/YYYY-MM-DD.log(追加)。
"""
from __future__ import annotations
import sys
import upsert
from api import log_error, setup_logging, teardown_logging
def parse_args(argv):
"""极简命令行解析:第一个非 -- 开头的参数是二维码。"""
opts = {"qr": "", "send": False, "debug": False, "no_verify": False,
"cooldown": None, "extra_data": [],
"unlock_music": [], "unlock_master": True, "unlock_remaster": True}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--no-verify":
opts["no_verify"] = True
elif a == "--extra-data" and i + 1 < len(argv):
opts["extra_data"].append(argv[i + 1])
i += 1
elif a == "--cooldown" and i + 1 < len(argv):
opts["cooldown"] = float(argv[i + 1])
i += 1
elif a in ("--music", "--unlock-music") and i + 1 < len(argv):
# 可重复;也支持逗号分隔(中英文逗号都认)
for part in str(argv[i + 1]).replace(",", ",").split(","):
part = part.strip()
if not part:
continue
try:
opts["unlock_music"].append(int(part))
except ValueError:
opts["unlock_bad"] = part
i += 1
elif a == "--no-unlock-master":
opts["unlock_master"] = False
elif a == "--no-unlock-remaster":
opts["unlock_remaster"] = False
elif not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["qr"] or not opts["unlock_music"]:
# 没有二维码、或者没有指定要解锁哪首 —— 直接把用法打出来
print(__doc__)
return 1
if opts.get("unlock_bad"):
# 参数本身就不对,连扫码都不用做
setup_logging("unlockmusic")
log_error("--music 里有非法 musicId:" + str(opts["unlock_bad"]))
teardown_logging()
return 6
# ★ 整个流程复用 upsert.run(),这里只多传三个解锁参数
return upsert.run(
opts,
unlock_music=opts["unlock_music"],
unlock_master=opts["unlock_master"],
unlock_remaster=opts["unlock_remaster"],
log_name="unlockmusic",
)
if __name__ == "__main__":
sys.exit(main())
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
================================================================================
整条流程只有四步,而且**一行日志都不用自己写** ——
每个 api 函数被调用时会自动打印:
=== UserLogin 开始运行 ===
[接口说明] 用户正式登录(开启本次登录会话)。
[位置参数] 12345678, 'iUfgRmjr...'
[关键字参数] region_id=1, place_id=1000
=== UserLogin 运行结束 ===
日志模式改 config.py 一行:
LOG_LEVEL = "info" 只展示 api 脚本的运行情况和结果
LOG_LEVEL = "debug" 额外展示向 api 发送的数据与 api 返回的数据
日志文件(两种同时写):
logs/latest.log 只含**当次**主脚本运行(下次覆盖)
logs/YYYY-MM-DD.log 存放**当日**产生的所有日志(追加)
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python upsert.py <二维码> # 预检:登录 + 抓快照 + 组包,不发包
python upsert.py <二维码> --send # 真实上传成绩(★ 有副作用,不可撤销)
python upsert.py <二维码> --send --debug # 同上,日志里带完整收发报文
python upsert.py <二维码> --send --cooldown 0 # 跳过发包前的 15 秒冷却
python upsert.py <二维码> --send --no-verify # 跳过上传后的落库回查
# ★ 解锁(解禁)歌曲请用另一个主脚本(它复用本脚本的整套流程)
python unlockmusic.py <二维码> --send --music 11414
# ★ 额外塞节点(--extra-data,可重复,后给的覆盖先给的)
python upsert.py <二维码> --send --extra-data extra.json
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
python upsert.py <二维码> --send --extra-data a.json --extra-data b.json
--------------------------------------------------------------------------------
★ UpsertUserAll 一个函数就把整条结算链路跑完了
--------------------------------------------------------------------------------
1. 抓基线快照(没传 snapshot 时自动抓,用 token)
2. 从快照推本局 5 只角色(游玩前等级)
3. 构造本局成绩明细 userPlaylogList
4. 调前置接口 GetUserNewItemListApi
5. 组装 upsertUserAll 的 36 个节点(快照基线 + 本局演算 + 差分)
6. 合并 extra_data 里的外部 JSON
7. 发包前自检
8. 冷却 -> 真实发包
9. 落库回查(重新拉服务器数据,确认真的写进去了)
--------------------------------------------------------------------------------
★ --extra-data:额外塞节点
--------------------------------------------------------------------------------
想手工覆盖 / 补上某几个节点(比如本局拿到的新 KaleidxScope 门)时用:
# ① 从文件读(★ 推荐,不用跟 shell 引号搏斗)
python upsert.py <二维码> --send --extra-data extra.json
# ② 直接写 JSON 字符串
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
# ③ 多个来源,后给的覆盖先给的
python upsert.py <二维码> --send --extra-data base.json --extra-data patch.json
extra.json 长这样:
{
"userKaleidxScopeList": [
{"gateId": 1, "isGateFound": true, "isKeyFound": true, "isClear": false,
"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,
"bestAchievement": 0, "bestDeluxscore": 0,
"bestAchievementDate": "", "bestDeluxscoreDate": "",
"playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": false}
],
"isNewKaleidxScopeList": "1"
}
★ 键必须是 upsertUserAll 的 36 个节点之一(含 11 条 isNew* 位串),
写错会明确报错并列出可用节点名。
★ 没给对应位串时会按数组长度自动补 "0" 并提示 ——
新增条目请显式传 "1","0" 表示存量更新。
★ JSON 在**扫码之前**就校验,写错了直接退出,不会浪费二维码。
--------------------------------------------------------------------------------
★ 这个文件同时是一个可复用的模块
--------------------------------------------------------------------------------
`run(opts, ...)` 就是整套流程(扫码 → 预览 → 登录 → UpsertUserAll → 登出),
`main()` 只做参数解析然后调它。所以别的主脚本可以直接:
import upsert
sys.exit(upsert.run({"qr": qr, "send": True, ...}, log_name="unlockmusic"))
unlockmusic.py 就是这么写的 —— 它只多了一个"要解锁哪些歌"的入参。
── 在代码里直接调 api 的等价写法 ──
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
extra_data={"userKaleidxScopeList": [...], "isNewKaleidxScopeList": "1"},
confirm=True,
)
"""
from __future__ import annotations
import sys
from api import (
setup_logging,
teardown_logging,
log_info,
log_warning,
log_error,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
UpsertUserAll,
ConfirmRequiredError,
load_extra_data,
PatchError,
NODE_ORDER,
NODE_TYPES,
)
from config import PLACE_ID, REGION_ID
def parse_args(argv):
"""极简命令行解析:第一个非 -- 开头的参数是二维码。"""
opts = {"qr": "", "send": False, "debug": False, "no_verify": False,
"cooldown": None, "extra_data": []}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--no-verify":
opts["no_verify"] = True
elif a == "--extra-data" and i + 1 < len(argv):
# 可重复;值是 JSON 字符串或 JSON 文件路径
opts["extra_data"].append(argv[i + 1])
i += 1
elif a == "--cooldown" and i + 1 < len(argv):
opts["cooldown"] = float(argv[i + 1])
i += 1
elif not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
def run(opts, *, unlock_music=None, unlock_master=True, unlock_remaster=True,
log_name="upsert") -> int:
"""跑完整套流程。upsert.py 自己用,unlockmusic.py 也复用它。
opts 里必须有的键:qr / send / debug / no_verify / cooldown / extra_data
unlock_music / unlock_master / unlock_remaster 三个是解锁用的,upsert.py 不传。
"""
if not opts.get("qr"):
print(__doc__)
return 1
# --debug 只影响本次运行,不改 config.py
if opts.get("debug"):
import config as _cfg
_cfg.LOG_LEVEL = "debug"
# 日志会话:开头一句。之后每次 api 调用都会自动记录,主脚本不用再写日志
setup_logging(log_name)
# ---- ★ 先解析 --extra-data(放在扫码之前:JSON 写错就别浪费二维码了)----
extra = None
if opts["extra_data"]:
merged = {}
try:
for item in opts["extra_data"]:
part = load_extra_data(item)
merged.update(part)
log_info("--extra-data 读入 " + str(len(part)) + " 个节点:" + ", ".join(part))
except PatchError as exc:
log_error("--extra-data 解析失败:" + str(exc))
return 6
# 节点名也在这里查(不然要等到登录后组包时才报错,白跑一趟)
unknown = [k for k in merged if k not in NODE_TYPES]
if unknown:
log_error("--extra-data 里有未知节点名:" + ", ".join(unknown))
log_error(" 可用节点(" + str(len(NODE_ORDER)) + " 个):" + ", ".join(NODE_ORDER))
return 6
if not merged:
log_warning("--extra-data 解析出来是空的,忽略")
else:
extra = merged
log_info("--extra-data 合计 " + str(len(extra)) + " 个节点:" + ", ".join(extra))
# ---- 解锁参数校验(同样放在扫码之前)----
if unlock_music:
log_info("要解锁 " + str(len(unlock_music)) + " 首:"
+ ", ".join(str(m) for m in unlock_music)
+ "(Master=" + str(unlock_master)
+ " / Re:Master=" + str(unlock_remaster) + ")")
# 会话状态:保证异常时也能登出(否则账号会卡在 isLogin=True,下次登录 returnCode=100)
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---- 扫码:二维码 -> userId + token ----
user_id, token = ParseQRCode(opts["qr"])
session.update(user_id=user_id, token=token)
# ---- 预览:顺便挡住「账号已在游戏中」的情况,避免顶号 ----
preview = GetUserPreview(user_id, token)
if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
# ---- 登录:拿到 playlogId 与 loginDateTime(结算上报要用)----
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ---- 结算:★ 一个函数搞定构造 + 发包 + 落库回查 ----
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
extra_data=extra,
unlock_music_ids=unlock_music or None,
unlock_master=unlock_master,
unlock_remaster=unlock_remaster,
dry_run=not opts["send"],
confirm=opts["send"],
cooldown_sec=opts["cooldown"],
verify_landing=not opts["no_verify"],
place_id=PLACE_ID, region_id=REGION_ID,
)
return 0
except ConfirmRequiredError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
# 兜底登出:登录过且还没登出就释放会话,否则账号卡在 isLogin=True
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
def main() -> int:
return run(parse_args(sys.argv[1:]), log_name="upsert")
if __name__ == "__main__":
sys.exit(main())
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
use_ticket.py —— 消耗账号当前持有的功能券(用 UpsertUserAll 表达「用票打了一局」)
================================================================================
一条完整的「用票」链路:
ParseQRCode -> GetUserPreview -> UserLogin -> GetUserCharge
-> GetNeedUserData(基线快照)-> UpsertUserAll -> GetUserCharge(复查)-> UserLogout
--------------------------------------------------------------------------------
★ 为什么可以用 UpsertUserAll 消耗票券
--------------------------------------------------------------------------------
GetUserChargeApi 与 UpsertUserAllApi 里的 userChargeList **格式完全相同**
(UserCharge.cs:chargeId / stock / purchaseDate / validDate),
所以只要把 UpsertUserAll 的 userChargeList 节点改成「该票 stock - 1」,
服务端就会认为这张票被这一局用掉了。
真机实证(gamehook 抓包 2026-10-06 22:23 那一局,正好用了 2 倍券):
userChargeList: [
{"chargeId":2,"stock":0,...}, <- 买来时是 1,本局用掉变成 0
{"chargeId":3,"stock":0,...},
{"chargeId":5,"stock":0,...}
]
userGamePlaylogList[0].useTicketId = 2 <- ★ 用了哪张票
userGamePlaylogList[0].playCredit = 2 <- ★ 本局消耗的 credit
userPlaylogList[0].playCredit = 2
userData.lastPlayCredit = 2
★ playCredit 的算法(source/.../Net.VO.Mai2/VOExtensions.cs:838-845):
playCredit = Credit.GameCostEnoughPlay() // 投币数,普通局 = 1
+ TicketManager.GetTicketCredit(ticketId) // 票的 creditNum
2 倍券的 creditNum = 1 -> playCredit = 1 + 1 = 2 (与真机抓包一致)
creditNum 表见 TICKET_CREDIT_NUM。
★ 真机报文里**没有** isNewChargeList 这个节点(它不在 36 个节点里),
所以这里也不需要给位串。
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python use_ticket.py <二维码> # 预检:只看报文,不发包
python use_ticket.py <二维码> --list # 只列当前持有的票,不消耗
python use_ticket.py <二维码> --ticket-id 2 # 指定要消耗哪张(默认第一张持有的)
python use_ticket.py <二维码> --ticket-id 2 --send # ★ 真实消耗
python use_ticket.py <二维码> --send --music-id 143 --level 4
python use_ticket.py <二维码> --send --skip-entry # 跳过入场数据下载
★ 消耗会写入服务器(不可撤销)。不传 --send 时只构造报文。
★ 二维码**有效期 10 分钟**。
★ 账号当前**没有**持有功能票时直接拒绝(没有可消耗的东西)。
"""
from __future__ import annotations
import sys
from api import (
setup_logging,
teardown_logging,
log_error,
log_info,
log_warning,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
GetUserCharge,
GetNeedUserData,
summarize_snapshot,
UpsertUserAll,
PlayData,
ConfirmRequiredError,
ticket_name,
Ping,
# ★ 原先在 config.py,已下沉到 api/UpsertUserChargelog.py
TICKET_CREDIT_NUM,
)
from config import (
PLACE_ID,
REGION_ID,
PLAY_MUSIC_ID,
PLAY_MUSIC_LEVEL,
PLAY_CREDIT,
)
def read_charges(user_id, label):
"""调 GetUserChargeApi,打印票券状态并返回 (全部, 持有的)。"""
raw = GetUserCharge(user_id)
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
if not lst:
log_info(" (空)")
for c in lst:
cid = c.get("chargeId")
stock = int(c.get("stock", 0) or 0)
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
+ " stock=" + str(stock)
+ " purchaseDate=" + str(c.get("purchaseDate"))
+ " validDate=" + str(c.get("validDate"))
+ (" ← ★ 持有中" if stock > 0 else ""))
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
return lst, held
def parse_args(argv):
opts = {"qr": "", "ticket_id": None, "send": False, "list": False,
"debug": False, "skip_entry": False,
"music_id": PLAY_MUSIC_ID, "level": PLAY_MUSIC_LEVEL}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--list":
opts["list"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--skip-entry":
opts["skip_entry"] = True
elif a == "--ticket-id" and i + 1 < len(argv):
opts["ticket_id"] = int(argv[i + 1])
i += 1
elif a == "--music-id" and i + 1 < len(argv):
opts["music_id"] = int(argv[i + 1])
i += 1
elif a == "--level" and i + 1 < len(argv):
opts["level"] = int(argv[i + 1])
i += 1
elif not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["qr"]:
print(__doc__)
return 1
if opts["debug"]:
import config as _cfg
_cfg.LOG_LEVEL = "debug"
setup_logging("use_ticket")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---- 心跳:决定后续所有请求的 number 头 ----
Ping()
user_id, token = ParseQRCode(opts["qr"])
session.update(user_id=user_id, token=token)
preview = GetUserPreview(user_id, token)
if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ---- ★ 读当前持有的功能券 ----
all_charges, held = read_charges(user_id, "消耗前")
if opts["list"]:
log_info("--list:只列票券,不做任何写操作")
return 0
if not held:
log_error("账号当前**没有持有任何功能票**,没有可消耗的东西。")
log_error(" 想买票请用 buy_ticket.py;已持有的票会在 userChargeList 里 stock > 0。")
return 4
# ---- 选定目标票 ----
target = None
if opts["ticket_id"] is not None:
for c in held:
if int(c.get("chargeId", -1)) == int(opts["ticket_id"]):
target = c
break
if target is None:
log_error("指定的 chargeId=" + str(opts["ticket_id"])
+ " 当前并不持有;持有的有:"
+ ", ".join(str(c.get("chargeId")) for c in held))
return 4
else:
target = held[0]
ticket_id = int(target.get("chargeId"))
credit_num = int(TICKET_CREDIT_NUM.get(ticket_id, 0) or 0)
play_credit = int(PLAY_CREDIT) + credit_num
log_info("选定要消耗的票:chargeId=" + str(ticket_id) + " " + ticket_name(ticket_id)
+ " stock=" + str(target.get("stock"))
+ " creditNum=" + str(credit_num)
+ " -> playCredit=" + str(PLAY_CREDIT) + "+" + str(credit_num)
+ "=" + str(play_credit))
# ---- ★ 构造新的 userChargeList:目标票 stock - 1,其余原样 ----
new_stock = int(target.get("stock", 0) or 0) - 1
if new_stock < 0:
new_stock = 0
new_list = []
for c in all_charges:
if not isinstance(c, dict):
continue
item = dict(c)
if int(item.get("chargeId", -1)) == ticket_id:
item["stock"] = new_stock
log_info(" userChargeList 里 chargeId=" + str(ticket_id)
+ " 的 stock:" + str(target.get("stock")) + " -> " + str(new_stock))
new_list.append(item)
# ---- 入场数据下载(贴近真机节奏,同时也是 UpsertUserAll 的基线快照)----
snapshot = None
if not opts["skip_entry"]:
snapshot = GetNeedUserData(user_id, token=token,
place_id=PLACE_ID, region_id=REGION_ID)
_sum = summarize_snapshot(snapshot)
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
+ str(len(snapshot.get("_failed") or [])) + " 个")
# ---- ★ 用 UpsertUserAll 表达「用这张票打了一局」----
# play.use_ticket_id -> userGamePlaylogList[0].useTicketId
# play.play_credit -> userGamePlaylogList[0].playCredit / userPlaylogList[].playCredit
# / userData.lastPlayCredit
# extra_data.userChargeList -> 覆盖节点,把票的 stock 减 1
play = PlayData(
music_id=opts["music_id"],
music_level=opts["level"],
use_ticket_id=ticket_id,
play_credit=play_credit,
)
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
snapshot=snapshot,
play=play,
extra_data={"userChargeList": new_list},
dry_run=not opts["send"],
confirm=opts["send"],
verify_landing=bool(opts["send"]),
place_id=PLACE_ID, region_id=REGION_ID,
)
# ---- ★ 复查:票券 stock 应该减 1 ----
_, held_after = read_charges(user_id, "消耗后")
if not opts["send"]:
log_info("[dry-run] 未发包。想真实消耗请加 --send(★ 会写入服务器)")
return 0
still = [c for c in held_after if int(c.get("chargeId", -1)) == ticket_id]
if still:
log_warning("chargeId=" + str(ticket_id) + " 仍然持有(stock="
+ str(still[0].get("stock")) + ")—— 服务端可能没落库")
return 6
log_info("★ 票券已消耗:chargeId=" + str(ticket_id) + " 不再持有 ✅")
return 0
except ConfirmRequiredError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
+496
View File
@@ -0,0 +1,496 @@
# maipulse 功能补齐计划(按截图那套移动端来)
目标:把现在这个网页工具,做成你截的那种移动端 app 的样子 ——
底部五个 tab(主页 / 票据 / B50 / 风险 / 设置),每个功能一张卡片,带 60 秒冷却条。
原则:**已经跑通的后端一个都不重写**,只补缺的 + 重排前端。
---
## 一、现状盘点(已有,直接用)
| 功能 | 后端 | 前端 | 状态 |
|---|---|---|---|
| 扫码登录 / 续玩 / 退出 | `core/session.py` + `/api/qr` `/api/login` `/api/logout` | 卡片 1 | 能用 |
| 2/3/5 倍功能票 | `core/charge.py` + `/api/charge` `/api/ticket` | 卡片 2 | 能用 |
| 解小黑屋 | `core/repair.py` + `/api/status` `/api/unstick` | 卡片 7 | 能用(未在真卡住号上复测) |
| 歌曲解锁(全量 / 选中) | `core/unlock.py` + `/api/unlock` | 卡片 6 | 能用 |
| 收藏品 / 礼物 | `core/items.py` + `/api/items` `/api/newitems` `/api/grant` | 卡片 4、5 | 能用 |
| B50 + 导出图片 | `core/b50.py` + `/api/b50` `/api/cover` | 卡片 8 | 能用,导出实测 2240×1608 |
| 店铺切换 | `core/shop.py` + `/api/shops` `/api/shop` | 卡片 3 附近 | 能用 |
这些只做两件事:**搬进新 UI**、**按截图补交互细节**(下拉框、列表、勾选、冷却条)。
---
## 二、要补的功能(截图里有,现在没有)
### 1. 主页(玩家信息全表)
数据源:`api.GetNeedUserData(uid, token)` 一次拿全,不需要多发请求。
要显示的区块(对齐你的截图):
- 头部:头像(iconId 拼图)、昵称、Rating、历史最高
- Rating 详情:旧谱 Rating / 新谱 Rating / 合计 / 历史最高
- 入坑信息:入坑日期、首次游戏、初始 Rom、初始数据版本
- 游戏信息:最后游戏、最后游玩、最后登录、Rom 版本、数据版本、最后地区(placeId → 地区名查 `shop.py` 的店铺表)
- 游玩统计:总游玩次数、当期游玩、总达成率、总 DX 分、总同步数、Awake
- 状态:联网会员、继承、封禁状态、每日奖励
- 更多详情:用户 ID(打码)、当前 P、累计 P、图标 ID、名牌 ID、姓名框 ID、背景框 ID、称号 ID、奖杯 ID、伙伴 ID、段位、阶级、段级
新接口:`GET /api/profile` → 直接吐上面这些字段(前端不再自己拼)。
字段名要先实测确认(`userData[0]` 里到底叫 `point` 还是 `userPoint`、`classValue` 之类),拉一次快照对字段。
### 2. 旅行伙伴
数据源:`userCharacterList`(不是 `userItemList`,也不是搭档 `Partner=10`)。
三个区块:
- 发放新伙伴:填 ID → 加到列表 → 一次提交(写成 `userCharacterList` 追加行)
- 编组出战槽位:槽 0(队长)~槽 4,每槽选一个已拥有角色 + 可选等级;「把槽 0 复制到其他槽」
- 已拥有伙伴:列 `#id Lv n` 的 chips(截图里那 48 个)
新接口:`GET /api/characters`(拉现有)、`POST /api/characters`(发放 / 编组)。
风险:ID 不在机台表里服务端照样存,但客户端会静默跳过 → 结果里要回报“服务端已存、游戏内可能不显示”。
### 3. 添加舞里程
数据源:`userData[0].point`(余额)/ `totalPoint`(累计)。
两种写法:累加(当前 + N)/ 覆盖。整个 int32 区间,填负数就是扣。
客户端只在“获得”时把余额钳到 99999,商店扣款不带钳位,所以写更大不会被回写。
新接口:`POST /api/mileage`,返回改前 / 改后的 point 与 totalPoint。
这项最容易出事(写坏就是余额异常),默认 dry-run,预检里明确写“覆盖会直接改余额”。
### 4. 设置 / 关于
- 设置:服务器地址(默认 127.0.0.1:8787)、是否自动退出登录、是否只预检
- 关于:版本、数据源说明、免责(自用工具)
不需要后端。
---
## 三、前端改造(重点,工作量最大)
现在是一个 PC 版两列卡片页,要改成截图那种:
整体
- 顶部返回 + 页面标题
- 内容区卡片:`background:#f2f2f6`、圆角 12、内边距 16、无边框
- 表单控件:Material 风 outlined 输入框(label 浮在边框上)、下拉箭头、大号圆角主按钮
- 底部 tab bar:主页 / 票据 / B50 / 风险 / 设置
交互
- 登录后 60 秒冷却:顶部粉色提示条「登录后需冷却 60 秒,剩余 N 秒」,按钮置灰
- 所有写操作跑完自动退出登录(可勾选)
- 危险操作统一放在「风险」tab 里,进去先一条红色警告卡
实现方式:单页 + hash 路由(`#/home` `#/ticket` `#/b50` `#/risk/...` `#/settings`),
不引框架,保持现在这个 `html/index.html` 一个文件,方便以后套 WebView。
---
## 四、实施顺序
1. **P1 主页信息**:拉一次快照对字段 → `/api/profile` → 主页 tab
2. **P2 B50 搬进新 UI**:海报 + 导出(逻辑不动,换壳)
3. **P3 票据 / 解锁 / 收藏品搬进新 UI**:+ 冷却条
4. **P4 旅行伙伴**:`/api/characters`
5. **P5 舞里程**:`/api/mileage`
6. **P6 设置 / 关于**
P1 必须先做,因为伙伴、里程都要读同一份快照,字段对一遍就都对了。
---
## 五、风险与纪律
- 60 秒登录冷却:所有 `UpsertUserAll` 类写入都在 `session.fresh()` 之后等满 60 秒,界面要显示倒计时,不能点了没反应。
- 票的坑:`userChargeList` 里只要有 `stock>0`,之后所有写入都会被服务端判非法整包丢弃。所以**先解歌 / 发道具,最后才买票**。
- 每项写操作默认 dry-run 预检(只算不发),勾掉「只预检」才真发。
- 不动已经跑通的 `settle / charge / unlock / items / b50`,只加接口和新页面。
---
## 六、验收口径
每个功能都要给出:改前值 → 改后值(回读快照确认)+ 一条日志 + 截图,不能只看 `returnCode=1` 就说成功。
---
## 进度记录(2026-10-28 05:00)
对照 Ichino(D:\DSH\maimai com\Ichino,Flutter 客户端)逐项落地。
### 已完成
1. **B50 海报重做(照 Ichino best50_poster.dart 原始坐标)**
- 底图 `html/assets/b50/base.png`(1762×1850)+ 绝对坐标叠加,不再用 CSS 拼版;
- 卡片 300×100,`left = 65 + 320*(i%5)`,Best35 `top = 350 + 120*i/5`,Best15 `top = 1250 + 120*i/5`;
- 评级图 `UI_TTR_Rank_*.png`、FC/AP 与 FS/FDX 图标、DX 徽标、难度色 RA 药丸全部走官方素材;
- `ra` 换成 Ichino 整数公式(`_BANDS` + `_SSSP_OFFSET=224`),实测 9166 与游戏内一致;
- 导出 html2canvas `scale=1` → 1762×1850,已验证;
- 未登录时海报署名用打码 ID,不印完整账号。
2. **主页资料 `GET /api/profile`**(新 `core/profile.py`)
- 一次 `GetNeedUserData` 填满;字段照 Ichino `UserDataBean`:Rating 详情 / 入坑信息 / 游戏信息 / 游玩统计 / 状态 / 更多详情;
- 前端 6 个分区 + 空态提示。
3. **旅行伙伴 `GET|POST /api/characters`**(新 `core/characters.py`)
- 读:`userCharacterList` + `userData.charaSlot`(int[5],槽 0 队长);
- 写:`userCharacterList` + 逐行 `isNewCharacterList`(已有 '0' / 新增 '1'),可同时改 charaSlot;
- 默认 dry-run,写后回读核对。
4. **舞里程 `GET|POST /api/mileage`**(新 `core/mileage.py`)
- 改 `point` / `totalPoint`,累加或覆盖,int32 钳位;
- userData 整表覆盖 → 先用 `build_user_all` 组好这一局,再替换那一行,保住 playCount / lastPlayDate 等痕迹。
5. **UI 改版(浅色 Material + 底部 5 tab)**
- 主页 / 票据 / B50 / 风险 / 设置;hash 路由 `#home #ticket #b50 #risk #settings`;
- 卡片按 `data-tab` 显隐,`main` 改单列 560 居中;
- 风险页顶部红框警告 + 顺序铁律(先解歌发道具,最后买票);
- 结果用底部轻提示(ticker)播报,日志收进设置页;
- 设置页可搜店铺切机台、退出登录。
### 待办
- 玩家记录 / 牌子进度页:需要登录态下 `GetUserMusicDetailAll`(当前未登录返回空明文);
- 推分推荐暂不算「平均达成率 / 统计样本」,本地没有水鱼聚合数据,不编造;
- 真机验证:登录后走一遍 主页 → 旅行伙伴 → 舞里程 的回读。
### 追加(本轮)
6. **60 秒冷却条**:`GET /api/state` 新增 `coolRemain`(登录后剩余秒数,金票 LogoutTimeLimit 同口径),
票据页粉色条倒计时提示「现在发包会被静默丢弃」。
7. **组包自测**:`tools/selftest_payload.py` 用假 snapshot 走 `build_user_all` 验证——
userData 49 个字段整行覆盖后一个不丢,point/totalPoint/charaSlot 改写生效,
`isNewCharacterList` 位串与 Ichino `BuildListData` 同口径(已有 '0' / 新增 '1')。已跑通。
### 追加(第 2 轮)
8. **字段映射自测**:`tools/selftest_fields.py` 把 GetNeedUserData 换成假快照,
验证 profile 的 24 个关键字段逐个取到正确值、userId 打码成 `123****78`、
characters 能解析出 2 个角色与 `[101..105]` 槽位。已跑通。
9. **B50 看大图**:海报下方加「看大图」,克隆一份 1762 宽的原尺寸海报铺进全屏查看器,
手机上横向滑动能看清卡片细节;`?b50=ID&zoom=1` 可直接验证。
### 追加(第 3 轮)
10. **选择器(解锁歌曲 / 称号 / 头像 / 名牌 / 边框 / 角色)浅色适配**:
标题单行省略、搜索框 `flex:1 1 0;width:auto` 不再把标题挤成两行;13000+ 项列表滚动正常。
`?picker=music|title|icon|nameplate|frame|character` 可直接验。
11. **结算等待遮罩改浅色**(白底圆角 + 深色字 + 蓝色进度条),不再和浅色主题撞色。
12. **自动登出真正接线**:`post()` 里对 6 个写接口(ticket/grant/unlock/characters/mileage/unstick)
做统一判断——勾了开关且**不是** dry-run 才登出,默认不勾,避免打断连续操作。
13. **B50 空态**:没查过时给一句引导,不是空白。
### 追加(第 4 轮)
14. **票种档位修正(对照 Astral TICKET_CREDIT_NUM)**:
原实现只有 chargeId 2/3/5,实际付费票是 2/3/4/5/6,`creditNum = N-1`(1/2/3/4/5)。
已补 4 倍票(creditNum 3)与 6 倍票(creditNum 5),错误文案同步成「2~6 倍票」,
下拉选项把 price 改标 creditNum(容易和「金额」混淆)。
Ichino 的 `_ticketNameMap` 只有 2/4/5,是显示映射不全——它实际从 `GetUserChargeApi`
拿在售列表,所以以服务器列表为准、本地只做换算。
15. **对照 Ichino 核过一遍字段表**:`models/user_data.dart` 的 41 个字段与 `core/profile.py` 一一对应,没有多余也没有缺。
### 追加(第 5 轮)
16. **旅行伙伴等级对齐 Ichino**:原来 `levels` 想填多少就发多少,现在钳到 **1~999999**
(`minCharacterLevel` / `maxCharacterLevel`,0 会被判非法)。
读取时附带 `displayLevel = level % 10000` 与 `rebirth = level // 10000`——
客户端把界面等级和转生次数编码在一个 int 里,直接显示原始值会看到「Lv10005」。
改等级时沿用原行的 `awakening` / `useCount`,不会把使用次数清零(Ichino 原注释点名过这个坑)。
自测:`clamp_level` 边界与 `split_level(10005) == (5, 1)` 通过。
### 追加(第 6 轮)
17. **整页验收脚本化**:`?dbg=1` 时挂 `window.onerror` + `unhandledrejection`,
把异常写进 `<title>`,再用 headless `--dump-dom` 逐个页签读。
结果:5 个页签(home / ticket / b50 / risk / settings)**零 JS 错误**,
`scrollWidth` 都不超过视口(risk 页 485/500 是纵向滚动条占位),没有横向溢出。
### 追加(第 7 轮)
18. **页签对齐 Ichino 的 6 个**:`home_tab.dart` 是 home / tickets / best50 / risk / settings / **about**,
于是把「关于」从设置页拆成独立页签(版本、协议来源 Astral、行为口径 Ichino、数据只在本机、几条规矩)。
注意 `.card[data-tab]` 的显示规则是逐页签写的,新增页签必须同步加一条 CSS,否则页面全空白。
19. **结算等待改成分步提示**:照 Ichino 的 StepProgressCard,进度条上方按阶段翻字——
读取快照 / 组包等满 60 秒 / 发送写包 / 回读核对,不再只显示「已等 N 秒」。
### 追加(第 8 轮)
20. **前后端 id 一致性 lint**:把 HTML 的 id 集合与 JS 里 `$('…')` / `setText('…')` 的引用对比,
只有 `poster` 是渲染时动态建的(正常),34 个 `k*` 字段全部有对应节点——不会出现登录后一片空白。
21. **`/api/characters` 参数加固**:`add` / `levels` / `slots` 的 `int()` 原来在 try 之外,
前端传了非数字会变成 Flask 的 500 HTML 页面(前端 `r.json()` 直接抛错)。现在返回 JSON 错误。
22. **风险页按操作顺序重排**:警告 → 解锁歌曲 → 发放收藏品 → 发放礼物 → 解锁搭档 →
旅行伙伴 → 舞里程 → 解禁小黑屋。理由是先解歌、发道具,最后才碰票相关的东西,
跟警告卡里那条铁律一致。
### 追加(第 9 轮)
23. **UI 预览模式 `?preview=1`**:照 Ichino `dev/ui_preview_data.dart` 的做法,
不发任何请求,直接用一份假数据(Rating 15234 那套)把主页 / 旅行伙伴 / 舞里程撑起来,
能看到登录后的真实排版,也方便截图比对。数据里特意放了一条 `level=10005`(1 转 5 级)
与一条 `999999`(99 转 9999 级),验证转生拆分显示。
24. **登录卡随登录态收起**:拿到资料后主页不再顶着扫码框(登出按钮在设置页),
登录态失效时再放出来。
### 追加(第 10 轮)
25. **预览模式铺满风险页**:`?preview=1` 时自动把旅行伙伴与舞里程的假数据装上,
实测渲染:`共 3 个角色|出战槽 #0 101 · #1 102 …`、
`已拥有:#101 Lv5 · #102 Lv5(1 转)· #103 Lv9999(99 转)`——转生拆分显示正确,
舞里程区显示 `当前余额 234567|累计 9876543`。P4 / P5 的前端展示至此有据可查。
### 追加(第 11 轮 · 登录态活体验收 + 外观)
26. **P1 活体验收通过**:`GET /api/profile` 拉真实数据 —— 昵称 maimai、
Rating 9168、历史最高 12337、musicRating 9168、游玩 70 局、累计 P 9630、地区 江苏、icon 302,
userId 按约定打码成 `128****21`。
★ 客户端显示 9168,B50 本地算 9166,差 2 分正好对应那 3 首「定数未收录」的曲目——
不是算错,是缺 ds 无法计入,这也反过来印证了 ra 公式没有偏。
27. **P4 / P5 活体验收通过**:`/api/characters` 读到 48 个角色,出战槽
`455201/455202/455203/455204/550101`;`/api/mileage` 读到 point 2130 / totalPoint 9630。
写路径用 dry-run 打真实快照验过:里程 2130→2230、累计 9630→9730(preview.playCount=70 正确保留);
旅行伙伴 update=101、willSend=1、patch 键含
`userCharacterList / isNewCharacterList / userMusicDetailList / isNewMusicDetailList / userGamePlaylogList`。
(未真发写包:改数值属于账号数据,等用户自己决定。)
28. **底部图标换成 Tabler Icons**(MIT,本地 `html/assets/icons/*.svg`,不依赖外网):
home / ticket / chart-bar / alert-triangle / settings / info-circle,统一线性风格,
用 CSS `mask` 上色,选中态自动跟随 `--accent`。
29. **外观设置(设置页新增一张卡)**:上传背景图(canvas 压到最长边 1600、jpeg 0.82 存 localStorage)、
清除背景、卡片不透明度 30~100%、背景虚化 0~20px、背景压暗 0~70%、恢复默认。
卡片走 `rgba(255,255,255,var(--alpha))` + `backdrop-filter`,有背景时顶栏也跟着半透明。
持久化实测:写一套 alpha=.6 的配置后换一次加载,背景与透明度都还在。
`?looktest=1` 可复现这个自查,`?bgdemo=1` 只看渐变效果不落盘。
### 追加(第 12 轮 · 多账号)
30. **`core/accounts.py`**:扫码登录成功后自动把 `userId + token` 存到 `data/accounts.json`
(原子写,tmp → replace)。对外只给「打码 userId + 昵称 + 时间 + ref」,
**token 与完整 userId 都不出后端**;切换账号时前端回传的是 `ref`
(`sha256(userId)[:12]`),后端按 ref 反查真实行。
31. **接口**:`GET /api/accounts` 列表;`POST /api/accounts` 支持
`action=switch`(用存的 token 重新 `UserLogin`,切之前先把当前号登出,避免 returnCode=100)/
`rename` / `delete`。
32. **界面**:设置页新增「账号」卡,一行一个账号(昵称 / 打码 id / 最后使用 / 切换 / 删)。
切换时有等待遮罩,成功后会刷新主页资料。
打码统一成前 3 + 固定四星 + 后 2(`128****21`),不泄露位数。
33. **离线上测通过**:upsert / list(current 标记)/ ref 反查 / rename / touch / remove /
未知 ref 返回 None 全部符合预期;界面用一条演示数据渲染过,随后已删除,store 现在为空。
34. ⏳ **待验**:用存下来的 token 隔一段时间重新登录是否还有效。
要通过这一步才算真正确认「不用再扫码」——需要用户扫一次码来写第一条真实记录。
### 追加(第 13 轮)
35. **去掉「关于」页**(用户要求):删掉 section、底部按钮、`TABS` 里的 about 与对应 CSS 规则,
`about.svg` 也一并删了;全文已无 about 残留。`go()` 本来就有「不在 TABS 里就回落 home」的兜底,
旧的 `#about` 书签不会白屏。底部回到 5 个页签均分。
### 追加(第 14 轮 · 全曲成绩 + 推分推荐)
36. **`core/history.py`**:`api.GetUserMusicDetailAll(userId, clientId)` 拉全曲成绩
(Astral 已把两层嵌套摊平成「一条 = 一个难度」)。achievement 是 ×10000 整数,
除以 10000;定数先查 `data/master/ds_override.json`、再查 music.json;
算不出定数的标 `unrated`,不进 ra 统计,另出 `unratedItems` 供手工补。
统计含 AP / FC / FDX / 平均达成率 / 最高 ra / 按难度分布。
37. **`core/recommend.py`**:推分算法。
① 每首已打曲子算 SS+/SSS/SSS+ 三档 ra(复用 b50 的整数公式);
② 在 B35 榜上的按「新 ra − 自己当前 ra」算,不在榜上的按「新 ra − 榜尾」算;
③ `min_achievement()` 对 ra 二分反查,给出「打到 98.9934% 就能顶掉榜尾」这种具体数;
④ 候选按定数中位数 ±0.7 收窄(别推荐打不动的),按涨幅排序,前 35 首累加算整体潜力。
二分自测:ds 12.0/13.8/14.5 三档 × 四个目标档位,反查值再算回去全部 `>= 目标 ra`,
单调性成立;拿不到目标的返回 None。
38. **接口**:`GET /api/history`(默认前 120 条、full=1 全量)、
`GET /api/recommend`、`POST /api/ds`(合并写覆盖表,ds<=0 表示删)。
未登录一律拦下并给明确提示。
39. **界面**:B50 页新增「推分建议」卡 —— 两个按钮(算推分 / 看全曲成绩)。
推分列出:曲名 + 难度定数 / 当前达成 / ra / 「打到多少」/ 涨幅,顶部三个数(榜尾 ra、可推曲目、整体潜力)。
全曲成绩列出统计、难度分布、按曲名搜索、明细行(达成率 / 评级 / FC·AP / ra)。
未收录定数的条目单独一区,可直接填数字保存,存完自动重算。
40. **踩到的坑**:`stopWait` 未定义(现网只有 `endWait`)→ 加别名;`ds_override` 内层
key 是 int、保存时按 str 处理,导致 `pop("3")` 匹配不上、删除静默失效 → 统一转 str。
41. ⏳ **待真实验收**:全曲成绩要登录才能拉。扫一次码后验:
`/api/history` 条数是否接近真机 739、`/api/recommend` 是否给出合理建议、
补完那几条定数后本地算出的 rating 能不能对上客户端 9168。
### 追加(第 15 轮 · 推分多样性 + 定数联动 + 对分)
42. **推分算法返工**(离线压测 400 条假成绩暴露出来的):
原来按「打到 SSS+」的 ra 排序,结果同一批定数的曲子涨幅完全相同,前排全是 ds 14.5 的歌、
完全没有区分度(top8 定数去重后只剩 1 个值)。
改成:主档用 **SSS(100%)**算涨幅(SSS+ 只作参考列),同涨幅按 `effort`(还要提多少达成率)
升序,再按定数做稀释——**每个定数最多留 3 首**。改完 top10 里出现 4 个不同定数。
`total` / `potential` 仍按稀释前的全量算(否则潜力会被低估:39 首 vs 200 首)。
43. **★ 定数覆盖表接进 B50**(这条不修的话功能是断的):原来只有 `history.ds_for` 读
`ds_override.json`,`b50.ds_of` 不读 —— 用户补完定数,B50 页面还是 9166,永远对不上。
现在 `b50.ds_of(music, level, music_id)` 先查覆盖表;即使 music.json 里根本没这首
(国服新曲就是这种情况,music 为 None)也能走覆盖表算出 ra。
`history` 的 load/save_override 改为委托 b50,两边共用一份实现。
实测:覆盖前 ds=None/ra=0 → 覆盖后 ds=14.6/ra=306,B50 与全曲成绩两边结果完全一致。
44. **`GET /api/verify` 对分**:客户端 `playerRating` / 服务端 `GetUserRating.rating` /
本地按定数算的合计,三个数并排显示,差值不为 0 时直接告诉用户「少了 N 分,M 条定数未收录」。
界面在推分卡里加了「核对分数」按钮。
45. **全曲成绩缓存**:`history.fetch` 加 5 分钟 TTL 缓存(拉一次要翻十几页,
算推分和看成绩连着点时不该重复拉);`/api/history?full=1` 不吃截断。
46. **修复**:删除定数覆盖项静默失效(`load_override` 内层 key 是 int,保存路径按 str `pop`,
对不上)→ 统一转 str 后删除生效;`stopWait` 未定义 → 加 `endWait` 别名。
47. ⏳ **仍待真实数据验收**(要登录):`/api/history` 的真实条数、`/api/recommend` 的真实推荐、
`/api/verify` 三处 rating 是否对平(目标要求对上客户端 9168)。
### 追加(第 16 轮 · ★ rating 对平了:9166 → 9168)
48. **真实数据验证(不需要登录,`GetUserRating` 是公开接口)**:
`GET /api/b50?userId=<真实号>` → `totalRating=9166`、`b35=9166`、`b15=0`、`missing=3`。
客户端显示 9168,差 2 分——正是那 3 条定数缺失的 newRatingList。
49. **定位到那 3 条是什么**:b15 里 `11806 Expert 10.3913%`、`11836 Master 0%`、
`11835 Master 0%`(还有一条 11847 本地已有定数)。ach=0 的三条贡献 0 分,
真正差的就是 11806 —— 几乎没打过(10.39%)。
50. **补全方式改成自动**:写了 `tools/fetch_df.py`,把水鱼
`https://www.diving-fish.com/api/maimaidxprober/music_data`(1404 首,id 与国服 musicId 同源)
缓存到 `data/master/music_df.json`(146.6 KB)。
`b50.load_music()` 改成「music.json 为主,缺的 id 用水鱼补」,名字与定数一起补上,
不再依赖手工填。
51. **★ 验证结果**:清空手填覆盖表后重跑 →
`11806 Fraq Expert ds=12.2 ra=2`、`11836 ビビデバ ds=11.8`、`11835 はいよろこんで ds=11.6`、
`11847 M@GICAL☆CURE! LOVE ♥ SHOT! ds=11.2`,`missing=0`,
**`totalRating=9168` —— 与客户端完全一致**。
(12.2 × 103913 × 16 // 1e8 = 2,这 2 分就是差的那 2 分。)
52. 手填覆盖表仍保留(优先级高于水鱼缓存),作为缓存也没收的新曲兜底。
53. ⏳ 剩下要登录才能验的:`/api/history` 真实条数、`/api/recommend` 真实推荐、
`/api/verify` 三处 rating 是否显示「已对平」(本地 9168 = 客户端 9168)。
### 追加(第 17 轮 · 真实数据跑推分 + 摸清全曲接口)
54. **水鱼缓存合并生效**:`load_music()` 后曲库 1326 → 1411(补了 85 首),
11806/11836/11835/11847 的名字与四个难度定数全部到位。
`unlock` / `items` 不依赖 `load_music`,所以这个合并不会影响解锁列表。
55. **真实 B50 数据跑推分**:拿这个号真实的 50 条(B35+B15)当输入试跑
→ 榜尾 ra 254(最終鬼畜妹フランドール・S Master 12.1),候选 12 首,
整体潜力 +230(SSS 档)/ +371(SSS+ 档)。
推荐前几名:心象蜃気楼 97.2864%(+29)、フォニイ 97.0541%(+29)、ウミユリ海底譚 97.4920%(+28)……
9 个不同定数,effort 落在 1.1~3.0 之间(都是「再练一点点就有」的量),结果比假数据合理。
56. **★ 摸清 `GetUserMusicApi` 与 `GetUserRating` 的差别**:
后者免登录就能查(所以 B50 一直能用),前者**必须挂在真实登录会话上**,
否则服务端 HTTP 200 但解密后是空串(Astral 报「返回空明文」)。
实测直接调 `api.GetUserMusicDetailAll(<真实 userId>)` 就是这个错。
结论:全曲成绩一定得先扫码登录,没有免登录的路子。
57. `/api/history` 与 `/api/recommend` 的错误改成一句能照着做的提示
(「全曲成绩要登录才能拉:GetUserMusicApi 不接受免登录查询」),不再把 Astral 那一屏甩给用户。
58. `recommend.suggest` 的字段读取改成 `.get`(B50 的条目没有 `playCount`,
用它当输入时会 KeyError;真实来源 history/board 字段并不完全一致)。
59. ⏳ 最后一步:全曲成绩的真实条数只能等扫码验证。
### 追加(第 18 轮 · 免登录推分 + 真实结果验收)
60. **`/api/recommend` 支持降级**:全曲成绩要登录,但 B50 那 50 条本身就是「我打过的曲子」,
拿来当候选池照样能算推分(池子从几百条缩到 50 条,结论依然成立)。
未登录时返回 `degraded=true` 并在界面标黄说明;登录后自动走全量成绩。
这样**不用扫码就能验收推分功能**,也是给没登录用户的第一屏体验。
61. **★ 真实数据验收(这个号,未登录)**:`GET /api/recommend?userId=…`
→ `degraded=true`、候选 12 首、潜力 +230(SS+/SSS/SSS+ 三档 137 / 230 / 371)。
推荐结果:心象蜃気楼 Master 13.4(97.2864% → 97.3881%,+29)、
フォニイ Re:Master 13.3(97.0541% → 97.3655%,+29)、
ウミユリ海底譚 Master 13.4(97.4920% → 97.7612%,+28)、
失敗作少女 Master 13.1(+27)、命に嫌われている Master 12.8(+22)。
62. **`?uid=` 直达**:URL 带 uid 时自动填输入框并查 B50 + 推分,方便看别人的分,也方便我自己验收。
63. **界面重排**:海报下面那块原来的「推分建议」其实是**曲库推荐**(从没打过的曲子里挑),
和新加的「按你的成绩算」重复,改成:
① 曲库推荐默认只铺 6 张卡(3 行)+「展开全部 24 首」,原来 16 张卡占了两千多像素;
② 标题分开成「曲库推荐(还没打的)」和「推分建议(按你的成绩)」。
64. ⏳ 全曲成绩的真机拉取仍未验证(`GetUserMusicApi` 必须真实登录会话)。
### 追加(第 19 轮 · 难度筛选 + 一次白屏事故)
65. **全曲成绩支持按难度筛**:`/api/history?level=3,4`(0=Bsc 1=Adv 2=Exp 3=Mas 4=ReM),
后端先按难度过滤再截断(前端过滤只能滤已截断的 120 条,不准),返回 `shownTotal` 与 `levelFilter`。
界面在统计下面加了一排 全部 / EXP / MAS / ReM。
66. **★ 白屏事故**:给 `.lvtabs` 加样式时,anchor 找的是 `/* ---------- 推分建议 / 全曲成绩 ---------- */`,
但那行注释在 **JS 区**(`go()` 函数后面),不是 `<style>` 里 —— CSS 规则被塞进 JavaScript,
整页脚本解析失败,只省下 header 和 tabbar。
教训:这个文件样式表与脚本都在同一个 html 里,插 CSS 必须定位到 `</style>` 之前,
不能靠注释文字找位置。已修复并把规则插到 `</style>` 前。
67. 难度 chips 的状态要在 `renderHistory()` 渲染之后同步(chips 是 innerHTML 生成出来的,
渲染前 querySelector 拿不到),否则「全部」不会高亮。
68. 新曲封面:`/api/cover/11806`、`11836`、`11835`、`11633` 全部 200(96~340KB),
海报里新曲不会开天窗。
### 追加(第 20 轮 · 功能票精简 + 抓到摊平丢 musicId 的真机 bug)
69. **功能票去掉 4 倍 / 6 倍**:国服机台不再售这两档,留着只是误点。
`charge.TICKETS` 只剩 2/3/5(creditNum 1/2/4),`buy()` 对 4、6 直接拒并说明原因,
前端 `<select id="chargeId">` 同步只留三项。
70. **★ 静态审查抓到真机必崩的 bug**:`api.GetUserMusicDetailAll` 摊平时会丢 `musicId`。
服务端返回的是 `{"userMusicList": [{"musicId": N, "userMusicDetailList": [...]}]}` ——
musicId 在**组**这一层,detail 里没有;Astral 只做 `flat.extend(group["userMusicDetailList"])`,
没有把父层 musicId 带下来。照原样用,全曲成绩会拿到一堆没有曲号的条目 →
定数一律 None → 整页变成「未收录」、ra 全 0、推分也全废。
这是只有真机才暴露的问题,我这边没有登录拉不到真实数据,是靠读 Astral 源码发现的。
71. **改法**:`history._fetch_items()` 自己调 `get_all_pages` 并摊平,
每条 detail 补上父层 `musicId`(detail 自带则尊重它);拿不到 `get_all_pages`
时才退回官方封装。
72. **Mock 验证**:造了两组 3 条 detail(全部不带 musicId),摊平后
`musicId 834/834/11806` 都在,定数 14.9 / 15.0 / 12.2 全部算得出,
ra 312 / 291 / 2,`unrated=0`。没这个修复,这三条会是 `musicId=0`、名字空、定数空。
### 追加(第 21 轮 · ★ 更正上一轮的错误结论)
73. **更正第 70~72 条**:上一轮说「Astral 摊平时丢 musicId、真机必崩」——**判断错了**。
证据在 `Astral/selftest.py:548-555` 的 fixture:
```python
"userMusicDetailList": {"userMusicList": [
{"userMusicDetailList": [
{"musicId": 143, "level": 4, "playCount": 7, "achievement": 0,
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0, ...}]}]}
```
外层 group 里**没有** musicId,是**内层 detail 自带** `musicId`。
`_dispense.py:30` 的 `{(x["musicId"], x["level"]) for x in ...}` 也印证了这一点
(它取的就是摊平后的列表)。所以 `history.fetch` 原来的 `it.get("musicId")` 本来就是对的。
74. `_fetch_items()` 仍然保留,但定位改成**加固**而不是修 bug:对四种形状都成立 ——
detail 带 musicId(真实形状)原样保留;group 带而 detail 不带则补上;
两边都带以 group 为准;两边都没有则记 0。实测:
A(143,4)/(143,3)、B(834,3)、C(999,3)、D(0,3)。
75. **顺便核对了字段名**:`history._row` 用到的 `achievement / level / playCount /
comboStatus / syncStatus` 与 selftest fixture 里的 detail 字段**完全一致**,
都是 ×10000 的整数达成率,没有拼写或口径问题。
76. 结论:全曲成绩这条链路在代码层面没有已知缺陷,剩下的只有真机跑一次。
### 追加(第 22 轮 · ★★ 真机验收通过,目标达成)
**用户在机台扫码登录了**(`/api/state` → `logged=true`,账号 maimai / 128****21),
全曲成绩这条链路第一次在真号上跑通。
77. **全曲成绩(真机)**:`GET /api/history` → **207 条**(每曲每难度一条),涉及 **180 首曲子**
- 已算 ra **207 条,未收录 0 条**,总游玩次数 255
- AP 19 / FC 33 / FDX 0 / 平均达成率 86.2609% / 最高 ra 292
- 难度分布:Basic 26 · Advanced 8 · Expert 47 · Master 111 · Re:Master 12 · 宴 3
78. **★ 真机抓到两个只有上机才会暴露的问题**:
1. **宴/協/息谱算不出定数**:`111355 [協]ラグトレイン`、`100998 [宴]Oshama Scramble!`、
`111597 [息]ノンブレス・オブリージュ` 三条的 `level = 10`,
—— 服务端用越界值标记特殊谱面,定数存在 `notes[0]`,原来 `notes[level]` 直接越界返回 None。
修:`ds_of()` 里 `level >= 5 且越界` 时改用 `notes[0]`;
另加 `level_name()`,这几条的难度名显示「宴」而不是「?」。
修完 unrated 3 → 0,三条 ra 分别 292 / 175 / 155
(協ラグトレイン 175.3337% 是全场最高分,被 clamp 到 100.5%)。
2. **`syncStatus = 5` 被映射成 "sync"**:真机 207 条里 **152 条**是 5,
显示出来满屏「sync」纯属误导(EasyMai 表里有这个值,但国服没拿它当 FS/FDX 标记)。
修:`_SYNC` 去掉 5,只保留 0~4(''/fs/fsp/fdx/fdxp)。
79. **★ 自检路由 `/api/selftest`**(登录后一键跑,用户自己也能用):5/5 全过
- 全曲成绩条数:207 条 / 180 首
- 定数未收录:全部收录
- 达成率非零:平均 86.2609%,最高 ra 292
- **B35+B15 合计:本地 9168 / 客户端 9168** ✓
- 全曲最高分 vs B50 第一:OMAKENO Stroke(Master 13.8)ra 290 / B50 ra 290 ✓
- 这条检查第一版没过:宴谱 協ラグトレイン ra 292 比 B50 第一名还高。
不是 bug —— 宴谱不参与 B35 排名,比较时排除 level>=5 即可。
80. **★ 全曲模式的推分(真机)**:`/api/recommend` → `degraded=false`,候选池 207 条
- 可推 **58 首**,榜尾 ra 254(最終鬼畜妹フランドール・S Master 12.1)
- 三档潜力:SS+ **+830** / SSS **+1106** / SSS+ **+1526**
- 前几名:INTERNET OVERDOSE 94.2194% → +37、神々が恋した幻想郷 92.5207% → +37、
ドクハク 96.4595% → +35、ここからはじまるプロローグ。→ +35
- 全部 `onBoard=false`(要顶掉榜尾那类),和降级模式(全是榜内)形成对比
81. **登录态持久化验证**:重启服务会清空会话(`logged=false`),
但 `POST /api/accounts {action:"switch"}` 用保存的 token 能直接恢复 ——
实测恢复后 `isLogin=false / banState=0 / loginDateTime=1793198451`,
再拉全曲成绩照常成功。也就是说以后重启不用重新扫码。
82. **目标五项全部达成**:① 全曲成绩真机拉通 ✓ ② 定数缺漏(含水鱼补全 + 手填覆盖 + 宴谱兜底)✓
③ Ichino 整数 ra 公式 + 推分 ✓ ⑤ **9168 = 9168** ✓(④ 移动端按用户要求未做)
+182
View File
@@ -0,0 +1,182 @@
# maipulse 功能扩展计划(对齐 Ichino)
参考实现:`D:\DSH\maimai com\Ichino`(Flutter,eaquira 系列的 Dart 移植)
本文件只是计划,**不改动现有任何文件**;确认后再按阶段动手。
---
## 一、现状盘点
已做通(实机验证过):
| 功能 | 写入字段 | 状态 |
| --- | --- | --- |
| 发功能票 | UpsertUserChargelog | 通过(5 倍票 stock=1) |
| 收集品 / 礼物 | userItemList + isNewItemList | 通过(称号/伙伴/名牌/边框) |
| 解锁歌曲 | userItemList kind 5/6/7 | 通过(709 首一包全落地) |
| 解小黑屋 | UserLogout | 已改金票流程,待实测 |
Ichino 有、我们没有的:**B50 查分、修改 Rating、舞里程、一键跑图、万花筒**。
---
## 二、要加的五个功能
### 1. B50 查分(只读,零风险)—— 按 EasyMai 规划
参考:`https://github.com/Lista233/EasyMai`(Vue/Uni-app,202★)
关键文件:`pages/maib50/maib50.vue`、`utils/b50adapter.js`、`utils/ratingUtils.js`
#### 数据源对比(我们比它更直接)
EasyMai 的路子是绕的:自建后端 `getUserMusic` + 水鱼 `update_records` 导入,
再从水鱼拿回算好的 rating。我们不需要这一层:
| | EasyMai | 我们 |
| --- | --- | --- |
| 成绩 | 自建后端 + 水鱼 | `api.GetUserRating(user_id, client_id)` 直取 |
| 定数 | 本地 `musicData`(水鱼曲库) | 本地 `data/master/music.json` 的 `notes[]` |
| 鉴权 | jwt-token + import-token | 只要 userId + 机台 clientId,**不用登录** |
| 写操作 | 往水鱼导入成绩 | 无,纯只读 |
#### 已验证的接口形状(2026-10-28 实测,账号 <REDACTED>)
GetUserRating -> userRating:
rating : 0(服务端不填,别指望它)
ratingList : 35 条 {musicId, level, romVersion, achievement}
newRatingList : ≤15 条 同上
nextRatingList / nextNewRatingList / udemae
注意 **没有 ra 字段**,必须自己算。定数从
`data/master/music.json` 的 `notes[level]` 拼:`level + levelDecimal/10`
(例:11633 Master → level 13, levelDecimal 8 → 13.8)。
#### ra 公式(水鱼标准表,24 档)
achievement >= 100.5% -> 22.4 >= 95% -> 15.2 >= 82% -> 2.4
>= 100% -> 21.6 >= 94% -> 13.6 >= 80% -> 1.6
>= 99.5% -> 21.1 >= 93% -> 12.0 >= 78% -> 1.2
>= 99% -> 20.8 >= 92% -> 10.4 >= 76% -> 0.8
>= 98.5% -> 20.3 >= 91% -> 8.8 >= 74% -> 0.4
>= 98% -> 20.0 >= 90% -> 8.0 >= 72% -> 0.2
>= 97% -> 17.6 >= 86% -> 4.8 >= 70% -> 0.1
>= 96% -> 16.8 >= 84% -> 3.2 其余 -> 0
ra = floor(ds × coef)
#### 实测校验(同一账号)
B35 求和 = 9075,服务端总 rating = 9168,差 93
差的来源:newRatingList 4 条里 3 条在国服 music.json 查不到定数(未实装的新曲)
所以「定数缺失」必须当成正常情况处理,不能当错误。
#### 展示(照 EasyMai 的结构)
- 顶部:总 Rating = B35 + B15,配色按 `getCurrentRatingClass()`
(≥15000 rainbow / ≥14500 bright-gold / ≥14000 gold / ≥13000 blue / ≥12000 copper)
- 两块列表:B35(旧曲 35 条)、B15(新曲最多 15 条),每行
曲名 · 难度(Basic/Adv/Exp/Master/Re:Master)· 定数 · 达成率 · ra · FC/FS
- FC/FS 适配(`b50adapter.js`):
`comboStatus 0/1/2/3/4 -> ''/fc/fcp/ap/app`,
`syncStatus 0/1/2/3/4/5 -> ''/fs/fsp/fdx/fdxp/sync`
- 定数缺失的行标 `—`,不进合计,并在底部注明"n 首定数缺失"
#### 第一版不做
- 封面图(要拉 CDN 并转 base64)
- 海报导出、历史快照、分享(EasyMai 本地存 20 条 + 分享图)
#### 改动面
core/b50.py 取数 + 算 ra + 组装(约 120 行)
app.py + GET /api/b50?userId=
index.html + 一张「B50 查分」卡(总分 + 两个列表)
#### 验收
- 与游戏内 B50 前三名的 ra 逐个对得上
- 总分误差 = 定数缺失那几首的 ra(应能解释清楚,不是bug)
### 2. 修改 Rating(写,高风险)
- 字段:`userData[0].playerRating` + `userRatingList[0].rating`,**两处必须同时改且一致**
- 范围 0 ~ 99999(Ichino `clampValue`)
- 已知后果:下次真实游玩会被服务端按成绩重算覆盖;`ratingList` 里每首的 rating 不动
- 新增:`core/rating.py`
### 3. 舞里程(写,中风险)
- 字段:`userData[0].point`(int32 余额),可选带 `totalPoint`(累计)
- 两种模式:设为 X / 当前值 + X;负数即扣款
- 客户端的钳位只发生在「获得」路径(`UserDetail.AddMile` 钳到 99999),商店扣款不钳,
所以传大值不会被客户端回写覆盖
- 新增:`core/point.py`
### 4. 一键跑图(写,中风险)
- 字段:`userMapList` + `isNewMapList`,主键 mapId,每行
`{mapId, distance, isLock, isClear, isComplete, unlockFlag}`
- 关键:`isClear` / `isComplete` 在客户端是**从 distance 派生**的
(`MapMaster.CreateUserDataMapList`),单发 flag 下次进区域选择就被冲掉,
所以必须把 `distance` 直接推到 `UserMapData.MaxDistance = 999999999`
- `unlockFlag` 语义是反的:`IsFinishedOpening ? 0 : 1`,取 0 才是「已开启」
- 需要先 `api.GetUserMapAll()` 拿现有行合并(保留已有进度,别清零)
- 新增:`core/maprun.py`
### 5. 万花筒专区(写,高风险)
- 字段:`userKaleidxScopeList` + `isNewKaleidxScopeList`
- 每行是**整行替换**(15 字段),`isGateFound` / `isKeyFound` / `isClear` 同在一条
- 陷阱:不先回读原行合并成绩 / 日期 / playCount,会把已有记录清零
- 需先 `api.GetUserKaleidxScope()`
- 新增:`core/kaleidx.py`
---
## 三、所有写入项的统一前置(这次踩坑换来的)
1. **先查票**:`GetUserCharge` 里任何 `stock > 0` 的功能票都会让 UpsertUserAll 整包被判非法,
先拦下来提示「号上有票,先清票/等过期」;
2. **等满 60 秒**:登录后不足一分钟的结算包会被静默丢弃 → 复用 `settle.ensure_play_window`;
3. **一条占位 playlog + 一条占位 detail**(大包模式),跟发收藏品一致;
4. **一包一局**:每次 `session.fresh` 换局,一局只收一次 UpsertUserAll;
5. 每项写完都回读校验(`GetUserData` / `GetUserMap` / `GetUserRating`),不回读不算成功。
---
## 四、实施顺序
| 阶段 | 内容 | 风险 | 验收方式 |
| --- | --- | --- | --- |
| A | B50 查分 | 无(只读) | 页面显示总分与 50 首,和游戏内对得上 |
| B | 舞里程、一键跑图 | 中(可回退) | 改完回读数值;游戏内看里程/区域状态 |
| C | 修改 Rating、万花筒 | 高 | 改完回读;rating 以游戏内显示为准 |
---
## 五、改动面(确认后才动)
新增文件(互不影响):
core/b50.py core/rating.py core/point.py core/maprun.py core/kaleidx.py
追加式改动(只在文件末尾加,不动已有逻辑):
app.py +6 条路由(/api/b50, /api/rating, /api/point, /api/map, /api/kaleidx, /api/kaleidx/list)
html/index.html +3 张卡(B50 查分 / 数值修改 / 拓展功能)
复用不修改:
core/settle.py(60 秒窗口、占位 playlog)
core/session.py(登录/换局/登出)
core/items.py(GetUserItem 封装,用来回读)
core/charge.py(查票的闸门)
---
## 六、不做的事
- 不移植 Ichino 的 B50 PNG 海报(要引图表依赖,先上表格)
- 不动现有的六项功能与其界面
- 不引入 Flutter 那套 UI,继续用当前单页
+174
View File
@@ -0,0 +1,174 @@
# maipulse 发包工具 — 实施计划
> 目标:舞萌DX 国服(SDGB)本地发包工具,六项功能,GUI 操作,协议复用 Astral。
> 计划版本:v1(2026-10-21),发票口径已确认 = 2/3/5 倍功能票(charge 体系)。
---
## 一、目标与范围
| 功能 | 协议落点 | 状态 |
|---|---|---|
| 发票(2/3/5 倍功能票) | `UpsertUserChargelogApi`(chargeId 2/3/5) | 复用 Astral `buy_ticket.py` 链路 |
| 解禁小黑屋 | `UserLogoutApi`(type 5/4)+ `GetUserPreview.banState` 检测 | 待实现(会话解卡口径) |
| 解锁伙伴 | `userItemList` itemKind=10(Partner) | 待实现 |
| 发礼物 | `userItemList` itemKind=4(Present) | 待实现 |
| 发收藏品 | `userItemList` itemKind=1/2/3/9/11(Plate/Title/Icon/Character/Frame) | 待实现 |
| 解锁歌曲(一键 / 选中) | `userItemList` itemKind=5/6/7 | 内核已有,需改成真全量+分批 |
非目标:不做修改客户端、不做注入、不做成绩伪造以外的机台侧改动。
---
## 二、现状(已核实)
1. 协议层:`D:\DSH\maimai\Astral`(主分支 mai),`api/` 40+ 接口,`MaimaiNet.py` 负责 AES-256-CBC + zlib + MD5 URL 混淆 + Cookie 会话。
2. 环境:Python 3.13 + flask 3.1.0 / requests 2.32.5 / pycryptodome 3.23.0 / openpyxl 3.1.5,全部可用。
3. 机台身份:`config.py` 已配 CLIENT_ID `A63E01E3636`、PLACE_ID `2282`、REGION_ID `15` → KEYCHIP_ID `A63E-01E36362282`。
4. 反编译源码:`sdgb-unpack\full.cs\Assembly-CSharp.unpack.decompiled.cs`(9.0 MB),`ItemKind` 枚举已确认 15 种。
5. 数据表:`maipulse\data\master\` 下 ticket / partner / title / icon / plate / frame / chara / music 八张。
6. 店铺表:`SDGB_keychips_20260929.csv.xlsx`,3383 行,字段 client_id / place_id / name / region_id / region_name;**place_id 混十进制与十六进制**(`859` 与 `"082f"` 并存),必须统一解析。
7. 现有 GUI:`maipulse\app.py`(Flask,400 行)+ `html\index.html`(六面板 + 选择器),内核有下述 5 个必修缺陷。
---
## 三、六项功能的实现定义
### 1. 发票(确认口径:2/3/5 倍功能票)
- 接口:`UpsertUserChargelogApi`,走 Astral `api.UpsertUserChargelog()`。
- chargeId 与上报 price(= ticket.creditNum,不是金额):
- 2 → 2倍チケット,price 1
- 3 → 3倍チケット,price 2
- 5 → 5倍チケット,price 4
- 调用链:`ParseQRCode → GetUserPreview(顶号)→ UserLogin → GetUserCharge(查重)→ 等 15s → UpsertUserChargelog → 再查一次确认 → UserLogout(TestIn)`
- 内部已有闸门:chargeId 必须是付费票(ticketKind=1)、`stock > 0` 直接拒(重复购买有封号风险)、同账号同进程只允许一次。
- UI:票种选择 2/3/5、当前持有列表(chargeId / stock / validDate)、购买按钮、预检开关。
### 2. 解禁小黑屋
- 口径:账号卡在 `isLogin=True`(扫码提示已登录、机台进不去)。
- 做法:先 `GetUserPreview` 读状态;能拿到 preview 说明二维码有效,直接 `UserLogin` 后立刻 `UserLogout(type=5 Quit 或 4 TestIn)`,loginDateTime 用本次 login 返回的值。
- `banState != 0`(橙/红限制)属于服务端下发字段,客户端写不进 `userData`,只做**检测 + 明示**,不伪造解除。
- UI:一键解卡 + 状态展示(isLogin / banState / lastLoginDate)。
### 3. 解锁伙伴
- `userItemList` 条目 `{"itemKind":10,"itemId":<partnerId>,"stock":1,"isValid":true}`。
- 数据源:`data/master/partner.json`(id / name / lock / norm)。
- 可选增强:`userIntimateList`(主键 partnerId,字段 intimateLevel / intimateCountRewarded)——单独开关,默认不动。
### 4. 发礼物
- `userItemList` 条目 itemKind=4(Present),id 表待补:现有 `master.json` 无 present 分类,需要从 `sdgb.lemonno.xyz` 或反编译 `PresentData` 表导出。
### 5. 发收藏品
- 一个通用发放器按 kind 切表:
- 1 Plate(`plate.json`)
- 2 Title(`title.json`,724 KB)
- 3 Icon(`icon.json`)
- 9 Character(`chara.json`)
- 11 Frame(`frame.json`)
- UI:分类切换 + 搜索 + 多选 + 数量。
### 6. 解锁歌曲
- itemKind:5 本体 / 6 Master / 7 Re:Master(8 = Strong,前端标签需改)。
- 规则:musicId < 10000 的标准谱 Master/Re 天生解锁,不写 6/7;只写 5。
- 一键全量:`music.json` 全表 → 减掉 `GetUserItem(5)` 已有 → 分批(单包 ≤4 首,规避 2 首包被拒,按 4/3/3 切)→ 每包前 fresh session(登出 + 重登)→ 落库回查。
- 选中解锁:id 列表直接走 `UpsertUserAll(unlock_music_ids=[...])`。
---
## 四、内核必修(审计结论)
1. **位串长度错**:`_push_items` 发完整 `userItemList`,却只给 `"1"*新增数` 的 `isNewItemList`。`BuildListData` 要求一位一条 → 必须逐条 0(存量)/1(新增),否则 500 或重复插入。
2. **全量解锁是假的**:`unlock_music_ids=[-1]` 会被 `build_unlock_items` 直接丢弃(只认 >0 的 id)。必须换成真实全表 + 分批。
3. **假成绩风险**:固定塞 `music 11308 / level 3 / achievement 0` 的 playlog,服务端可能不收;改为最小合法 playlog,且不污染账号统计(playCount 增量可控)。
4. **每包新会话**:服务端把一次登录当一局,一局只收一次 Upsert → 批量流程强制 `_fresh_session`。
5. **kind8 命名**:Survival → Strong(`MusicUnlockStrongList`)。
6. **place_id 解析**:xlsx 里 `"082f"` 按十六进制 → 2095,`859` 按十进制;统一转 int 后再拼 KEYCHIP。
---
## 五、架构
```
maipulse/
app.py Flask 路由(/api/qr /login /logout /charge /grant /unlock /repair /shop /master)
core/session.py 会话:ParseQRCode、GetUserPreview、UserLogin、fresh_session、UserLogout
core/charge.py 功能票:GetUserCharge 查重 → 15s → UpsertUserChargelog → 回查
core/grant.py 通用道具:GetUserItem(kind) → 合并 → 位串 → UpsertUserAll patch
core/unlock.py 歌曲:全量分批 / 选中
core/repair.py 小黑屋:状态检测 + 解卡登出
core/shop.py xlsx 解析 + 店铺搜索 + config 回写 + KEYCHIP 校验
data/master/*.json
html/index.html 六面板
```
原则:协议层只 import Astral,不复制、不 fork;业务层只做编排与数据合并;前端只发 HTTP。
---
## 六、里程碑
| 阶段 | 内容 | 验收 |
|---|---|---|
| M0 冒烟 | 跑 `selftest.py`、起 maipulse、dry-run 空包 | 284 项绿 + 页面可开 |
| M1 内核 | 位串 / 全量 / playlog / fresh session / 回查 | dry-run 报文节点与位串长度一致 |
| M2 功能 | 票 → 伙伴 → 收藏品 → 礼物 → 歌曲 → 小黑屋 | 每项 dry-run + 单条实发 + Get 回查 |
| M3 店铺 | xlsx 解析(hex 修正)+ 店铺选择 + config 回写 | KEYCHIP 与表内一致 |
| M4 界面 | 六面板 + 状态卡 + 错误详情 + 日志 | 全流程浏览器操作 |
| M5 验收 | 逐功能 预检 → 实发 → 游戏内确认 | 验收记录 + 截图 |
---
## 七、风险与验证口径
- 一局一 Upsert、单包 ≤4 首、2 首包被拒 → 分批固定 4/3/3。
- 重复插入已有条目 → HTTP 500;所有发放前必须先 Get 差集。
- `banState` 只读,不伪造。
- 二维码 10 分钟过期,一过期就得回机台重扫。
- 所有写操作默认 dry-run,UI 上必须显式勾选「真实发包」。
- 每步落库回查(GetUserItem / GetUserCharge),以服务器回读为准,不以本地构造为准。
---
## 八、实测结论(2026-10-27)
### ★ 一局至少 60 秒 —— 之前所有"回码 1 但不落库"的真正原因
MaiTechUI 金票包 UpsertUserAllApi.poop() 原样:
long num = Configs.getNowUnixTime() - Configs.NowLoginTime;
Thread.Sleep(Configs.LogoutTimeLimit - (int)num * 1000); // LogoutTimeLimit = 60000
return base.poop();
它从不在登录后 60 秒内提交结算。服务端对"登录不到一分钟就交成绩"的包一律回
returnCode=1 然后把 userItemList / userMusicDetailList 整块丢掉。
已验证(账号 <REDACTED>):每次发包前补足 60 秒后,四类道具全部真落地。
| kind | 名称 | 发送 id | 库存变化 | waited |
| --- | --- | --- | --- | --- |
| 2 | 称号 | 32 | 147 → 148 | 57.7s |
| 10 | 伙伴 | 12 | 22 → 23 | 52.6s |
| 1 | 名牌 | 2 | 49 → 50 | 57.4s |
| 11 | 边框 | 2 | 41 → 42 | 56.8s |
实现:core/settle.py:ensure_play_window(),登录时在 state 里记 login_at,
items.grant / unlock.unlock_selected / unlock.unlock_all 在发 UpsertUserAll 前调用。
### 本局必须是 dummy 曲 11538 的自洽全 Miss
除了 playlog,还要把这一局的四处痕迹一起对上(见 settle.py 头注释):
userData.lastPlayDate/dateTime、userMusicDetailList + isNewMusicDetailList、
userGamePlaylogList(playTrack=1 / playlogId=loginId / playSpecial 随机置换)、
userPlaylogList(achievement=0 且有 miss)。
### 重启必须走 tools/restart.ps1
直接杀进程会把服务端会话留在 isLogin=True,账号进小黑屋(登录回 100),
且 loginId 只存在内存里,事后连 UserLogout 都发不对。
/api/shutdown 会先登出再退出。
+1153
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
"""maipulse 业务层:会话 / 道具 / 功能票 / 解禁 / 解卡 / 店铺。
协议实现全部来自同目录的 Astral 工程(api/ 包),这里只做编排。
"""
from __future__ import annotations
import os
import sys
# ★ 让 core.* 无论从哪个入口 import 都能找到 api 包(Flask、自检脚本、CLI 都一样)
_ASTRAL = os.environ.get("ASTRAL_DIR", r"D:\DSH\maimai\Astral")
if os.path.isdir(_ASTRAL) and _ASTRAL not in sys.path:
sys.path.insert(0, _ASTRAL)
+161
View File
@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
"""多账号:把扫码换来的 userId + token 存本机,之后直接用它重新登录。
为什么能省掉扫码:二维码只是一次性入口,扫出来的 token 才是账号凭证。
`session.login(state, user_id, token)` 每天都用它做 UserLogin——只要机台上
没有重新扫码把旧凭证顶掉,同一个 token 可以反复登录。
★ token 属于敏感信息:
· 只写 `data/accounts.json`(本机,不进任何仓库、不上传);
· 接口返回给前端的只有打码 userId 与昵称,token 不出这个模块。
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from pathlib import Path
_STORE = Path(__file__).resolve().parent.parent / "data" / "accounts.json"
def mask_user_id(user_id) -> str:
"""12345678901 -> 123****01(和面板其它地方同一口径)。"""
s = str(user_id or "")
if not s:
return "-"
if len(s) <= 6:
return s[:1] + "***"
return s[:3] + "****" + s[-2:] # 固定 4 星,不泄露位数
def ref_of(user_id) -> str:
"""对外引用:userId 的短哈希。
前端拿到的永远是打码 id 与这个 ref,切换账号时只回传 ref,
完整 userId 与 token 都不出后端。
"""
return hashlib.sha256(str(int(user_id)).encode("utf-8")).hexdigest()[:12]
def get_by_ref(ref):
ref = str(ref or "")
if not ref:
return None
for row in _load():
if ref_of(row["userId"]) == ref:
return row
return None
def _load() -> list:
try:
raw = json.loads(_STORE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
rows = raw.get("accounts") if isinstance(raw, dict) else raw
out = []
for row in (rows or []):
if not isinstance(row, dict):
continue
try:
uid = int(row.get("userId") or 0)
except (TypeError, ValueError):
continue
token = str(row.get("token") or "")
if uid <= 0 or not token:
continue
out.append({
"userId": uid,
"token": token,
"name": str(row.get("name") or ""),
"savedAt": str(row.get("savedAt") or ""),
"lastUsed": str(row.get("lastUsed") or ""),
})
return out
def _save(rows: list) -> None:
_STORE.parent.mkdir(parents=True, exist_ok=True)
tmp = _STORE.with_suffix(".tmp")
payload = {"accounts": rows}
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, _STORE) # 原子替换,写一半断电不会把存档写坏
def _now() -> str:
return time.strftime("%Y-%m-%d %H:%M:%S")
def list_accounts(current_user_id=None) -> list:
"""给前端看的列表:没有 token,只有打码 id / 昵称 / 时间。"""
cur = str(current_user_id or "")
rows = _load()
rows.sort(key=lambda r: r.get("lastUsed") or "", reverse=True)
return [{
"ref": ref_of(r["userId"]),
"userId": mask_user_id(r["userId"]),
"name": r["name"] or "(未命名)",
"lastUsed": r["lastUsed"] or r["savedAt"] or "-",
"current": str(r["userId"]) == cur,
} for r in rows]
def upsert(user_id, token, name: str = "") -> None:
"""登录成功后保存 / 刷新这个账号。"""
uid = int(user_id)
token = str(token or "")
if uid <= 0 or not token:
return
rows = _load()
for row in rows:
if row["userId"] == uid:
row["token"] = token
row["lastUsed"] = _now()
if name:
row["name"] = name
break
else:
rows.append({"userId": uid, "token": token, "name": name or "",
"savedAt": _now(), "lastUsed": _now()})
_save(rows)
def get(user_id):
uid = int(user_id)
for row in _load():
if row["userId"] == uid:
return row
return None
def touch(user_id) -> None:
uid = int(user_id)
rows = _load()
for row in rows:
if row["userId"] == uid:
row["lastUsed"] = _now()
_save(rows)
return
def rename(user_id, name: str) -> bool:
uid = int(user_id)
rows = _load()
for row in rows:
if row["userId"] == uid:
row["name"] = str(name or "")[:40]
_save(rows)
return True
return False
def remove(user_id) -> bool:
uid = int(user_id)
rows = _load()
left = [r for r in rows if r["userId"] != uid]
if len(left) == len(rows):
return False
_save(left)
return True
+340
View File
@@ -0,0 +1,340 @@
# -*- coding: utf-8 -*-
"""B50 查分(只读,不发包)。
数据链路(比 EasyMai 直接一层):
GetUserRating(userId, clientId) -> userRating.ratingList(35) + newRatingList(≤15)
定数 -> data/master/music.json 的 notes[level]:level + levelDecimal/10
ra -> floor(ds × 系数),系数是水鱼标准 24 档表
★ 服务端**不返回 ra**(rating 字段恒为 0),必须自己算。
★ 国服曲库可能缺新曲定数(实测 newRatingList 4 条里 3 条查不到),
这种情况按「未收录」返回,不参与合计,不是错误。
"""
from __future__ import annotations
import json
import math
import os
import api
from . import session
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MUSIC_JSON = os.path.join(APP_DIR, "data", "master", "music.json")
#: 手填的定数覆盖表(国服新曲 music.json 里没有,ra 就算不出来)
OVERRIDE_JSON = os.path.join(APP_DIR, "data", "master", "ds_override.json")
#: 水鱼曲库缓存(tools/fetch_df.py 生成)—— 补 music.json 里还没有的新曲名字与定数
DF_JSON = os.path.join(APP_DIR, "data", "master", "music_df.json")
LEVEL_NAMES = ("Basic", "Advanced", "Expert", "Master", "Re:Master")
# ★ ra / 评级:照 Ichino `services/rating_calculator.dart`(移植自 Empurple),
# 整数运算,和客户端一致。之前用的 24 档系数表会差 1 分(13.8 / 99.6636 那张
# 表给 291,正确的 290),所以改成这个。
# 达成率区间上界(原始值 = achievement × 10000)/ RA 系数 / 评级。
_BANDS = (
(100000, 0, "D"), (200000, 16, "D"), (300000, 32, "D"), (400000, 48, "D"),
(500000, 64, "D"), (600000, 80, "C"), (700000, 96, "B"), (750000, 112, "BB"),
(799999, 120, "BBB"), (800000, 128, "BBB"), (900000, 136, "A"), (940000, 152, "AA"),
(969999, 168, "AAA"), (970000, 176, "AAA"), (980000, 200, "S"), (989999, 203, "Sp"),
(990000, 206, "Sp"), (995000, 208, "SS"), (999999, 211, "SSp"), (1000000, 214, "SSp"),
(1004999, 216, "SSS"), (1005000, 222, "SSS"),
)
_MAX_ACH = 1005000
_SSSP_OFFSET = 224
# 照 EasyMai utils/b50adapter.js
_COMBO = {0: "", 1: "fc", 2: "fcp", 3: "ap", 4: "app"}
# ★ 真机上 syncStatus=5 占多数(实测 207 条里 152 条),EasyMai 表里给的是 "sync",
# 但这个值在国服没有被当成 FS/FDX 系标记用,显示出来只会让人困惑 —— 不映射。
_SYNC = {0: "", 1: "fs", 2: "fsp", 3: "fdx", 4: "fdxp"}
_music_cache = None
_override_cache = None
def load_override() -> dict:
"""{musicId(int): {level(int): ds}} —— 手填的定数覆盖表,改了文件要 reload_override()。"""
global _override_cache
if _override_cache is None:
data = {}
if os.path.exists(OVERRIDE_JSON):
try:
with open(OVERRIDE_JSON, encoding="utf-8") as fp:
raw = json.load(fp) or {}
for mid, levels in raw.items():
try:
key = int(mid)
except (TypeError, ValueError):
continue
if not isinstance(levels, dict):
continue
slot = {}
for lv, ds in levels.items():
try:
slot[int(lv)] = float(ds)
except (TypeError, ValueError):
continue
if slot:
data[key] = slot
except (OSError, ValueError):
data = {}
_override_cache = data
return _override_cache
def reload_override() -> dict:
global _override_cache
_override_cache = None
return load_override()
def _df_rows() -> dict:
"""水鱼缓存 -> 与 music.json 同形状的曲目行(notes 按 ds 拆成 level/levelDecimal)。"""
if not os.path.exists(DF_JSON):
return {}
try:
with open(DF_JSON, encoding="utf-8") as fp:
raw = json.load(fp) or {}
except (OSError, ValueError):
return {}
out = {}
for key, row in raw.items():
try:
mid = int(key)
except (TypeError, ValueError):
continue
notes = []
for ds in (row.get("ds") or []):
try:
value = round(float(ds), 1)
except (TypeError, ValueError):
notes.append({"level": 0, "levelDecimal": 0})
continue
base = int(value)
dec = int(round((value - base) * 10))
notes.append({"level": base, "levelDecimal": dec})
out[mid] = {"id": mid, "name": row.get("name") or "", "notes": notes,
"_source": "df"}
return out
def load_music() -> dict:
"""musicId -> 曲目行(含 notes 定数)。缓存常驻,改文件后重启即可。
★ 以 music.json 为主;里面没有的 id 用水鱼缓存补上(名字 + 定数)。
国服 music.json 会比服务端曲库旧一个版本,新曲不补的话
B50 的 newRatingList 会有几条算不出 ra、rating 比客户端少几分。
"""
global _music_cache
if _music_cache is None:
with open(MUSIC_JSON, encoding="utf-8") as fp:
rows = json.load(fp)
db = {int(m.get("id") or 0): m for m in rows if m.get("id")}
added = 0
for mid, row in _df_rows().items():
if mid in db:
continue
db[mid] = row
added += 1
_music_cache = db
if added:
print("[b50] 水鱼缓存补了 %d 首(music.json 里没有)" % added)
return _music_cache
def ds_of(music: dict, level: int, music_id=None):
"""定数:先查手填覆盖表,再取 notes[level] 的 level + levelDecimal/10。
★ 覆盖表必须在这里生效 —— B50 和全曲成绩共用这套定数,
否则用户补完定数,B50 页面还是老的 9166,两边对不上。
"""
if music_id:
manual = load_override().get(int(music_id), {}).get(int(level))
if manual:
return round(float(manual), 1)
notes = (music or {}).get("notes") or []
# ★ 宴 / 協 / 息 谱:服务端 level 用 10 这类越界值标记,定数放在 notes[0]。
# 不这么处理这几条永远算不出 ra(真机实测 3 条:協ラグトレイン、宴Oshama、息ノンブレス)
if level >= 5 and level >= len(notes) and notes:
level = 0
if level < 0 or level >= len(notes):
return None
note = notes[level] or {}
base = int(note.get("level") or 0)
if base <= 0:
return None
return round(base + int(note.get("levelDecimal") or 0) / 10.0, 1)
def level_name(level) -> str:
"""难度名。宴/協/息谱服务端用 level>=5 标记,界面上统一显示「宴」。"""
try:
level = int(level)
except (TypeError, ValueError):
return "?"
if 0 <= level < len(LEVEL_NAMES):
return LEVEL_NAMES[level]
if level >= 5:
return "宴"
return "?"
def rate_of(achievement: float):
"""返回 (ra, 评级码)。评级码对齐 Ichino 的 UI_TTR_Rank_*.png:D/C/B/BB/BBB/A/AA/AAA/S/Sp/SS/SSp/SSS/SSSp。"""
raw = int(round(float(achievement) * 10000))
raw = max(0, min(_MAX_ACH, raw))
offset, rate = _SSSP_OFFSET, "SSSp"
for upper, band_offset, band_rate in _BANDS:
if raw < upper:
offset, rate = band_offset, band_rate
break
return offset, rate
def ra_of(ds, achievement: float):
"""整数公式:round(ds×10) × raw × offset / 1e8,和客户端一致。"""
if not ds:
return 0
offset, _ = rate_of(achievement)
raw = max(0, min(_MAX_ACH, int(round(float(achievement) * 10000))))
return int(round(ds * 10)) * raw * offset // 100000000
def _row(item: dict, music: dict) -> dict:
level = int(item.get("level") or 0)
ach = int(item.get("achievement") or 0) / 10000.0
music_id = int(item.get("musicId") or 0)
# ★ 即使 music.json 里没有这首(music 为 None),也要走一遍覆盖表:
# 国服新曲正是这种情况,补了定数才算得出 ra
ds = ds_of(music, level, music_id)
_, rate = rate_of(ach)
return {
"musicId": int(item.get("musicId") or 0),
"name": (music or {}).get("name") or "",
"level": level,
"levelName": level_name(level),
"ds": ds,
"achievement": round(ach, 4),
"ra": ra_of(ds, ach),
"rate": rate,
"combo": _COMBO.get(int(item.get("comboStatus") or 0), ""),
"sync": _SYNC.get(int(item.get("syncStatus") or 0), ""),
"rank": int(item.get("scoreRank") or 0),
"romVersion": int(item.get("romVersion") or 0),
"unrated": ds is None,
}
def _block(items: list, music_db: dict) -> dict:
rows = []
total = 0
missing = 0
for item in items or []:
music = music_db.get(int(item.get("musicId") or 0))
row = _row(item, music)
if row["unrated"]:
missing += 1
total += row["ra"]
rows.append(row)
rows.sort(key=lambda r: (-r["ra"], -r["achievement"]))
return {"count": len(rows), "rating": total, "missing": missing, "items": rows}
def fetch(user_id: int) -> dict:
"""只读:拿 B35 / B15 与合计。不需要登录、不需要 token。"""
who = session.ids()
raw = api.GetUserRating(int(user_id), client_id=who["client_id"])
rating = (raw or {}).get("userRating") or {}
music_db = load_music()
b35 = _block(rating.get("ratingList"), music_db)
b15 = _block(rating.get("newRatingList"), music_db)
total = b35["rating"] + b15["rating"]
return {
"userId": int(user_id),
"totalRating": total,
"b35": b35,
"b15": b15,
"missing": b35["missing"] + b15["missing"],
"serverRating": int(rating.get("rating") or 0),
"recommend": recommend(b35, b15, total, music_db),
}
# ★ 推分建议(对齐 S.Noist / EasyMai 那套口径)
# 平均定数 = 总 rating / (22.4 × 50),推荐区间取 [平均, 平均+0.5];
# 候选曲 = 该区间内、打到 SS+(99.5%)能超过 B35 最低分的曲目,按可涨分值排序。
_TARGETS = (("SS+", 99.5), ("SSS", 100.0), ("SSS+", 100.5))
def recommend(b35: dict, b15: dict, total: int, music_db: dict, top: int = 24) -> dict:
r35 = [r["ra"] for r in b35["items"] if not r["unrated"]]
ds35 = sorted(r["ds"] for r in b35["items"] if not r["unrated"] and r["ds"])
floor_ra = min(r35) if r35 else 0
avg = round(total / (22.4 * 50), 1) if total else 0.0
# ★ 推荐区间用 B35 定数的**中位数**,不是 total/(22.4×50)。
# 后者算的是「全 SSS+ 时需要的平均定数」,对低 rating 账号会低到没有候选(实测 8.1,0 首);
# 中位数反映的是「这个号实际在打的难度」,正好是能推得动的那一档。
med = ds35[len(ds35) // 2] if ds35 else avg
lo = max(1.0, med)
hi = min(15.0, round(med + 0.5, 1))
rows = []
for mid, music in music_db.items():
for level in (3, 4):
ds = ds_of(music, level)
if not ds or ds < lo or ds > hi:
continue
vals = {name: ra_of(ds, ach) for name, ach in _TARGETS}
gain = vals["SSS+"] - floor_ra
if gain <= 0:
continue
rows.append({
"musicId": mid,
"name": music.get("name") or "",
"level": level,
"levelName": level_name(level),
"ds": ds,
"raSSPlus": vals["SS+"],
"raSSS": vals["SSS"],
"raSSSPlus": vals["SSS+"],
"gain": gain,
"gainSSPlus": max(0, vals["SS+"] - floor_ra),
})
rows.sort(key=lambda r: (-r["gain"], -r["ds"]))
# 区间内候选太少时放宽一档(低 rating 账号常常只有几首同定数)
if len(rows) < 8:
for mid, music in music_db.items():
for level in (3, 4):
ds = ds_of(music, level)
if not ds or ds < lo or ds > min(15.0, hi + 0.5):
continue
if any(r["musicId"] == mid and r["level"] == level for r in rows):
continue
vals = {name: ra_of(ds, ach) for name, ach in _TARGETS}
gain = vals["SSS+"] - floor_ra
if gain <= 0:
continue
rows.append({
"musicId": mid, "name": music.get("name") or "",
"level": level, "levelName": level_name(level), "ds": ds,
"raSSPlus": vals["SS+"], "raSSS": vals["SSS"],
"raSSSPlus": vals["SSS+"], "gain": gain,
"gainSSPlus": max(0, vals["SS+"] - floor_ra),
})
rows.sort(key=lambda r: (-r["gain"], -r["ds"]))
# 保守/乐观潜力:拿前 35 名的可涨分累加(B35 只有 35 个位置)
pot_ssp = sum(r["gain"] for r in rows[:35])
pot_sssp = sum(max(0, r["raSSSPlus"] - floor_ra) for r in rows[:35])
return {
"averageDs": avg,
"dsRange": [lo, hi],
"floorRa": floor_ra,
"targetRA": {"SS+": ra_of(hi, 99.5), "SSS+": ra_of(hi, 100.5)},
"potential": {"SS+": pot_ssp, "SSS+": pot_sssp},
"songs": rows[:top],
"total": len(rows),
}
+236
View File
@@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
"""旅行伙伴(userCharacterList)+ 出战编组(UserDetail.charaSlot)。
照 Ichino `models/user_character.dart` 与 `travel_partner_page.dart`:
· 角色不是「搭档」。搭档是 ItemKind.Partner=10,走 userItemList;
角色的拥有状态由 `upsertUserAll.userCharacterList` 表达,
一行四个字段 {characterId, level, awakening, useCount},主键 characterId。
· 出战编组是 `userData[0].charaSlot`,固定 int[5],槽 0 是队长。
· `isNewCharacterList` 是逐行位串:服务端已有同 characterId 的行走 '0',
没有才是 '1'(客户端 BuildListData 同口径)。
· ID 不在机台 Chara 表里时服务端照样保存,但客户端 CharacterSelectProces
会静默跳过 —— 所以发放结果要如实回报「服务端已存,游戏内可能不显示」。
"""
from __future__ import annotations
import importlib
import api
from . import session, settle
_upsert = importlib.import_module("api.UpsertUserAll")
_build_user_all = _upsert.build_user_all
#: 旅行伙伴等级边界(Ichino UserAllPayloadBuilder.minCharacterLevel / maxCharacterLevel)
MIN_LEVEL = 1
MAX_LEVEL = 999999
def clamp_level(value) -> int:
"""等级钳到 1..999999 —— 客户端 `UserChara.Clear()` 初值是 1,0 会被判非法。"""
try:
v = int(value)
except (TypeError, ValueError):
return MIN_LEVEL
return MIN_LEVEL if v < MIN_LEVEL else (MAX_LEVEL if v > MAX_LEVEL else v)
def split_level(level: int) -> tuple:
"""真实等级 -> (界面等级, 转生次数)。
客户端 `UserChara` 把两项编码在一个 int 里:显示 level % 10000,
转生 level // 10000(999999 = 99 转生 + 9999 级)。
"""
try:
v = int(level)
except (TypeError, ValueError):
v = MIN_LEVEL
if v < 0:
v = 0
return (v % 10000, v // 10000)
def _user_row(snapshot: dict) -> dict:
node = (snapshot or {}).get("userData")
if isinstance(node, dict):
node = node.get("userData")
if isinstance(node, list):
for row in node:
if isinstance(row, dict):
return row
if isinstance(node, dict):
return node
return {}
def _char_rows(snapshot: dict) -> list:
node = (snapshot or {}).get("userCharacterList")
if isinstance(node, dict):
node = node.get("userCharacterList")
rows = []
for raw in (node or []):
if not isinstance(raw, dict):
continue
try:
cid = int(raw.get("characterId") or 0)
except (TypeError, ValueError):
continue
if cid <= 0:
continue
level = int(raw.get("level") or 0)
shown, rebirth = split_level(level)
rows.append({
"characterId": cid,
"level": level,
"displayLevel": shown,
"rebirth": rebirth,
"awakening": int(raw.get("awakening") or 0),
"useCount": int(raw.get("useCount") or 0),
})
rows.sort(key=lambda r: r["characterId"])
return rows
def _slots(row: dict) -> list:
raw = row.get("charaSlot") or []
values = []
for item in list(raw)[:5]:
try:
values.append(int(item or 0))
except (TypeError, ValueError):
values.append(0)
while len(values) < 5:
values.append(0)
return values
def read(state: dict) -> dict:
"""只读:已拥有角色 + 当前编组。"""
uid = int(state.get("user_id") or 0)
token = state.get("token") or ""
if not uid or not token:
raise session.SessionError("还没有登录,先扫码登录")
snap = api.GetNeedUserData(uid, token=token, save=False)
row = _user_row(snap)
chars = _char_rows(snap)
return {
"characters": chars,
"count": len(chars),
"slots": _slots(row),
}
def write(state: dict, *, add=(), levels=None, slots=None, dry_run: bool = True) -> dict:
"""发放角色 / 改等级 / 编组出战槽位。
add: 要发放或更新的 characterId 列表
levels: {characterId: level},不给就保持原样(新角色默认 1 级)
slots: 长度 5 的列表,不给就不动编组
"""
uid = int(state.get("user_id") or 0)
if not uid or not state.get("token"):
raise session.SessionError("还没有登录,先扫码登录")
add = [int(x) for x in (add or []) if int(x) > 0]
levels = {int(k): clamp_level(v) for k, v in (levels or {}).items()
if int(k) > 0}
who = session.ids()
session.fresh(state)
snapshot = api.GetNeedUserData(uid, token=state["token"], save=False)
have = {row["characterId"]: row for row in _char_rows(snapshot)}
row = _user_row(snapshot)
result = {
"dryRun": bool(dry_run),
"add": add,
"new": [c for c in add if c not in have],
"update": [c for c in add if c in have],
"slotsBefore": _slots(row),
"slotsAfter": None,
"landed": None,
}
patch = {}
if add:
rows = []
flags = []
for cid in add:
base = dict(have.get(cid) or {"characterId": cid, "level": 1,
"awakening": 0, "useCount": 0})
if cid in levels:
base["level"] = levels[cid]
rows.append(base)
flags.append("0" if cid in have else "1")
patch["userCharacterList"] = rows
patch["isNewCharacterList"] = "".join(flags)
play, playlog = settle.prepare(state, snapshot)
patch.update(settle.patch(state, snapshot, play))
new_slots = None
if slots is not None:
new_slots = [int(x or 0) for x in list(slots)[:5]]
while len(new_slots) < 5:
new_slots.append(0)
result["slotsAfter"] = new_slots
# charaSlot 在 userData 节点里,整表覆盖必须给完整行
preview = _build_user_all(
snapshot, play, patch,
login_date_time=int(state["login_date_time"]),
auth_date_time=int(state["date_time"]),
playlog_id=int(state["login_id"]),
place_id=who["place_id"], place_name=who["place_name"],
region_id=who["region_id"], region_name=who["region_name"],
client_id=who["client_id"],
)
rows_ud = preview.get("userData") or []
if not rows_ud or not isinstance(rows_ud[0], dict):
raise session.SessionError("组包失败:userData 节点为空")
merged = dict(rows_ud[0])
merged["charaSlot"] = new_slots
patch["userData"] = [merged]
else:
result["slotsAfter"] = result["slotsBefore"]
if dry_run:
result["patchKeys"] = sorted(patch.keys())
result["willSend"] = len(patch.get("userCharacterList") or [])
return result
settle.ensure_play_window(state)
settle.upload(state, playlog)
resp = api.UpsertUserAll(
uid, state["login_id"],
login_date_time=state["login_date_time"],
auth_date_time=state["date_time"],
token=state["token"],
snapshot=snapshot,
play=play,
user_playlog_list=[playlog],
patch=patch,
verify_landing=False,
auto_characters=False,
place_id=who["place_id"], place_name=who["place_name"],
region_id=who["region_id"], region_name=who["region_name"],
client_id=who["client_id"],
cooldown_sec=0.0,
dry_run=False,
confirm=True,
)
result["returnCode"] = getattr(resp, "returnCode", None)
# 回读验收
try:
after = api.GetNeedUserData(uid, token=state["token"], save=False)
now = {r["characterId"] for r in _char_rows(after)}
result["landed"] = sorted(c for c in add if c in now)
result["missing"] = sorted(c for c in add if c not in now)
result["slotsNow"] = _slots(_user_row(after))
result["ok"] = (not result["missing"]) and (
new_slots is None or result["slotsNow"] == new_slots)
except Exception as exc: # noqa: BLE001
result["landed"] = "回读失败:%s" % exc
result["ok"] = None
return result
+106
View File
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""功能票(2 / 3 / 5 倍票)—— UpsertUserChargelogApi。
★ 4 倍与 6 倍票已从选项里去掉:国服机台不再售这两档,留着只会让人误点。
★ 与「发道具」不是同一套:
功能票走 userChargeList(GetUserChargeApi),买票走 UpsertUserChargelogApi;
上报的 price 不是金额,而是 ticket.creditNum:
chargeId 2 = 2倍票 price 1
chargeId 3 = 3倍票 price 2
chargeId 5 = 5倍票 price 4
(依据 VOExtensions.cs:410 result.price = ticket.creditNum)
安全闸门(Astral 内部):
1. chargeId 必须是付费票(ticketKind=1);
2. 账号已持有该票(stock > 0)直接拒 —— 重复购买有封号风险;
3. 必须先跑过 GetUserCharge(本模块开跑就先查一次,天然满足);
4. 同一账号同一进程只允许买一次。
"""
from __future__ import annotations
import time
import api
from . import session, settle
#: chargeId -> creditNum(= 上报的 price)。Astral TICKET_CREDIT_NUM:
#: 付费票 N -> creditNum N-1。
#: ★ 只保留 2 / 3 / 5 三档 —— 国服机台已经不出 4 倍和 6 倍了,
#: 买了也只是空上报,别让用户以为还能选。
TICKETS = {
2: {"name": "2倍票", "price": 1},
3: {"name": "3倍票", "price": 2},
5: {"name": "5倍票", "price": 4},
}
DEFAULT_WAIT = 15.0 # 与真机节奏一致(登录 -> 买票约 15~25 秒)
def ticket_name(charge_id: int) -> str:
return TICKETS.get(int(charge_id), {}).get("name", "chargeId=" + str(charge_id))
def list_charge(user_id: int) -> list:
who = session.ids()
raw = api.GetUserCharge(user_id, client_id=who["client_id"])
rows = raw.get("userChargeList") if isinstance(raw, dict) else raw
return [dict(r) for r in (rows or []) if isinstance(r, dict)]
def buy(state: dict, charge_id: int, *, dry_run: bool = False,
wait: float = DEFAULT_WAIT) -> dict:
charge_id = int(charge_id)
if charge_id not in TICKETS:
raise RuntimeError("只支持 chargeId 2 / 3 / 5(2 倍 / 3 倍 / 5 倍票;4 倍与 6 倍已停用)")
user_id = int(state["user_id"])
session.fresh(state)
before = list_charge(user_id)
held = [c for c in before if int(c.get("stock") or 0) > 0]
if held:
detail = "、".join(
"%s(chargeId=%s) stock=%s" % (ticket_name(c.get("chargeId")),
c.get("chargeId"), c.get("stock"))
for c in held
)
raise RuntimeError("账号已持有功能票:" + detail
+ "。重复购买有封号风险,先用掉再买")
if wait > 0 and not dry_run:
time.sleep(float(wait))
# ★ 和 UpsertUserAll 一样,登录后不足 60 秒发的写包会被服务端丢掉
# (表现就是 returnCode=0,什么都没写)。金票买票前也走同一段等待逻辑。
waited = settle.ensure_play_window(state)
who = session.ids()
resp = api.UpsertUserChargelog(
user_id, charge_id, state["login_date_time"],
dry_run=bool(dry_run),
confirm=not dry_run,
guard_no_stock=True,
guard_purchasable=False, # 不按机台上架清单拦
place_id=who["place_id"],
region_id=who["region_id"],
client_id=who["client_id"],
)
after = list_charge(user_id)
mine = [c for c in after if int(c.get("chargeId") or 0) == charge_id]
return {
"chargeId": charge_id,
"ticket": ticket_name(charge_id),
"price": TICKETS[charge_id]["price"],
"dryRun": bool(dry_run),
"returnCode": getattr(resp, "returnCode", None),
"apiName": getattr(resp, "apiName", ""),
"before": before,
"after": after,
"landed": bool(mine and int(mine[0].get("stock") or 0) > 0),
"stock": int(mine[0].get("stock") or 0) if mine else 0,
"validDate": mine[0].get("validDate") if mine else None,
"waited": round(float(waited), 1),
}
+220
View File
@@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
"""全曲成绩(GetUserMusicApi 全量)—— 只读。
链路:
api.GetUserMusicDetailAll(userId, clientId)
-> {"userMusicList": [{musicId, level, playCount, achievement, comboStatus, syncStatus, ...}]}
已由 Astral 摊平成「一条 = 一首曲子的一个难度」。
★ 服务端响应是两层嵌套(外层按曲分组、内层按难度),Astral 的
GetUserMusicDetailAll 已经摊平了,这里直接用。
★ achievement 是 ×10000 的整数(和 GetUserRating 同口径),要 /10000。
★ 定数缺失(国服新曲常见)时标记 unrated,不参与 ra 合计,也不算错误——
可以用 data/master/ds_override.json 手工补:{"musicId": {"3": 13.8}}
"""
from __future__ import annotations
import json
import os
import time
import api
from . import accounts, b50, session
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OVERRIDE_JSON = os.path.join(APP_DIR, "data", "master", "ds_override.json")
_override_cache = None
#: 全曲成绩缓存 —— 拉一次要翻十几页,短时间内重复点按钮没必要再拉一遍
_CACHE_TTL = 300.0
_cache = {"at": 0.0, "uid": 0, "data": None}
def load_override() -> dict:
"""定数覆盖表统一由 b50 管(B50 与全曲成绩必须用同一份,否则两边分数对不上)。"""
return b50.load_override()
def reload_override() -> dict:
return b50.reload_override()
def save_override(rows: dict) -> dict:
"""**合并**写入覆盖表:{musicId: {level: ds}};ds<=0 表示删掉这一条。
合并而不是整体替换 —— 前端只提交刚填的那几条,不能把已有的覆盖冲掉。
"""
# 内层 key 统一成字符串(load_override 里是 int,直接拷贝会让 pop("3") 失效)
current = {str(k): {str(lv): v for lv, v in vals.items()}
for k, vals in load_override().items()}
for mid, levels in (rows or {}).items():
try:
key = str(int(mid))
except (TypeError, ValueError):
continue
slot = current.setdefault(key, {})
for lv, ds in (levels or {}).items():
try:
level = str(int(lv))
value = round(float(ds), 1)
except (TypeError, ValueError):
continue
if not (0 <= int(level) <= 4):
continue
if value > 0:
slot[level] = value
else:
slot.pop(level, None)
if not slot:
current.pop(key, None)
clean = {}
for key, levels in current.items():
keep = {lv: float(ds) for lv, ds in levels.items() if float(ds) > 0}
if keep:
clean[key] = keep
path = b50.OVERRIDE_JSON
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fp:
json.dump(clean, fp, ensure_ascii=False, indent=2)
os.replace(tmp, path)
reload_override()
return clean
def ds_for(music_id: int, level: int, music: dict):
"""定数:先看手工覆盖,再查 music.json。"""
manual = load_override().get(int(music_id), {}).get(int(level))
if manual:
return round(float(manual), 1)
# 交给 b50 处理(它会先查手填覆盖表,再查曲库),music 为 None 也要走
return b50.ds_of(music, level, music_id)
def _row(item: dict, music: dict) -> dict:
music_id = int(item.get("musicId") or 0)
level = int(item.get("level") or 0)
ach = int(item.get("achievement") or 0) / 10000.0
ds = ds_for(music_id, level, music)
_, rate = b50.rate_of(ach)
return {
"musicId": music_id,
"name": (music or {}).get("name") or "",
"level": level,
"levelName": b50.level_name(level),
"ds": ds,
"achievement": round(ach, 4),
"ra": b50.ra_of(ds, ach),
"rate": rate,
"combo": b50._COMBO.get(int(item.get("comboStatus") or 0), ""),
"sync": b50._SYNC.get(int(item.get("syncStatus") or 0), ""),
"playCount": int(item.get("playCount") or 0),
"manualDs": ds_for(music_id, level, None) is not None,
"unrated": ds is None,
}
def clear_cache() -> None:
_cache.update({"at": 0.0, "uid": 0, "data": None})
def _fetch_items(uid: int, client_id: str) -> list:
"""拉全量成绩并摊平成「一条 = 一个难度」,每条都带 musicId。
★ 不能直接用 api.GetUserMusicDetailAll:服务端返回的是
{"userMusicList": [{"musicId": N, "userMusicDetailList": [ {...}, ... ]}, ...]}
musicId 在**组**这一层、detail 里没有。Astral 摊平时只做
`flat.extend(group["userMusicDetailList"])`,没有把父层的 musicId 带下来 ——
直接用会拿到一堆没有曲号的成绩,定数一律查不到、整页变成「未收录」。
这里自己摊平并在每条 detail 上补 musicId(detail 自带就尊重它)。
"""
try:
from api.UserGetterCommon import get_all_pages, UserMusicRequestVO
except ImportError: # 老版本 Astral:退回官方封装,至少能出数据
raw = api.GetUserMusicDetailAll(uid, client_id=client_id)
return [dict(x) for x in ((raw or {}).get("userMusicList") or [])
if isinstance(x, dict)]
groups = get_all_pages(
"GetUserMusicApi", uid, "userMusicList",
body_factory=lambda ni: UserMusicRequestVO(
userId=uid, nextIndex=ni or 0, maxCount=50),
client_id=client_id,
)
out = []
for group in groups:
if not isinstance(group, dict):
continue
mid = int(group.get("musicId") or 0)
details = group.get("userMusicDetailList") or []
if isinstance(details, dict):
details = [details]
for detail in details:
if not isinstance(detail, dict):
continue
row = dict(detail)
if mid:
row["musicId"] = mid
else:
row.setdefault("musicId", 0)
out.append(row)
return out
def fetch(state: dict, *, use_cache: bool = True) -> dict:
"""拉全量成绩并算好 ra,返回统计 + 明细。默认吃 5 分钟缓存。"""
uid = int(state.get("user_id") or 0)
if not uid or not state.get("token"):
raise session.SessionError("还没有登录,先扫码登录")
if use_cache and _cache["uid"] == uid and _cache["data"] is not None \
and time.monotonic() - _cache["at"] < _CACHE_TTL:
return _cache["data"]
who = session.ids()
items = _fetch_items(uid, who["client_id"])
db = b50.load_music()
rows = [_row(it, db.get(int(it.get("musicId") or 0))) for it in items
if isinstance(it, dict)]
rows.sort(key=lambda r: (-(r["ra"] or 0), -r["achievement"]))
rated = [r for r in rows if not r["unrated"]]
by_level = {}
for r in rows:
slot = by_level.setdefault(r["levelName"], {"count": 0, "ra": 0,
"fc": 0, "ap": 0, "dx": 0})
slot["count"] += 1
slot["ra"] = max(slot["ra"], 0) + r["ra"]
if r["combo"] in ("fc", "fcp"):
slot["fc"] += 1
if r["combo"] in ("ap", "app"):
slot["ap"] += 1
if r["level"] >= 3:
slot["dx"] += 1
out = {
"userId": accounts.mask_user_id(uid),
"total": len(rows),
"rated": len(rated),
"unrated": len(rows) - len(rated),
"playCount": sum(r["playCount"] for r in rows),
"apCount": sum(1 for r in rows if r["combo"] in ("ap", "app")),
"fcCount": sum(1 for r in rows if r["combo"] in ("fc", "fcp", "ap", "app")),
"fdxCount": sum(1 for r in rows if r["sync"] in ("fdx", "fdxp")),
"avgAchievement": round(sum(r["achievement"] for r in rated) / len(rated), 4)
if rated else 0.0,
"bestRa": rated[0]["ra"] if rated else 0,
"byLevel": by_level,
"items": rows,
# 定数查不到的条目单独列一份:全曲成绩里它们 ra=0,排在最后,容易被截断
"unratedItems": [{
"musicId": r["musicId"], "name": r["name"], "level": r["level"],
"levelName": r["levelName"], "achievement": r["achievement"],
"rate": r["rate"],
} for r in rows if r["unrated"]],
}
_cache.update({"at": time.monotonic(), "uid": uid, "data": out})
return out
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more