feat: initial commit

This commit is contained in:
Menci
2025-11-10 03:05:19 +08:00
commit 267f6829c6
25 changed files with 4090 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
/config.yaml
+1
View File
@@ -0,0 +1 @@
node-linker=hoisted
+2
View File
@@ -0,0 +1,2 @@
inputs: {}
outputDir: output
+3
View File
@@ -0,0 +1,3 @@
inputs:
JPN:
22: D:\maimai\BUDDiES_PLUS
+153
View File
@@ -0,0 +1,153 @@
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import importPlugin from 'eslint-plugin-import';
import stylisticPlugin from '@stylistic/eslint-plugin';
import type { Linter } from 'eslint';
const commonConfig: Linter.Config = {
plugins: {
import: importPlugin,
'@typescript-eslint': tsPlugin as any,
stylistic: stylisticPlugin,
},
rules: {
'import/order': [
'error',
{
groups: ['builtin', 'external', ['internal', 'parent', 'sibling', 'index']],
pathGroups: [
{
pattern: '@proj-marina/**',
group: 'internal',
position: 'before',
},
{
pattern: '@/**',
group: 'internal',
position: 'before',
},
],
'newlines-between': 'always',
distinctGroup: false,
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'import/no-duplicates': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'prefer-const': 'error',
'no-var': 'error',
'no-debugger': 'error',
'object-shorthand': 'error',
'prefer-template': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'@typescript-eslint/prefer-optional-chain': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/return-await': ['error', 'always'],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': ['error'],
'@typescript-eslint/prefer-as-const': 'error',
'@typescript-eslint/prefer-for-of': 'error',
'@typescript-eslint/prefer-includes': 'error',
'@typescript-eslint/prefer-string-starts-ends-with': 'error',
'@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }],
'stylistic/indent': ['error', 2, {
'offsetTernaryExpressions': true
}],
'stylistic/linebreak-style': ['error', 'unix'],
'stylistic/semi': ['error', 'always'],
'stylistic/quotes': ['error', 'single', {
'avoidEscape': true,
'allowTemplateLiterals': 'avoidEscape',
}],
'stylistic/comma-dangle': ['error', 'always-multiline'],
'stylistic/arrow-parens': ['error', 'as-needed'],
'stylistic/object-curly-spacing': ['error', 'always'],
'stylistic/array-bracket-spacing': ['error', 'never'],
'stylistic/space-before-function-paren': ['error', {
'anonymous': 'always',
'named': 'never',
'asyncArrow': 'always',
}],
'stylistic/space-in-parens': ['error', 'never'],
'stylistic/comma-spacing': ['error', { 'before': false, 'after': true }],
'stylistic/key-spacing': ['error', { 'beforeColon': false, 'afterColon': true }],
'stylistic/keyword-spacing': ['error'],
'stylistic/space-before-blocks': ['error', 'always'],
'stylistic/space-infix-ops': ['error'],
'stylistic/no-trailing-spaces': ['error'],
'stylistic/eol-last': ['error', 'always'],
'stylistic/no-multiple-empty-lines': ['error', { 'max': 1, 'maxEOF': 0 }],
'stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'stylistic/object-curly-newline': ['error', {
'ObjectExpression': { 'multiline': true, 'consistent': true },
'ObjectPattern': { 'multiline': true, 'consistent': true },
'ImportDeclaration': { 'multiline': true, 'consistent': true },
'ExportDeclaration': { 'multiline': true, 'consistent': true }
}],
'stylistic/array-bracket-newline': ['error', 'consistent'],
'stylistic/function-paren-newline': ['error', 'consistent'],
'stylistic/member-delimiter-style': ['error', {
'multiline': {
'delimiter': 'semi',
'requireLast': true
},
'singleline': {
'delimiter': 'semi',
'requireLast': false
}
}],
'stylistic/type-annotation-spacing': ['error'],
'stylistic/jsx-quotes': ['error', 'prefer-double'],
},
settings: {
'import/internal-regex': '^@proj-marina/',
'import/resolver': {
typescript: {
project: ['./tsconfig.json'],
noWarnOnMultipleProjects: true,
},
},
},
};
const parserOptions: Linter.ParserOptions = {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json'],
noWarnOnMultipleProjects: true,
};
const config: Linter.Config[] = [
{
...commonConfig,
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions,
},
},
{
ignores: [
'**/node_modules/**',
// Build output
'**/dist/**',
'**/build/**',
'**/coverage/**',
'eslint.config.ts',
],
},
];
export default config;
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@maigolabs/kairos",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "bun src/index.ts",
"typecheck": "tsc",
"lint": "eslint --cache --ext .",
"lint:fix": "eslint --cache --ext . --fix"
},
"license": "UNLICENSED",
"packageManager": "pnpm@10.20.0",
"private": true,
"devDependencies": {
"@eslint/js": "^9.39.1",
"@stylistic/eslint-plugin": "^5.5.0",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"cross-env": "^10.1.0",
"eslint": "^9.39.1",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"jiti": "^2.6.1",
"typescript": "^5.9.3"
},
"dependencies": {
"@guiiai/logg": "^1.2.5",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.0",
"bun": "^1.3.2",
"es-toolkit": "^1.41.0",
"fast-xml-parser": "^5.3.1",
"glob": "^11.0.3",
"tinypool": "^2.0.0",
"zod": "^4.1.12"
}
}
+2960
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- bun
- unrs-resolver
+26
View File
@@ -0,0 +1,26 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { merge } from 'es-toolkit';
import yaml from 'js-yaml';
import { z } from 'zod';
import { MaimaiRegion, MaimaiMajorVersionId } from './interfaces/index';
import { run } from './master';
const dirname = path.dirname(fileURLToPath(import.meta.url));
export const Config = z.object({
inputs: z.record(
z.enum(MaimaiRegion),
z.record(z.preprocess(Number, z.enum(MaimaiMajorVersionId)), z.string()),
),
outputDir: z.string(),
});
export type Config = z.infer<typeof Config>;
const loadConfigFile = (filename: string) => yaml.load(fs.readFileSync(path.resolve(dirname, '..', filename), 'utf-8')) as Record<string, unknown>;
const config = Config.parse(merge(loadConfigFile('config.base.yaml'), loadConfigFile('config.yaml')));
await run(config);
+71
View File
@@ -0,0 +1,71 @@
export enum MaimaiRegion {
JPN = 'JPN',
EXP = 'EXP',
CHN = 'CHN',
}
export function maimaiRegionFromGameId(gameId: string) {
switch (gameId) {
case '\x53\x44\x45\x5a':
return MaimaiRegion.JPN;
case '\x53\x44\x47\x41':
return MaimaiRegion.EXP;
case '\x53\x44\x47\x42':
return MaimaiRegion.CHN;
default: // Fallback to JPN
return MaimaiRegion.JPN;
}
}
export enum MaimaiMajorVersionId {
maimai = 0,
maimai_PLUS = 1,
GreeN = 2,
GreeN_PLUS = 3,
ORANGE = 4,
ORANGE_PLUS = 5,
PiNK = 6,
PiNK_PLUS = 7,
MURASAKi = 8,
MURASAKi_PLUS = 9,
MiLK = 10,
MiLK_PLUS = 11,
FiNALE = 12,
DX = 13,
DX_PLUS = 14,
Splash = 15,
Splash_PLUS = 16,
UNiVERSE = 17,
UNiVERSE_PLUS = 18,
FESTiVAL = 19,
FESTiVAL_PLUS = 20,
BUDDiES = 21,
BUDDiES_PLUS = 22,
PRiSM = 23,
PRiSM_PLUS = 24,
CiRCLE = 25,
}
export const maimaiMajorVersionIds = Object.values(MaimaiMajorVersionId).filter(
v => typeof v === 'number',
) as MaimaiMajorVersionId[];
export function maimaiVersionIdFromVersionString(gameVersion: string) {
if (!gameVersion) {
return maimaiMajorVersionIds[maimaiMajorVersionIds.length - 1];
}
const v = Number(gameVersion.split('.')[1]);
const i = Math.floor(v / 5);
const result = MaimaiMajorVersionId.DX + i;
return maimaiMajorVersionIds.includes(result)
? result
: /* Fallback to the latest version */ maimaiMajorVersionIds[maimaiMajorVersionIds.length - 1];
}
export type MetadataUnversioned<T> = { unversioned: T };
export type MetadataVersioned<T> = { versioned: Partial<Record<MaimaiMajorVersionId, T>> };
export type MetadataMaybeVersioned<T> = MetadataUnversioned<T> | MetadataVersioned<T>;
export type MetadataUnregionalized<T> = { unregionalized: MetadataMaybeVersioned<T> };
export type MetadataRegionalized<T> = { regionalized: Partial<Record<MaimaiRegion, MetadataMaybeVersioned<T>>> };
export type MetadataMaybeRegionalized<T> = MetadataUnregionalized<T> | MetadataRegionalized<T>;
+6
View File
@@ -0,0 +1,6 @@
export * from './base';
export * from './music';
export * from './title';
export const maimaiMetadataKinds = ['music', 'title', 'frame', 'icon', 'partner', 'plate', 'chara', 'card', 'loginBonus'] as const;
export type MaimaiMetadataKind = (typeof maimaiMetadataKinds)[number];
+50
View File
@@ -0,0 +1,50 @@
import type { MaimaiMajorVersionId, MaimaiRegion } from './base';
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;
};
export type MaimaiMusicMetadataBase = {
name: string;
artist: string;
genre: string;
bpm: number;
charts: MaimaiChartMetadata[];
};
export type MaimaiMusicMetadataIntermediate = MaimaiMusicMetadataBase & {
chartsWithLevel: MaimaiChartMetadataIntermediate[];
versionId: MaimaiMajorVersionId;
deletedInPatch: boolean;
netOpenDate: string | null;
};
export enum MaimaiMusicAddDeleteLogEntry {
Added = 1,
AddedReMaster = 2,
DeletedFromPackage = 3,
DeletedInPatch = 4,
}
export type MaimaiMusicAddDeleteLog = Partial<Record<MaimaiMajorVersionId, MaimaiMusicAddDeleteLogEntry>>;
export interface MaimaiMusicMetadataRegionalInfo {
/**
* For a music deleted from package, it don't have the level value in that version.
* For a music deleted in patch, it has the level value in that version.
*/
addDeleteLog: MaimaiMusicAddDeleteLog;
netOpenDate: string | null;
}
export type MaimaiMusicLevelChangeLog = Partial<Record<MaimaiMajorVersionId, number>>[];
export interface MaimaiMusicMetadata extends MaimaiMusicMetadataBase {
/**
* The level change log per chart (BASIC, ADVANCED, EXPERT, MASTER, Re:MASTER).
*/
levelChangeLog: MaimaiMusicLevelChangeLog; // This is observed to be identical for all regions.
regionalInfo: Partial<Record<MaimaiRegion, MaimaiMusicMetadataRegionalInfo>>;
}
+17
View File
@@ -0,0 +1,17 @@
import type { MaimaiRegion, MetadataMaybeRegionalized } from './base';
export enum MaimaiTitleRareType {
Normal = 'Normal',
Bronze = 'Bronze',
Silver = 'Silver',
Gold = 'Gold',
Rainbow = 'Rainbow',
}
export type BasicMetadataBase<TExtra> = TExtra & { name: string };
export type BasicMetadataIntermediate<TExtra> = BasicMetadataBase<TExtra> & { netOpenDate: string | null };
export type BasicMetadata<TExtra = {}> = MetadataMaybeRegionalized<BasicMetadataBase<TExtra>> & {
regionalNetOpenDate: Partial<Record<MaimaiRegion, string | null>>;
};
export type MaimaiTitleMetadataExtra = { rareType: MaimaiTitleRareType };
+5
View File
@@ -0,0 +1,5 @@
import { initLogger, useGlobalLogger } from '@guiiai/logg';
initLogger();
export const createLogger = (name: string) => useGlobalLogger(name);
+89
View File
@@ -0,0 +1,89 @@
import fs from 'node:fs';
import path from 'node:path';
import Tinypool from 'tinypool';
import type { Config } from './index';
import type { MaimaiMajorVersionId, MaimaiMetadataKind, MaimaiRegion } from './interfaces';
import { maimaiMetadataKinds } from './interfaces';
import { createLogger } from './logger';
import { basicDataTypes } from './processors/basic';
import { mergeMusic } from './processors/music';
import { arrayToObject, getOrSet, objectEntries, objectMap } from './utils/base';
import type { WorkerArguments } from './worker';
const logger = createLogger('Master');
const pool = new Tinypool({
filename: new URL('./worker.ts', import.meta.url).href,
minThreads: 8,
maxThreads: 32,
});
export type IntermediateDataMap<TIntermediateData> = Map<MaimaiRegion, Map<MaimaiMajorVersionId, TIntermediateData>>;
export type MetadataMerger<TIntermediateData, TResult> = (intermediateDataMap: IntermediateDataMap<TIntermediateData>) => TResult;
export const run = async (config: Config) => {
const outputDir = path.resolve(config.outputDir);
await fs.promises.mkdir(outputDir, { recursive: true });
const index: Map<MaimaiMetadataKind, Map<MaimaiRegion, Map<MaimaiMajorVersionId, string>>> = new Map();
const tasks: Promise<void>[] = [];
for (const [regionName, versionPathMap] of objectEntries(config.inputs)) {
const region = regionName as MaimaiRegion;
for (const [versionName, streamingAssetsPath] of objectEntries(versionPathMap)) {
const version = Number(versionName) as MaimaiMajorVersionId;
const versionOutputDir = path.resolve(outputDir, 'intermediate', `${region}-${version}`);
await fs.promises.mkdir(versionOutputDir, { recursive: true });
const filePaths = arrayToObject(maimaiMetadataKinds, kind => path.resolve(versionOutputDir, `${kind}.json`));
for (const [metadataKind, outputFilePath] of objectEntries(filePaths)) {
const regionIndex = getOrSet(index, metadataKind as MaimaiMetadataKind, new Map());
const versionIndex = getOrSet(regionIndex, region, new Map());
versionIndex.set(version, outputFilePath);
if (fs.existsSync(outputFilePath)) continue;
tasks.push(pool.run({
region,
version,
streamingAssetsPath,
metadataKind,
outputFilePath,
} satisfies WorkerArguments));
}
}
}
await Promise.all(tasks);
const dataMap: Map<MaimaiMetadataKind, IntermediateDataMap<unknown>> = new Map();
for (const [kind, regionMergeIndex] of index.entries()) {
const regionMap = getOrSet(dataMap, kind, new Map());
for (const [region, versionMergeIndex] of regionMergeIndex.entries()) {
const versionMap = getOrSet(regionMap, region, new Map());
for (const [version, outputFilePath] of versionMergeIndex.entries()) {
const data = JSON.parse(await fs.promises.readFile(outputFilePath, 'utf-8'));
versionMap.set(version, data);
}
}
}
const mergers: Record<MaimaiMetadataKind, MetadataMerger<any, any>> = {
...objectMap(basicDataTypes, dataType => dataType.merge),
music: mergeMusic,
};
const result = objectEntries(mergers).reduce((result, [kind, merger]) => {
try {
result[kind] = merger(dataMap.get(kind)!);
return result;
} catch (error) {
logger.error(`Failed to merge ${kind} metadata`);
throw error;
}
}, {} as Record<MaimaiMetadataKind, unknown>);
const mergedFilePath = path.resolve(outputDir, 'merged.json');
await fs.promises.writeFile(mergedFilePath, JSON.stringify(result, null, 2));
logger.log(`Merged metadata to ${mergedFilePath}`);
};
+68
View File
@@ -0,0 +1,68 @@
import { omit } from 'es-toolkit';
import type { BasicMetadata, BasicMetadataBase, BasicMetadataIntermediate, MaimaiTitleMetadataExtra } from '../interfaces';
import { MaimaiTitleRareType } from '../interfaces';
import type { MetadataMerger } from '../master';
import { forEachParallel, objectMap } from '../utils/base';
import type { RegionalizedMap, RegionalizedNetOpenDate } from '../utils/data';
import { maybeCompactRegionalizedMap, parseNetOpenDate } from '../utils/data';
import { forEachRegionAndVersion } from '../utils/each';
import { globFiles, parseXmls } from '../utils/fs';
import { zCoerceNumber, zCoerceString, zParseEnum } from '../utils/zod';
import type { WorkerProcessor } from '../worker';
type IntermediateData<TExtra> = Record<number, BasicMetadataIntermediate<TExtra>>;
const defineDataType = <TExtra = {}>(
globXmls: (axxxDir: string) => ReturnType<typeof globFiles>,
xmlRootElementName: string,
parseExtraFields: (xmlData: any) => TExtra = () => ({}) as any,
): {
process: WorkerProcessor<IntermediateData<TExtra>>;
merge: MetadataMerger<IntermediateData<TExtra>, Record<number, BasicMetadata<TExtra>>>;
} => ({
process: async ctx => {
const result: Record<number, BasicMetadataIntermediate<TExtra>> = {};
await ctx.forEachAxxxDirOrdered(async axxxDir => await forEachParallel(parseXmls(globXmls(axxxDir)), async ({ xml }) => {
const xmlData = xml[xmlRootElementName];
const id = zCoerceNumber(xmlData.name.id);
const name = zCoerceString(xmlData.name.str);
const netOpenDate = parseNetOpenDate(xmlData.netOpenName.str);
result[id] = { name, netOpenDate, ...parseExtraFields!(xmlData) };
}));
return result;
},
merge: dataMap => {
// Merge
const regionalNetOpenDate: Record<number, RegionalizedNetOpenDate> = {};
const mergedMap: Record<number, RegionalizedMap<BasicMetadataBase<TExtra>>> = {};
forEachRegionAndVersion(dataMap, 'jpnFirst', 'oldFirst', (region, version, entries) => Object.entries(entries).forEach(([idStr, entry]) => {
const id = Number(idStr);
const regionalizedMap = mergedMap[id] ??= {};
const versionedMap = regionalizedMap[region] ??= {};
versionedMap[version] = omit(entry, ['netOpenDate']) as BasicMetadataBase<TExtra>;
(regionalNetOpenDate[id] ??= {})[region] ??= null;
if (entry.netOpenDate) (regionalNetOpenDate[id] ??= {})[region] = entry.netOpenDate; // Newer version overrides the older one.
}));
// Compact
return objectMap(mergedMap, (regionalizedMap, idStr) => ({
...maybeCompactRegionalizedMap(regionalizedMap!),
regionalNetOpenDate: regionalNetOpenDate[Number(idStr)]!,
}));
},
});
export const basicDataTypes = {
title: defineDataType<MaimaiTitleMetadataExtra>(axxxDir => globFiles(axxxDir, 'title', 'title', 'Title.xml'), 'TitleData', TitleData => ({
rareType: zParseEnum(MaimaiTitleRareType, TitleData.rareType),
})),
frame: defineDataType(axxxDir => globFiles(axxxDir, 'frame', 'frame', 'Frame.xml'), 'FrameData'),
icon: defineDataType(axxxDir => globFiles(axxxDir, 'icon', 'icon', 'Icon.xml'), 'IconData'),
partner: defineDataType(axxxDir => globFiles(axxxDir, 'partner', 'partner', 'Partner.xml'), 'PartnerData'),
plate: defineDataType(axxxDir => globFiles(axxxDir, 'plate', 'plate', 'Plate.xml'), 'PlateData'),
chara: defineDataType(axxxDir => globFiles(axxxDir, 'chara', 'chara', 'Chara.xml'), 'CharaData'),
card: defineDataType(axxxDir => globFiles(axxxDir, 'card', 'card', 'Card.xml'), 'CardData'),
loginBonus: defineDataType(axxxDir => globFiles(axxxDir, 'loginBonus', 'LoginBonus', 'LoginBonus.xml'), 'LoginBonusData'),
};
+241
View File
@@ -0,0 +1,241 @@
import type { MaimaiChartMetadataIntermediate, MaimaiMusicMetadata, MaimaiMusicMetadataIntermediate } from '../interfaces';
import { MaimaiRegion, MaimaiMajorVersionId, maimaiMajorVersionIds, MaimaiMusicAddDeleteLogEntry } from '../interfaces';
import { createLogger } from '../logger';
import type { MetadataMerger } from '../master';
import { forEachParallel, objectEntries, objectKeys } from '../utils/base';
import { parseNetOpenDate } from '../utils/data';
import { forEachRegionAndVersion } from '../utils/each';
import { globFiles, parseXmls } from '../utils/fs';
import { zCoerceNumber, zCoerceString, zParseEnum } from '../utils/zod';
import type { WorkerProcessor } from '../worker';
const logger = createLogger('Music');
type IntermediateData = Record<number, MaimaiMusicMetadataIntermediate>;
export const processMusic: WorkerProcessor<IntermediateData> = async ctx => {
const musics: Record<number, MaimaiMusicMetadataIntermediate> = {};
await ctx.forEachAxxxDirOrdered(async axxxDir => await forEachParallel(parseXmls(globFiles(axxxDir, 'music', 'music', 'Music.xml')), async ({ fileName, xml: { MusicData } }) => {
const id = zCoerceNumber(MusicData.name.id);
const name = zCoerceString(MusicData.name.str);
const artist = zCoerceString(MusicData.artistName.str);
const genre = zCoerceString(MusicData.genreName.str);
const bpm = zCoerceNumber(MusicData.bpm);
const versionId = zParseEnum(MaimaiMajorVersionId, MusicData.AddVersion.id);
const netOpenDate = parseNetOpenDate(MusicData.netOpenName.str);
const charts = (MusicData.notesData.Notes as any[]).map(note =>
// 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
: undefined);
// Remove charts of nonexistent difficulties.
while (charts.length > 0 && charts[charts.length - 1] === undefined) charts.pop();
// 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;
if (isDeleted) {
logger.warn(`Music ${fileName} (${name}) deleted by patch!`);
const existingMusic = musics[id];
if (!existingMusic) logger.error(`Music ${fileName} (${name}) is detected to be deleted by patch but not found!`);
else existingMusic.deletedInPatch = true;
return;
}
musics[id] = {
name,
artist,
genre,
bpm,
versionId,
charts: charts.map(c => ({ designer: c!.designer })),
chartsWithLevel: charts.map(c => c!),
netOpenDate,
deletedInPatch: false,
} satisfies MaimaiMusicMetadataIntermediate;
}));
return musics;
};
export const mergeMusic: MetadataMerger<IntermediateData, Record<number, MaimaiMusicMetadata>> = dataMap => {
const result: Record<number, MaimaiMusicMetadata> = {};
// Merge all musics. JPN first. Nerwer version first.
const lowsetSeenVersionId: Record<number, MaimaiMajorVersionId> = {};
forEachRegionAndVersion(dataMap, 'jpnFirst', 'newFirst', (region, version, musics) => Object.entries(musics).forEach(([idStr, music]) => {
const id = Number(idStr);
const resultMusic = result[id] ??= {
name: music.name,
artist: music.artist,
genre: music.genre,
bpm: music.bpm,
charts: music.charts,
levelChangeLog: [],
regionalInfo: {},
};
if (resultMusic.charts.length < music.charts.length) {
// Re:MASTER added.
resultMusic.charts.push(...music.charts.slice(resultMusic.charts.length));
}
const regionalInfo = resultMusic.regionalInfo[region] ??= {
addDeleteLog: {},
netOpenDate: null,
};
regionalInfo.netOpenDate = music.netOpenDate; // The newest version comes first.
lowsetSeenVersionId[id] = Math.min(lowsetSeenVersionId[id] ?? version, version);
}));
const knownMusicIds = new Set(objectKeys(result).map(Number));
const perRegionLevelChangeLog: Record<
number,
Partial<Record<MaimaiRegion, Partial<Record<MaimaiMajorVersionId, number | null>>[]>>
> = Object.fromEntries(objectKeys(result).map(id => [id, {}]));
// Process the change log - with the release version order.
forEachRegionAndVersion(dataMap, 'jpnFirst', 'oldFirst', (region, version, musics) => {
// Track the music IDs known but not seen in the current version.
const unseenMusicIds = new Set(knownMusicIds);
// Set the level in change log for the version, for each music and chart.
for (const [idStr, music] of objectEntries(musics)) {
const id = Number(idStr);
const changeLog = (perRegionLevelChangeLog[id]![region] ??= []);
for (const [i, chart] of music.chartsWithLevel.entries()) {
unseenMusicIds.delete(id);
if (chart) (changeLog[i] ||= {})[version] = chart.level;
}
}
// For each unseen music IDs, if we've seen it before in current region, mark it as "deleted from package".
for (const musicId of unseenMusicIds) {
const changeLog = perRegionLevelChangeLog[musicId]![region];
if (changeLog) {
let deleted = false;
for (const chart of changeLog) {
if (chart) {
chart[version] = null; // Mark as "deleted from package".
deleted = true;
}
}
if (deleted) logger.verbose(`Music ${musicId} (${result[musicId]!.name}) is deleted in ${MaimaiMajorVersionId[version]} package`);
}
}
});
// Check -- one chart's level should keep the same in the same version across regions.
for (const musicId of knownMusicIds) {
for (let difficulty = 0; difficulty <= 4; difficulty++) {
for (const version of maimaiMajorVersionIds) {
const resultMusic = result[musicId]!;
let known: { region: string; level: number } | undefined;
for (const region of objectKeys(resultMusic.regionalInfo)) {
if (region === MaimaiRegion.CHN) continue;
const changeLog = perRegionLevelChangeLog[musicId]![region]![difficulty];
const level = changeLog?.[version];
if (level) {
if (known) {
if (known.level !== level) {
logger.error(
`Chart ${musicId}[${difficulty}] (${result[musicId]!.name}) has different same-version level (${known.region}: ${known.level}, ${region}: ${level}) in ${MaimaiMajorVersionId[version]}`,
);
}
} else {
known = { region, level };
}
// Check -- if a music is added in a non-JPN region before the JPN version. the level should be the same.
if (region !== MaimaiRegion.JPN) {
const jpnChangeLog = perRegionLevelChangeLog[musicId]![MaimaiRegion.JPN]?.[difficulty];
if (jpnChangeLog) {
const firstVersion = lowsetSeenVersionId[musicId]!;
const firstLevel = perRegionLevelChangeLog[musicId]![MaimaiRegion.JPN]![difficulty]?.[firstVersion];
if (firstLevel && version < firstVersion) {
if (level !== firstLevel) {
logger.error(
`Chart ${musicId}[${difficulty}] (${result[musicId]!.name}) has different level (${region}: ${level}, JPN: ${firstLevel}) in ${MaimaiMajorVersionId[version]} (JPN added in ${MaimaiMajorVersionId[firstVersion]})`,
);
} else {
logger.verbose(
`Chart ${musicId}[${difficulty}] (${result[musicId]!.name}) has the same level (${level}) in ${MaimaiMajorVersionId[version]} (JPN added in ${MaimaiMajorVersionId[firstVersion]})`,
);
}
// else: missing data.
}
}
}
}
}
}
}
}
// Fill the `levelChangeLog` and `addDeleteLog` in the result with `perRegionLevelChangeLog`.
for (const [idStr, music] of objectEntries(result)) {
const id = Number(idStr);
const levelChangeLog = music.levelChangeLog;
for (const region of objectKeys(music.regionalInfo)) {
const regionLevelChangeLog = perRegionLevelChangeLog[id]![region]!;
if (region !== MaimaiRegion.CHN) {
for (const [difficulty, regionLevelChangeLogEntries] of regionLevelChangeLog.entries()) {
levelChangeLog[difficulty] ||= {};
for (const [version, level] of objectEntries(regionLevelChangeLogEntries)) {
if (level != null) {
levelChangeLog[difficulty]![version] = level;
}
}
}
}
const { addDeleteLog } = music.regionalInfo[region]!;
let previousVersion: MaimaiMajorVersionId | undefined;
for (const version of dataMap.get(region)!.keys()) {
let addDeleteLogEntry: MaimaiMusicAddDeleteLogEntry | undefined;
if (
regionLevelChangeLog[0]![version] != null &&
(previousVersion === undefined || regionLevelChangeLog[0]![previousVersion] == null)
) {
addDeleteLogEntry = MaimaiMusicAddDeleteLogEntry.Added;
} else if (
previousVersion !== undefined &&
regionLevelChangeLog[4]?.[version] != null &&
regionLevelChangeLog[4]?.[previousVersion] == null
) {
addDeleteLogEntry = MaimaiMusicAddDeleteLogEntry.AddedReMaster;
} else if (previousVersion !== undefined && regionLevelChangeLog[0]![previousVersion] != null) {
const input = dataMap.get(region)!.get(version)!;
if (regionLevelChangeLog[0]![version] == null) {
addDeleteLogEntry = MaimaiMusicAddDeleteLogEntry.DeletedFromPackage;
} else if (input[id]!.deletedInPatch) {
addDeleteLogEntry = MaimaiMusicAddDeleteLogEntry.DeletedInPatch;
}
}
if (addDeleteLogEntry) {
addDeleteLog[version] = addDeleteLogEntry;
}
previousVersion = version;
}
}
// Delete the unchanged version entries from level change log.
for (const levelChangeLogEntries of levelChangeLog.values()) {
const entries = objectEntries(levelChangeLogEntries).sort(([a], [b]) => Number(a) - Number(b));
for (const [i, [version, level]] of entries.entries()) {
// The first entry is always kept.
if (i === 0) continue;
// Check if the level is changed from the previous version.
const previousLevel = entries[i - 1]![1];
if (level === previousLevel) {
delete levelChangeLogEntries[version];
}
}
}
}
// TODO: Merge to one change log and track unavailablity (done). CHN 1.20 levels mismatch?
return result;
};
+36
View File
@@ -0,0 +1,36 @@
export const objectEntries = <T extends object>(obj: T) => Object.entries(obj) as Array<[keyof T, T[keyof T]]>;
export const objectFromEntries = <T extends [string | number | symbol, unknown][]>(entries: T) => Object.fromEntries(entries) as Record<T[number][0], T[number][1]>;
export const objectKeys = <T extends object>(obj: T) => Object.keys(obj) as (keyof T)[];
export const objectMap = <T extends object, R>(obj: T, fn: (value: T[keyof T], key: keyof T) => R) =>
Object.fromEntries(objectEntries(obj).map(([key, value]) => [key, fn(value, key)])) as Record<keyof T, R>;
export const arrayToObject = <T extends string | number | symbol, R>(array: readonly T[], fn: (value: T, index: number) => R) =>
Object.fromEntries(array.map((value, index) => [value, fn(value, index)])) as Record<T, R>;
export const objectFilter = <T extends object>(obj: T, fn: (value: T[keyof T], key: keyof T) => boolean) =>
Object.fromEntries(objectEntries(obj).filter(([key, value]) => fn(value, key))) as T;
export const getOrSet = <M extends Map<unknown, unknown>>(
map: M,
key: Parameters<M['has']>[0],
value: Parameters<M['set']>[1],
): Parameters<M['set']>[1] => {
if (!map.has(key)) {
map.set(key, value);
}
return map.get(key)!;
};
export async function forEachParallel<T>(
iterable: AsyncIterable<T> | Promise<Iterable<T>>,
callback: (value: T) => Promise<void>,
) {
const promises: Promise<void>[] = [];
for await (const value of await iterable) {
promises.push(callback(value));
}
await Promise.all(promises);
}
+34
View File
@@ -0,0 +1,34 @@
import { isEqual } from 'es-toolkit';
import type { MaimaiMajorVersionId, MaimaiRegion, MetadataMaybeRegionalized, MetadataMaybeVersioned } from '../interfaces';
import { objectEntries, objectFromEntries, objectMap } from './base';
import { zCoerceString } from './zod';
export const parseNetOpenDate = (input: unknown) => {
const str = zCoerceString(input);
const match = str.match(/^Net(\d{2})(\d{2})(\d{2})$/);
if (!match) return null;
const [, year, month, day] = match;
if (year === '99') return null;
return `20${year}-${month}-${day}`;
};
export type VersionedMap<T> = Partial<Record<MaimaiMajorVersionId, T>>;
export const maybeCompactVersionedMap = <T>(versionedMap: VersionedMap<T>): MetadataMaybeVersioned<T> => {
const versionedEntries = objectEntries(versionedMap)
.toSorted(([verA], [verB]) => verA - verB) // version: low -> high
.filter(([, data], index, array) => index === 0 || !isEqual(data, array[index - 1]?.[1])); // Skip entries that are equal to the previous version.
return versionedEntries.length === 1
? { unversioned: versionedEntries[0]![1]! }
: { versioned: objectFromEntries(versionedEntries) };
};
export type RegionalizedMap<T> = Partial<Record<MaimaiRegion, VersionedMap<T>>>;
export const maybeCompactRegionalizedMap = <T>(regionalizedMap: RegionalizedMap<T>): MetadataMaybeRegionalized<T> => {
const regionalizedEntries = objectMap(regionalizedMap, versionedMap => maybeCompactVersionedMap(versionedMap!));
return regionalizedEntries.JPN != null && isEqual(regionalizedEntries.JPN, regionalizedEntries.EXP) && isEqual(regionalizedEntries.JPN, regionalizedEntries.CHN)
? { unregionalized: regionalizedEntries.JPN! }
: { regionalized: regionalizedEntries };
};
export type RegionalizedNetOpenDate = Partial<Record<MaimaiRegion, string | null>>;
+38
View File
@@ -0,0 +1,38 @@
import type { MaimaiMajorVersionId } from '../interfaces';
import { MaimaiRegion } from '../interfaces';
export const forEachRegion = <T>(
regionMap: Map<MaimaiRegion, T>,
order: 'jpnFirst' | 'jpnLast',
callback: (region: MaimaiRegion, data: T) => void,
) =>
[...regionMap.entries()]
.sort(
([a], [b]) =>
(order === 'jpnFirst' ? 1 : -1) *
(Object.values(MaimaiRegion).indexOf(a) - Object.values(MaimaiRegion).indexOf(b)),
)
.forEach(([region, data]) => callback(region, data));
export const forEachVersion = <T>(
versionMap: Map<MaimaiMajorVersionId, T>,
order: 'oldFirst' | 'newFirst',
callback: (version: MaimaiMajorVersionId, data: T) => void,
) =>
[...versionMap.entries()]
.sort(([a], [b]) => (order === 'oldFirst' ? a - b : b - a))
.forEach(([version, data]) => callback(version, data));
export const forEachRegionAndVersion = <T>(
regionMap: Map<MaimaiRegion, Map<MaimaiMajorVersionId, T>>,
regionOrder: 'jpnFirst' | 'jpnLast',
versionOrder: 'oldFirst' | 'newFirst',
callback: (region: MaimaiRegion, version: MaimaiMajorVersionId, data: T) => void,
) => forEachRegion(
regionMap, regionOrder,
(region, versionMap) => forEachVersion(
versionMap, versionOrder,
(version, data) =>
callback(region, version, data),
),
);
+19
View File
@@ -0,0 +1,19 @@
import fs from 'node:fs';
import { XMLParser } from 'fast-xml-parser';
import { glob } from 'glob';
export async function* parseXmls(stream: ReturnType<typeof glob.stream>) {
for await (const file of stream) {
const fileName = typeof file === 'string' ? file : file.fullpath();
const xmlString = await fs.promises.readFile(fileName, 'utf-8');
const xml = new XMLParser({ ignoreAttributes: false, allowBooleanAttributes: true }).parse(xmlString);
yield { fileName, xml };
}
}
export const globAxxxDirs = async (streamingAssetsPath: string) =>
(await glob(`${streamingAssetsPath}/*`)).sort((a, b) => a.localeCompare(b));
export const globFiles = (axxxPath: string, dirName: string, subDirPrefix: string, fileName: string) =>
glob.stream(`${axxxPath}/${dirName}/${subDirPrefix}*/${fileName}`);
+6
View File
@@ -0,0 +1,6 @@
import type { util as zUtil } from 'zod';
import { z } from 'zod';
export const zCoerceNumber = (data: unknown) => z.coerce.number().parse(data);
export const zCoerceString = (data: unknown) => z.coerce.string().parse(data);
export const zParseEnum = <T extends zUtil.EnumLike>(enumType: T, data: unknown) => z.enum(enumType).parse(data);
+57
View File
@@ -0,0 +1,57 @@
import fs from 'node:fs';
import type { MaimaiMetadataKind, MaimaiRegion, MaimaiMajorVersionId } from './interfaces';
import { createLogger } from './logger';
import { basicDataTypes } from './processors/basic';
import { processMusic } from './processors/music';
import { objectMap } from './utils/base';
import { globAxxxDirs } from './utils/fs';
const logger = createLogger('Worker');
export interface WorkerArguments {
region: MaimaiRegion;
version: MaimaiMajorVersionId;
streamingAssetsPath: string;
metadataKind: MaimaiMetadataKind;
outputFilePath: string;
};
export interface WorkerContext extends WorkerArguments {
forEachAxxxDirOrdered: (callback: (axxxDir: string) => Promise<void>) => Promise<void>;
}
export type WorkerProcessor<TIntermediateData> = (ctx: WorkerContext) => TIntermediateData | Promise<TIntermediateData>;
const run = async (args: WorkerArguments) => {
const { region, version, streamingAssetsPath, metadataKind, outputFilePath } = args;
logger.log(`Generating metadata for maimai (region = ${region}, version = ${version})`);
// Axxx dirs are ordered, so don't use forEachParallel.
const ctx: WorkerContext = {
...args,
forEachAxxxDirOrdered: async (callback: (axxxDir: string) => Promise<void>) => {
for (const axxxDir of await globAxxxDirs(streamingAssetsPath)) {
logger.log(`Processing ${axxxDir}`);
await callback(axxxDir);
}
},
};
const metadataKindProcessors: Record<MaimaiMetadataKind, WorkerProcessor<unknown>> = {
...objectMap(basicDataTypes, dataType => dataType.process),
music: processMusic,
};
logger.log(`Writing metadata to ${outputFilePath}`);
await fs.promises.writeFile(outputFilePath, JSON.stringify(await metadataKindProcessors[metadataKind](ctx), null, 2));
};
export default async (args: WorkerArguments) => {
try {
await run(args);
} catch (error) {
logger.error(`Error processing metadata: ${error}`);
throw error;
}
};
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
"strictNullChecks": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}