///
import { FumenType, UnlockMusicEntry } from './models';
const MDB_BUNDLE = [
'game',
'dancearound_data',
'StreamingAssets',
'aa',
'win64',
'audioWorks',
'musicassetgroup_assets_music_config',
'mdb_cabinet_base.bundle',
];
export interface MdbMusic {
music_id: number;
title_name: string;
title_yomigana: string;
artist_name: string;
artist_yomigana: string;
bpm_max: number;
bpm_min: number;
distribution_date: string;
release_code: string;
volume: number;
bg_no: number;
region: number;
tags: string[];
limitation_type: number;
license: string;
color1: string;
color2: string;
color3: string;
has_mv: number;
demo_pri: number;
video_flags: { JP: number; US: number };
motion_flags: { JP: number; US: number };
fumens: Array<{
fumen_type: FumenType;
level: number;
playable: number;
has_official_dance: number;
backdancer_id: number;
price: number;
limitation_type: number;
}>;
}
export interface LocalMdbCatalog {
sourcePath: string;
musicCount: number;
chartCount: number;
lockedChartCount: number;
baselineChartCount: number;
musics: MdbMusic[];
unlocks: UnlockMusicEntry[];
}
class Reader {
offset = 0;
constructor(readonly data: Buffer) {}
bytes(size: number): Buffer {
const value = this.data.slice(this.offset, this.offset + size);
this.offset += size;
return value;
}
cstring(): string {
const end = this.data.indexOf(0, this.offset);
if (end < 0) throw new Error('unterminated UnityFS string');
const value = this.data.toString('utf8', this.offset, end);
this.offset = end + 1;
return value;
}
u16(): number {
const value = this.data.readUInt16BE(this.offset);
this.offset += 2;
return value;
}
u32(): number {
const value = this.data.readUInt32BE(this.offset);
this.offset += 4;
return value;
}
u64(): number {
return this.u32() * 0x100000000 + this.u32();
}
align(size: number): void {
this.offset = Math.ceil(this.offset / size) * size;
}
}
function lz4Length(
input: Buffer,
position: { value: number },
length: number
): number {
if (length !== 15) return length;
let next = 255;
while (next === 255) {
next = input[position.value++];
length += next;
}
return length;
}
function lz4(input: Buffer, outputSize: number): Buffer {
const output = Buffer.alloc(outputSize);
const position = { value: 0 };
let target = 0;
while (position.value < input.length) {
const token = input[position.value++];
const literalSize = lz4Length(input, position, token >>> 4);
input.copy(output, target, position.value, position.value + literalSize);
position.value += literalSize;
target += literalSize;
if (position.value >= input.length) break;
const distance = input[position.value] | (input[position.value + 1] << 8);
position.value += 2;
const matchSize = lz4Length(input, position, token & 0x0f) + 4;
for (let i = 0; i < matchSize; ++i) {
output[target] = output[target - distance];
++target;
}
}
if (target !== outputSize)
throw new Error(`LZ4 size mismatch: ${target}/${outputSize}`);
return output;
}
function decompress(input: Buffer, outputSize: number, flags: number): Buffer {
switch (flags & 0x3f) {
case 0:
return input;
case 2:
case 3:
return lz4(input, outputSize);
default:
throw new Error(`unsupported UnityFS compression ${flags & 0x3f}`);
}
}
function unpackUnityFs(bundle: Buffer): Buffer {
const header = new Reader(bundle);
if (header.cstring() !== 'UnityFS') throw new Error('not a UnityFS bundle');
const version = header.u32();
header.cstring();
header.cstring();
header.u64();
const compressedInfoSize = header.u32();
const infoSize = header.u32();
const flags = header.u32();
if (version >= 7) header.align(16);
const infoAtEnd = (flags & 0x80) !== 0;
const infoOffset = infoAtEnd
? bundle.length - compressedInfoSize
: header.offset;
const info = new Reader(
decompress(
bundle.slice(infoOffset, infoOffset + compressedInfoSize),
infoSize,
flags
)
);
info.bytes(16);
const blocks: Array<{ raw: number; packed: number; flags: number }> = [];
for (let count = info.u32(); count > 0; --count) {
blocks.push({ raw: info.u32(), packed: info.u32(), flags: info.u16() });
}
const nodes: Array<{ offset: number; size: number }> = [];
for (let count = info.u32(); count > 0; --count) {
const offset = info.u64();
const size = info.u64();
info.u32();
info.cstring();
nodes.push({ offset, size });
}
let dataOffset = infoAtEnd ? header.offset : infoOffset + compressedInfoSize;
if ((flags & 0x200) !== 0) dataOffset = Math.ceil(dataOffset / 16) * 16;
const chunks: Buffer[] = [];
for (const block of blocks) {
chunks.push(
decompress(
bundle.slice(dataOffset, dataOffset + block.packed),
block.raw,
block.flags
)
);
dataOffset += block.packed;
}
const data = Buffer.concat(chunks);
return Buffer.concat(
nodes.map(node => data.slice(node.offset, node.offset + node.size))
);
}
function findJsonEnd(data: Buffer, start: number): number {
let depth = 0;
let quoted = false;
let escaped = false;
for (let i = start; i < data.length; ++i) {
if (quoted) {
if (escaped) escaped = false;
else if (data[i] === 0x5c) escaped = true;
else if (data[i] === 0x22) quoted = false;
} else if (data[i] === 0x22) quoted = true;
else if (data[i] === 0x7b) ++depth;
else if (data[i] === 0x7d && --depth === 0) return i + 1;
}
return -1;
}
function extractMdb(bundle: Buffer): any {
const data = unpackUnityFs(bundle);
const marker = data.indexOf(Buffer.from('"music_count"'));
if (marker < 0) throw new Error('mdb_cabinet_base TextAsset was not found');
for (
let start = data.lastIndexOf(0x7b, marker);
start >= 0;
start = data.lastIndexOf(0x7b, start - 1)
) {
const end = findJsonEnd(data, start);
if (end < 0) continue;
try {
const document = JSON.parse(data.toString('utf8', start, end));
if (Array.isArray(document.musics)) return document;
} catch (_) {}
}
throw new Error('mdb_cabinet_base TextAsset was not found');
}
function number(value: any, fallback = 0): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function text(value: any): string {
return value == null ? '' : `${value}`;
}
function flagPair(value: any): { JP: number; US: number } {
return { JP: number(value && value.JP), US: number(value && value.US) };
}
function catalog(document: any, sourcePath: string): LocalMdbCatalog {
const musics: MdbMusic[] = [];
const unlocks: UnlockMusicEntry[] = [];
let lockedChartCount = 0;
let baselineChartCount = 0;
for (const source of document.musics) {
const musicId = number(source.music_id);
const limitation = number(source.limitation_type);
const fumens = (source.fumens || [])
.filter(
(fumen: any) =>
number(fumen.playable) === 1 || number(fumen.playable) === 2
)
.map((fumen: any) => {
const playable = number(fumen.playable);
if (playable === 1) ++lockedChartCount;
else ++baselineChartCount;
return {
fumen_type: text(fumen.fumen_type) as FumenType,
level: number(fumen.level),
playable,
has_official_dance: number(fumen.has_official_dance),
backdancer_id: number(fumen.backdancer_id),
price: number(fumen.fumen_price),
limitation_type: playable === 1 ? 2 : limitation,
};
});
if (!fumens.length) continue;
musics.push({
music_id: musicId,
title_name: text(source.title_name),
title_yomigana: text(source.title_yomigana),
artist_name: text(source.artist_name),
artist_yomigana: text(source.artist_yomigana),
bpm_max: number(source.bpm_max),
bpm_min: number(source.bpm_min),
distribution_date: text(source.distribution_date),
release_code: text(source.release_code),
volume: number(source.volume),
bg_no: number(source.bg_no),
region: number(source.region),
tags: Array.isArray(source.tags) ? source.tags.map(text) : [],
limitation_type: limitation,
license: text(source.license),
color1: text(source.color1),
color2: text(source.color2),
color3: text(source.color3),
has_mv: number(source.has_mv),
demo_pri: number(source.demo_pri),
video_flags: flagPair(source.video_flags),
motion_flags: flagPair(source.motion_flags),
fumens,
});
unlocks.push({ musicId, fumens: fumens.map(fumen => fumen.fumen_type) });
}
return {
sourcePath,
musicCount: musics.length,
chartCount: lockedChartCount + baselineChartCount,
lockedChartCount,
baselineChartCount,
musics,
unlocks,
};
}
function findBundle(gamePath: string): string | null {
const fs = require('fs');
const path = require('path');
const roots = gamePath
? [gamePath]
: [process.cwd(), path.dirname(process.cwd())];
for (const value of roots) {
const root = path.resolve(value);
const candidates =
path.extname(root).toLowerCase() === '.bundle'
? [root]
: [MDB_BUNDLE, MDB_BUNDLE.slice(1), MDB_BUNDLE.slice(2)].map(parts =>
path.join(root, ...parts)
);
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
return candidate;
}
}
return null;
}
let cacheKey = '';
let cache: LocalMdbCatalog | null = null;
let reportedError = '';
export function readLocalMdb(bundlePath: string): LocalMdbCatalog {
const fs = require('fs');
const path = require('path');
const resolved = path.resolve(bundlePath);
return catalog(extractMdb(fs.readFileSync(resolved)), resolved);
}
export async function loadLocalMdb(): Promise {
const fs = require('fs');
const configured = text(U.GetConfig('game_path')).trim();
const bundlePath = findBundle(configured);
if (!bundlePath) {
const error = configured
? `MDB was not found under ${configured}`
: 'game_path is not set';
if (reportedError !== error) console.warn(`[Dance Around] ${error}`);
reportedError = error;
return null;
}
const stat = fs.statSync(bundlePath);
const key = `${bundlePath}:${stat.size}:${stat.mtimeMs}`;
if (cache && cacheKey === key) return cache;
try {
cache = readLocalMdb(bundlePath);
cacheKey = key;
reportedError = '';
console.log(
`[Dance Around] loaded ${cache.musicCount} music / ${cache.chartCount} charts from local MDB`
);
return cache;
} catch (error) {
const message = `${bundlePath}: ${error}`;
if (reportedError !== message)
console.error(`[Dance Around] failed to load MDB: ${message}`);
reportedError = message;
return null;
}
}