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

+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;