From ba1b876cb5c0cb84be56ca30231943953554f473 Mon Sep 17 00:00:00 2001 From: Menci Date: Tue, 18 Nov 2025 20:35:29 +0800 Subject: [PATCH] feat: chart note stats (credits to @Dale2003) --- src/interfaces/music.ts | 15 ++++--- src/metadata/processors/music.ts | 75 +++++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/src/interfaces/music.ts b/src/interfaces/music.ts index 4171eb6..e52d2d5 100644 --- a/src/interfaces/music.ts +++ b/src/interfaces/music.ts @@ -1,12 +1,17 @@ import type { MaimaiMajorVersionId, MaimaiRegion } from './base'; +export type MaimaiChartNoteStats = { + tap: number; + hold: number; + slide: number; + touch: number; + break: number; +}; + export type MaimaiChartMetadata = { // `level` is not included here and should be inferred from the change log. designer: string; - // TODO: how many of TAP/HOLD/SLIDE/BREAK notes? -}; -export type MaimaiChartMetadataIntermediate = MaimaiChartMetadata & { - level: number; + stats?: MaimaiChartNoteStats; }; export type MaimaiMusicMetadataBase = { @@ -18,7 +23,7 @@ export type MaimaiMusicMetadataBase = { }; export type MaimaiMusicMetadataIntermediate = MaimaiMusicMetadataBase & { - chartsWithLevel: MaimaiChartMetadataIntermediate[]; + chartLevel: number[]; versionId: MaimaiMajorVersionId; deletedInPatch: boolean; netOpenDate: string | null; diff --git a/src/metadata/processors/music.ts b/src/metadata/processors/music.ts index a9be616..ba8e845 100644 --- a/src/metadata/processors/music.ts +++ b/src/metadata/processors/music.ts @@ -1,4 +1,9 @@ -import type { MaimaiChartMetadataIntermediate, MaimaiMusicMetadata, MaimaiMusicMetadataIntermediate } from '../../interfaces'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { glob } from 'glob'; + +import type { MaimaiChartMetadata, MaimaiChartNoteStats, MaimaiMusicMetadata, MaimaiMusicMetadataIntermediate } from '../../interfaces'; import { MaimaiRegion, MaimaiMajorVersionId, maimaiMajorVersionIds, MaimaiMusicAddDeleteLogEntry } from '../../interfaces'; import { createLogger } from '../../logger'; import { forEachParallel, objectEntries, objectKeys } from '../../utils/base'; @@ -13,9 +18,45 @@ const logger = createLogger('Music'); type IntermediateData = Record; +const parseChartContent = (chartContent: string): MaimaiChartNoteStats => { + const result: MaimaiChartNoteStats = { + tap: 0, + hold: 0, + slide: 0, + touch: 0, + break: 0, + }; + for (const line of chartContent.split('\n').map(line => line.trim())) { + if (line.startsWith('T_NUM_TAP')) { + result.tap = parseInt(line.split('\t')[1]?.trim() ?? '0'); + } else if (line.startsWith('T_NUM_HLD')) { + result.hold = parseInt(line.split('\t')[1]?.trim() ?? '0'); + } else if (line.startsWith('T_NUM_SLD')) { + result.slide = parseInt(line.split('\t')[1]?.trim() ?? '0'); + } else if (line.startsWith('T_REC_TTP')) { + result.touch = parseInt(line.split('\t')[1]?.trim() ?? '0'); + } else if (line.startsWith('T_NUM_BRK')) { + result.break = parseInt(line.split('\t')[1]?.trim() ?? '0'); + } + } + if (!Object.values(result).every(Number.isSafeInteger)) throw new Error(`Chart parsed to invalid note stats: ${JSON.stringify(result)}`); + result.tap = result.tap - result.touch; + return result; +}; + export const processMusic: WorkerProcessor = async ctx => { const musics: Record = {}; await ctx.forEachAxxxDirOrdered(async axxxDir => await forEachParallel(parseXmls(globFiles(axxxDir, 'music', 'music', 'Music.xml')), async ({ fileName, xml: { MusicData } }) => { + const musicDir = path.dirname(fileName); + const chartPaths = await glob(path.join(musicDir, '*.ma2')); + const chartStats = new Map(await Promise.all(chartPaths.map(async chartPath => { + const chartContent = await fs.promises.readFile(chartPath, 'utf-8'); + const difficulty = path.basename(chartPath).split('_').pop()?.split('.')[0]; + const numericDifficulty = difficulty === 'L' ? 0 : difficulty === 'R' ? 1 : Number(difficulty); + if (!Number.isSafeInteger(numericDifficulty)) throw new Error(`Failed to extract difficulty from chart path: ${chartPath}`); + return [numericDifficulty, parseChartContent(chartContent)] as const; + }))); + const id = zCoerceNumber(MusicData.name.id); const name = zCoerceString(MusicData.name.str); const artist = zCoerceString(MusicData.artistName.str); @@ -25,17 +66,19 @@ export const processMusic: WorkerProcessor = async ctx => { const netOpenDate = parseNetOpenDate(MusicData.netOpenName.str); const subEventDate = parseEventIdAsNetOpenDate(MusicData.subEventName.id); - const charts = (MusicData.notesData.Notes as any[]).map(note => + const buildChartMetadata = (note: any, stats?: MaimaiChartNoteStats) => ({ + level: zCoerceNumber(note.level) + zCoerceNumber(note.levelDecimal) / 10, + designer: zCoerceString(note.notesDesigner.str), + stats, + } satisfies MaimaiChartMetadata & { level: number }); + const charts = (MusicData.notesData.Notes as any[]).map((note, difficulty) => // DX and DX+ version didn't set the `isEnable` flag. ( ctx.version === MaimaiMajorVersionId.DX || ctx.version === MaimaiMajorVersionId.DX_PLUS ? !!note.level : note.isEnable ) - ? { - level: zCoerceNumber(note.level) + zCoerceNumber(note.levelDecimal) / 10, - designer: zCoerceString(note.notesDesigner.str), - } satisfies MaimaiChartMetadataIntermediate + ? buildChartMetadata(note, chartStats.get(difficulty)) : undefined); // Re:MASTER is removed (bugfix?). @@ -47,6 +90,11 @@ export const processMusic: WorkerProcessor = async ctx => { // Remove charts of nonexistent difficulties. while (charts.length > 0 && charts[charts.length - 1] === undefined) charts.pop(); + // Utage charts may have 2 chart files but only one notesData (_L and _R). + if (id >= 100000 && charts.length === 1 && chartStats.size === 2) { + charts.push(buildChartMetadata(MusicData.notesData.Notes[0], chartStats.get(/* _R */ 1))); + } + // Normally musics are deleted only on major version updates. // However, sometimes they delete musics in patches due to political or copyright reasons. const isDeleted = MusicData.eventName.id === 0; @@ -64,8 +112,8 @@ export const processMusic: WorkerProcessor = async ctx => { genre, bpm, versionId, - charts: charts.map(c => ({ designer: c!.designer })), - chartsWithLevel: charts.map(c => c!), + charts: charts.map(c => ({ designer: c!.designer, stats: c!.stats })), + chartLevel: charts.map(c => c!.level), netOpenDate, subEventDate, deletedInPatch: false, @@ -121,9 +169,9 @@ export const mergeMusic: MetadataMerger