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:
1 parent
80df51c239
commit
264b1d0aa7
7 files changed
+51326
No files matched your search
@@ -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('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('&', '&');
|
||||
|
||||
/// `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;
|
||||
Reference in new issue
Block a user