feat: 从机台数据包抽出 music/item/map/gate 的 ID 映射表

风险页填的全是裸 ID,而「填了表里没有的 ID」是这类功能最常见的失败方式:
服务器照样回 returnCode: 1,客户端建池时静默跳过,看起来就是没生效。

- tools/gen_id_mapping.dart:读 Sinmai_Data/StreamingAssets/A0xx/<表>/<x>.xml,
  按目录名升序合并、同 ID 后者覆盖(与 DataManager.LoadData 语义一致),
  输出 data/id_mapping/{music,items,maps,gates,meta}.json。
  ID 取 XML 的 <name><id>,不是目录名——目录名只是 dataName。
- 零依赖:自带一个只处理 XmlSerializer 那种规整结构的小扫描器,
  不为此往依赖树里加 xml 包、也不搅动 pubspec.lock。
- 产物放 data/ 不放 assets/:目前没有运行时读取方,进 assets 会白白把
  1.2MB 打进每个安装包。

用这份表顺手证实了三件事:musicId 11820 在曲表里根本不存在(11819、11821
也不在),所以它不是万花筒锁——lockType=4 的歌全表只有 6 首;角色 1001 同样
不存在,合法 ID 从 101 起;万花筒只有 gate 1~6,客户端 ForceAddMasterKey
写死的 gateId 7 在这份数据里没有对应行。
This commit is contained in:
Cookiues committed 2026-10-02 01:08:48 +08:00
1 parent 80df51c239
commit 264b1d0aa7
7 files changed
+51326

No files matched your search

