// 从机台数据包(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 // // 数据布局:`/Sinmai_Data/StreamingAssets/A0xx/<表名>/<前缀>/<表名>.xml`, // 一个实体一个目录。真正的 ID 在 XML 的 `` 里,目录名只是 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; /// 要抽的标量字段(`value`)。 final List scalars; /// 要抽的 StringID 字段(``),输出成 `` 与 `Name`。 final List 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 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() .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 = >>{}; for (final (kind, spec) in _itemTables) { items['$kind'] = _mergeById(dataDirs, spec); } final writes = { '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> _mergeById( List dataDirs, _TableSpec spec, ) { final byId = >{}; for (final root in dataDirs) { final dir = Directory('$root/${spec.dirName}'); if (!dir.existsSync()) continue; for (final entity in dir .listSync(recursive: true) .whereType() .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 _extract(String xml, _TableSpec spec) { final row = {}; 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); } /// 取 `...` 平衡块内的 `` 与 ``。 _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') ?? ''); } /// 第一个 `` … `` 平衡块的内容;`` 视为空。 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 = ''; 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; } /// `value`,或 `` / `` 当作空值忽略。 String? _scalar(String xml, String tag) { final open = xml.indexOf('<$tag>'); if (open < 0) return null; final end = xml.indexOf('', 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 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;