+41
View File
@@ -48,6 +48,8 @@ lib/
├── pages/ # 登录页与各功能页面 ├── pages/ # 登录页与各功能页面
├── services/ # 传输层、payload 组装、乐曲元数据、导出、RA 计算、调试日志 ├── services/ # 传输层、payload 组装、乐曲元数据、导出、RA 计算、调试日志
└── widgets/ # 可复用视觉组件(B50 海报、卡片/提示、冷却 mixin、阶段进度卡) └── widgets/ # 可复用视觉组件(B50 海报、卡片/提示、冷却 mixin、阶段进度卡)
tools/ # 离线开发工具(不参与构建产物)
data/id_mapping/ # 由 tools/gen_id_mapping.dart 从机台数据包抽出的 ID 映射表
``` ```
### 共享抽象 ### 共享抽象
@@ -159,6 +161,45 @@ lib/
`MapTreasure.xml` 里(`DataManager.LoadMaps` / `LoadMapTresures` 从本地数据目录读, `MapTreasure.xml` 里(`DataManager.LoadMaps` / `LoadMapTresures` 从本地数据目录读,
不经 title server),App 没有这份表,页面里也明写了这个限制。 不经 title server),App 没有这份表,页面里也明写了这个限制。
## ID 映射表
风险页要填的全是裸 ID,而「填了机台表里没有的 ID」是这一类功能最常见的失败方式:
服务器照样回 `returnCode: 1`,客户端却在建曲池/建角色池时静默跳过,看起来就是
「上传成功但没生效」。为此把机台数据包里的表抽成可查的映射:
```bash
dart run tools/gen_id_mapping.dart --package G:/Package
```
产物落在 `data/id_mapping/`:
| 文件 | 内容 |
| --- | --- |
| `music.json` | musicId → 曲名 / 艺术家 / 流派 / `AddVersion` / `disable` / `lockType` / `subLockType` / `eventName` |
| `items.json` | 按 `ItemKind` 分组(1 姓名框 2 称号 3 头像 9 角色 10 搭档 11 背景板 12 功能票)的 id → 名称 |
| `maps.json` | mapId → 区域名 / `IslandId` / `OpenEventId` / `ColorId` / `BonusMusicId` / `IsInfinity` / `IsCollabo` |
| `gates.json` | 万花筒三张表:`gateList`(gateId → 门名 / `gateType` / `eventName`)、`courseList`(courseId ↔ gateId ↔ keyId)、`keyList` |
要点:
- 真正的 ID 在 XML 的 `<name><id>` 里,**目录名只是 `dataName`,两者不相等**
(`icon/icon550201/Icon.xml` 的 `name.id` 实际是 1)。
- 数据包按 `Sinmai_Data/StreamingAssets/A0xx/` 分基础包与增补包,抽取时按目录名
升序合并、同 ID 后者覆盖前者,与客户端 `DataManager.LoadData` 的语义一致。
- 放在 `data/` 而不是 `assets/`:目前还没有代码在运行时读它,进 `assets/` 会被
原样打进每个安装包(约 1.2MB)。真要接进 App 时再连同 `pubspec.yaml` 一起挪。
顺手用这份表核出来的几个事实,都能解释此前的「不显示」:
- **musicId 11820 在曲表里根本不存在**(11819、11821 也不在;11814 之后直接跳到
11822)。它不是万花筒锁——`lockType = 4 (KaleidScope)` 的歌整张表只有 6 首
(11740 / 11745 / 11749 / 11753 / 11809 / 11814)。11820 属于「机台没有这首歌」,
任何 `UpsertUserAll` 都变不出来。
- **角色(旅行伙伴)1001 不存在**,合法 ID 从 101 起,整表 983 条、上界到 605905。
以前页面里「例如 1001」的提示正是这个坑。
- **万花筒只有 gate 1~6**(蓝/白/紫/黑/黄/红,course 与 key 同为 1~6 一一对应)。
客户端 `ForceAddMasterKey` 写死的 `gateId = 7`(万能钥匙门)在这份数据里没有对应行。
## 构建环境注意事项 ## 构建环境注意事项
以下问题在本仓库已确认,改动依赖或升级 Flutter 时请留意: 以下问题在本仓库已确认,改动依赖或升级 Flutter 时请留意:
+140
View File
@@ -0,0 +1,140 @@
{
"gateList": [
{
"id": 1,
"name": "蓝色之门",
"eventName": 25122432,
"eventNameName": "251224_03_2:青のカレイドスコープ解禁",
"gateType": "Normal",
"positionId": 1
},
{
"id": 2,
"name": "白色之门",
"eventName": 26021032,
"eventNameName": "260210_03_2:白のカレイドスコープ解禁",
"gateType": "Normal",
"positionId": 2
},
{
"id": 3,
"name": "紫色之门",
"eventName": 26032532,
"eventNameName": "260325_03_2:紫のカレイドスコープ解禁",
"gateType": "Normal",
"positionId": 3
},
{
"id": 4,
"name": "黑色之门",
"eventName": 26042832,
"eventNameName": "260428_03_2:黒のカレイドスコープ解禁",
"gateType": "Normal",
"positionId": 5
},
{
"id": 5,
"name": "黃色之门",
"eventName": 26061032,
"eventNameName": "260610_03_2:黄のカレイドスコープ解禁",
"gateType": "Normal",
"positionId": 6
},
{
"id": 6,
"name": "红色之门",
"eventName": 26080532,
"eventNameName": "260805_03_2:赤のカレイドスコープ解禁",
"gateType": "Normal",
"positionId": 4
}
],
"courseList": [
{
"id": 1,
"name": "青のカレイドスコープ",
"gateName": 1,
"gateNameName": "蓝色之门",
"keyName": 1,
"keyNameName": "蓝色钥匙"
},
{
"id": 2,
"name": "白のカレイドスコープ",
"gateName": 2,
"gateNameName": "白色之门",
"keyName": 2,
"keyNameName": "白色钥匙"
},
{
"id": 3,
"name": "紫のカレイドスコープ",
"gateName": 3,
"gateNameName": "紫色之门",
"keyName": 3,
"keyNameName": "紫色钥匙"
},
{
"id": 4,
"name": "黒のカレイドスコープ",
"gateName": 4,
"gateNameName": "黑色之门",
"keyName": 4,
"keyNameName": "黑色钥匙"
},
{
"id": 5,
"name": "黄のカレイドスコープ",
"gateName": 5,
"gateNameName": "黃色之门",
"keyName": 5,
"keyNameName": "黃色钥匙"
},
{
"id": 6,
"name": "赤のカレイドスコープ",
"gateName": 6,
"gateNameName": "红色之门",
"keyName": 6,
"keyNameName": "红色钥匙"
}
],
"keyList": [
{
"id": 1,
"name": "蓝色钥匙",
"gateName": 1,
"gateNameName": "蓝色之门"
},
{
"id": 2,
"name": "白色钥匙",
"gateName": 2,
"gateNameName": "白色之门"
},
{
"id": 3,
"name": "紫色钥匙",
"gateName": 3,
"gateNameName": "紫色之门"
},
{
"id": 4,
"name": "黑色钥匙",
"gateName": 4,
"gateNameName": "黑色之门"
},
{
"id": 5,
"name": "黃色钥匙",
"gateName": 5,
"gateNameName": "黃色之门"
},
{
"id": 6,
"name": "红色钥匙",
"gateName": 6,
"gateNameName": "红色之门"
}
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+23
View File
@@ -0,0 +1,23 @@
{
"generatedFrom": "G:/Package",
"dataDirs": [
"A000",
"A005",
"A011",
"A015"
],
"counts": {
"music": 1367,
"map": 168,
"gate": 6,
"course": 6,
"key": 6,
"itemKind1": 398,
"itemKind2": 3000,
"itemKind3": 1046,
"itemKind9": 983,
"itemKind10": 25,
"itemKind11": 345,
"itemKind12": 11
}
}
File diff suppressed because it is too large. Load diff
+313
View File
@@ -0,0 +1,313 @@
// 从机台数据包(G:\Package 这类整合包)里抽出 ID 映射表,生成
// data/id_mapping/*.json,供风险页做 ID 校对与名称展示。
//
// 产物放在 data/ 而不是 assets/:目前没有代码在运行时读它,放 assets/ 会被
// 原样打进每个安装包(约 1.2MB)。要接进 App 时再一起挪进 pubspec 的 assets。
//
// 用法:
// dart run tools/gen_id_mapping.dart --package G:/Package
//
// 数据布局:`<pkg>/Sinmai_Data/StreamingAssets/A0xx/<表名>/<前缀><dataName>/<表名>.xml`,
// 一个实体一个目录。真正的 ID 在 XML 的 `<name><id>` 里,目录名只是 dataName,
// 二者不相等(例如 `icon/icon550201/Icon.xml` 的 name.id 其实是 1)。
//
// 多个 A0xx 目录要按名字升序合并、后者覆盖同 ID 的行——客户端 DataManager.LoadData
// 就是这个语义(按 dirs 顺序遍历,`sortedDictionary[key] =` 直接覆写)。
// 只认形如 `A???` 的目录,其余(Table/、RomConfig.xml 等)跳过。
import 'dart:convert';
import 'dart:io';
/// 每张表的抽取规则。
class _TableSpec {
final String dirName;
final String xmlFileName;
/// 要抽的标量字段(`<tag>value</tag>`)。
final List<String> scalars;
/// 要抽的 StringID 字段(`<tag><id/><str/></tag>`),输出成 `<outKey>` 与 `<outKey>Name`。
final List<String> stringIds;
/// 输出文件名里的表名。
const _TableSpec({
required this.dirName,
required this.xmlFileName,
this.scalars = const [],
this.stringIds = const [],
});
}
/// `ItemKind`(`Net/VO/Mai2/ItemKind.cs`)到表目录的对应关系。
/// 角色(9)与万花筒钥匙(15)不走 userItemList,但 ID 照样要能查,所以一并收。
const List<(int itemKind, _TableSpec spec)> _itemTables = [
(1, _TableSpec(dirName: 'plate', xmlFileName: 'Plate.xml', stringIds: ['name'])),
(2, _TableSpec(dirName: 'title', xmlFileName: 'Title.xml', stringIds: ['name'])),
(3, _TableSpec(dirName: 'icon', xmlFileName: 'Icon.xml', stringIds: ['name'])),
(
9,
_TableSpec(dirName: 'chara', xmlFileName: 'Chara.xml', stringIds: ['name', 'genre']),
),
(10, _TableSpec(dirName: 'partner', xmlFileName: 'Partner.xml', stringIds: ['name'])),
(11, _TableSpec(dirName: 'frame', xmlFileName: 'Frame.xml', stringIds: ['name'])),
(12, _TableSpec(dirName: 'ticket', xmlFileName: 'Ticket.xml', stringIds: ['name'])),
];
const _musicSpec = _TableSpec(
dirName: 'music',
xmlFileName: 'Music.xml',
// lockType/subLockType 直接决定这首歌要不要靠 itemKind 5 解锁、
// Re:Master 要不要额外条件(MusicLockType: Unlock=0 Lock=1 Challenge=2
// Transmission=3 KaleidScope=4)。
scalars: ['disable', 'lockType', 'subLockType', 'longMusic', 'version'],
stringIds: ['name', 'artistName', 'genreName', 'AddVersion', 'eventName'],
);
const _mapSpec = _TableSpec(
dirName: 'map',
xmlFileName: 'Map.xml',
scalars: ['IsCollabo', 'IsInfinity'],
stringIds: ['name', 'IslandId', 'OpenEventId', 'ColorId', 'BonusMusicId'],
);
const _gateSpec = _TableSpec(
dirName: 'kaleidxScopeGate',
xmlFileName: 'KaleidxScopeGate.xml',
scalars: ['gateType', 'positionId'],
stringIds: ['name', 'eventName'],
);
/// 课题表是 keyId <-> gateId 的唯一桥(`GetKaleidxScopeCourseByKeyId` 扫的就是它)。
const _courseSpec = _TableSpec(
dirName: 'kaleidxScopeCourse',
xmlFileName: 'KaleidxScopeCourse.xml',
stringIds: ['name', 'gateName', 'keyName'],
);
const _keySpec = _TableSpec(
dirName: 'kaleidxScopeKey',
xmlFileName: 'KaleidxScopeKey.xml',
stringIds: ['name', 'gateName'],
);
void main(List<String> args) {
final packageRoot = _argValue(args, '--package') ?? 'G:/Package';
final outDir = Directory(
_argValue(args, '--out') ?? 'data/id_mapping',
);
final assetsRoot = Directory(
'$packageRoot/Sinmai_Data/StreamingAssets',
);
if (!assetsRoot.existsSync()) {
stderr.writeln('找不到 $assetsRoot');
exitCode = 1;
return;
}
// 升序,让高编号的增补包覆盖基础包,和客户端一致。
final dataDirs =
assetsRoot
.listSync()
.whereType<Directory>()
.where((d) => RegExp(r'^A\d{3}$').hasMatch(_baseName(d.path)))
.map((d) => d.path)
.toList()
..sort();
if (dataDirs.isEmpty) {
stderr.writeln('$assetsRoot 下没有 A??? 数据目录');
exitCode = 1;
return;
}
outDir.createSync(recursive: true);
final music = _mergeById(dataDirs, _musicSpec);
final maps = _mergeById(dataDirs, _mapSpec);
final gates = _mergeById(dataDirs, _gateSpec);
final courses = _mergeById(dataDirs, _courseSpec);
final keys = _mergeById(dataDirs, _keySpec);
final items = <String, List<Map<String, dynamic>>>{};
for (final (kind, spec) in _itemTables) {
items['$kind'] = _mergeById(dataDirs, spec);
}
final writes = <String, Object>{
'music.json': music,
'maps.json': maps,
'gates.json': {
'gateList': gates,
'courseList': courses,
'keyList': keys,
},
'items.json': items,
'meta.json': {
'generatedFrom': packageRoot,
'dataDirs': dataDirs.map(_baseName).toList(),
'counts': {
'music': music.length,
'map': maps.length,
'gate': gates.length,
'course': courses.length,
'key': keys.length,
for (final (kind, _) in _itemTables)
'itemKind$kind': items['$kind']!.length,
},
},
};
for (final entry in writes.entries) {
final file = File('${outDir.path}/${entry.key}');
file.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(entry.value),
);
stdout.writeln('wrote ${file.path}');
}
}
/// 扫描所有数据目录里该表的 XML,按 name.id 合并;同一 ID 后面出现的覆盖前面的。
List<Map<String, dynamic>> _mergeById(
List<String> dataDirs,
_TableSpec spec,
) {
final byId = <int, Map<String, dynamic>>{};
for (final root in dataDirs) {
final dir = Directory('$root/${spec.dirName}');
if (!dir.existsSync()) continue;
for (final entity in dir
.listSync(recursive: true)
.whereType<File>()
.where((f) => _baseName(f.path) == spec.xmlFileName)) {
final row = _extract(entity.readAsStringSync(), spec);
final id = row['id'];
if (id is int) byId[id] = row;
}
}
final ids = byId.keys.toList()..sort();
return [for (final id in ids) byId[id]!];
}
Map<String, dynamic> _extract(String xml, _TableSpec spec) {
final row = <String, dynamic>{};
final primary = _stringId(xml, 'name');
if (primary == null) return row;
row['id'] = primary.id;
row['name'] = primary.str;
for (final tag in spec.stringIds) {
if (tag == 'name') continue;
final v = _stringId(xml, tag);
if (v == null) continue;
// ID 用来做关联与校验,str 只是给人看的,分开两个 key 存。
row[tag] = v.id;
row['${tag}Name'] = v.str;
}
for (final tag in spec.scalars) {
final v = _scalar(xml, tag);
if (v == null) continue;
row[tag] = _asTyped(v);
}
return row;
}
class _StringId {
final int id;
final String str;
const _StringId(this.id, this.str);
}
/// 取 `<tag>...</tag>` 平衡块内的 `<id>` 与 `<str>`。
_StringId? _stringId(String xml, String tag) {
final block = _balancedBlock(xml, tag);
if (block == null) return null;
final idText = _scalar(block, 'id');
if (idText == null) return null;
final id = int.tryParse(idText);
if (id == null) return null;
return _StringId(id, _scalar(block, 'str') ?? '');
}
/// 第一个 `<tag>` … `</tag>` 平衡块的内容;`<tag/>` 视为空。
String? _balancedBlock(String xml, String tag) {
final open = xml.indexOf('<$tag>');
final selfClosed = xml.indexOf('<$tag ');
final bareSelfClosed = xml.indexOf('<$tag/>');
int start;
if (open >= 0 && (selfClosed < 0 || open < selfClosed)) {
start = open;
} else if (bareSelfClosed >= 0) {
return '';
} else if (selfClosed >= 0) {
start = selfClosed;
} else {
return null;
}
final closeTag = '</$tag>';
var depth = 0;
var i = start;
while (i < xml.length) {
final nextOpen = xml.indexOf('<$tag', i);
final nextClose = xml.indexOf(closeTag, i);
if (nextClose < 0) return null;
if (nextOpen >= 0 && nextOpen < nextClose) {
// 只有确实是开标签才算嵌套(后面跟 > / 空格 / /)。
final after = xml[nextOpen + tag.length + 1];
if (after == '>' || after == ' ' || after == '/') depth++;
i = nextOpen + tag.length + 1;
continue;
}
if (depth == 1 || depth == 0) {
final contentStart = xml.indexOf('>', start) + 1;
return xml.substring(contentStart.clamp(0, xml.length), nextClose);
}
depth--;
i = nextClose + closeTag.length;
}
return null;
}
/// `<tag>value</tag>`,或 `<tag />` / `<tag/>` 当作空值忽略。
String? _scalar(String xml, String tag) {
final open = xml.indexOf('<$tag>');
if (open < 0) return null;
final end = xml.indexOf('</$tag>', open);
if (end < 0) return null;
final raw = xml.substring(open + tag.length + 2, end).trim();
if (raw.isEmpty) return null;
return _decodeEntities(raw);
}
String _decodeEntities(String v) => v
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&amp;', '&');
/// `false`/`true` 转 bool,纯数字转 int,其余保留字符串。
Object _asTyped(String v) {
if (v == 'true') return true;
if (v == 'false') return false;
final n = int.tryParse(v);
return n ?? v;
}
String? _argValue(List<String> args, String name) {
final i = args.indexOf(name);
if (i < 0 || i + 1 >= args.length) return null;
return args[i + 1];
}
String _baseName(String path) => path.replaceAll(r'\', '/').split('/').last;