From 79b530ecdc9e281503426fbba940236ff32dd8df Mon Sep 17 00:00:00 2001 From: Avimitin Date: Sun, 30 Aug 2026 21:08:25 +0800 Subject: [PATCH] Add DANCE aROUND game service Implement card registration, persistent profiles and scores, local catalog unlocks, cabinet telemetry, and bounded motion storage. Read the installed Unity MDB at runtime so the public plugin contains no generated installation data or maintainer tooling. Co-authored-by: Codex --- dancearound@asphyxia/README.md | 7 + dancearound@asphyxia/handlers/common.ts | 127 ++++++ dancearound@asphyxia/handlers/motion.ts | 208 ++++++++++ dancearound@asphyxia/handlers/profile.ts | 337 ++++++++++++++++ dancearound@asphyxia/index.ts | 70 ++++ dancearound@asphyxia/mdb.ts | 391 +++++++++++++++++++ dancearound@asphyxia/models.ts | 166 ++++++++ dancearound@asphyxia/utils.ts | 474 +++++++++++++++++++++++ 8 files changed, 1780 insertions(+) create mode 100644 dancearound@asphyxia/README.md create mode 100644 dancearound@asphyxia/handlers/common.ts create mode 100644 dancearound@asphyxia/handlers/motion.ts create mode 100644 dancearound@asphyxia/handlers/profile.ts create mode 100644 dancearound@asphyxia/index.ts create mode 100644 dancearound@asphyxia/mdb.ts create mode 100644 dancearound@asphyxia/models.ts create mode 100644 dancearound@asphyxia/utils.ts diff --git a/dancearound@asphyxia/README.md b/dancearound@asphyxia/README.md new file mode 100644 index 0000000..2d38109 --- /dev/null +++ b/dancearound@asphyxia/README.md @@ -0,0 +1,7 @@ +# DANCE aROUND + +DANCE aROUND (`UDN`) plugin for Asphyxia CORE. + +Drag the `dancearound@asphyxia` folder into CORE's `plugins` folder. Set the +local game path in the plugin settings to load and unlock the installed music +catalog. diff --git a/dancearound@asphyxia/handlers/common.ts b/dancearound@asphyxia/handlers/common.ts new file mode 100644 index 0000000..bdffb8c --- /dev/null +++ b/dancearound@asphyxia/handlers/common.ts @@ -0,0 +1,127 @@ +/// + +import { nowMs, readNumber, readString } from '../utils'; +import { loadLocalMdb, MdbMusic } from '../mdb'; + +function mdbMusicResponse(music: MdbMusic): any { + return { + music_id: K.ITEM('s32', music.music_id), + title_name: K.ITEM('str', music.title_name), + title_yomigana: K.ITEM('str', music.title_yomigana), + artist_name: K.ITEM('str', music.artist_name), + artist_yomigana: K.ITEM('str', music.artist_yomigana), + bpm_max: K.ITEM('s32', music.bpm_max), + bpm_min: K.ITEM('s32', music.bpm_min), + distribution_date: K.ITEM('str', music.distribution_date), + release_code: K.ITEM('str', music.release_code), + volume: K.ITEM('s32', music.volume), + bg_no: K.ITEM('s32', music.bg_no), + region: K.ITEM('s32', music.region), + tags: { + tag: music.tags.map(tag => K.ITEM('str', tag)), + }, + limitation_type: K.ITEM('s32', music.limitation_type), + license: K.ITEM('str', music.license), + color1: K.ITEM('str', music.color1), + color2: K.ITEM('str', music.color2), + color3: K.ITEM('str', music.color3), + has_mv: K.ITEM('bool', music.has_mv), + demo_pri: K.ITEM('s32', music.demo_pri), + fumens: { + fumen: music.fumens.map(fumen => ({ + fumen_type: K.ITEM('str', fumen.fumen_type), + level: K.ITEM('s32', fumen.level), + playable: K.ITEM('s32', fumen.playable), + has_official_dance: K.ITEM('s32', fumen.has_official_dance), + backdancer_id: K.ITEM('s32', fumen.backdancer_id), + price: K.ITEM('s32', fumen.price), + limitation_type: K.ITEM('s32', fumen.limitation_type), + })), + }, + video_flags: { + JP: K.ITEM('s32', music.video_flags.JP), + US: K.ITEM('s32', music.video_flags.US), + }, + motion_flags: { + JP: K.ITEM('s32', music.motion_flags.JP), + US: K.ITEM('s32', music.motion_flags.US), + }, + }; +} + +export const getCommon: EPR = async (info, data, send) => { + const catalog = U.GetConfig('unlock_all_songs') ? await loadLocalMdb() : null; + return send.object( + { + mdb: { + music: catalog ? catalog.musics.map(mdbMusicResponse) : [], + }, + event: {}, + }, + { status: 0 } + ); +}; + +export const lockMultiLogin: EPR = async (info, data, send) => { + return send.object( + { + result: K.ITEM('s32', 0), + }, + { status: 0 } + ); +}; + +export const savePcbData: EPR = async (info, data, send) => { + const reader = $(data); + await DB.Upsert( + { collection: 'pcb' } as any, + { + $set: { + collection: 'pcb', + model: info.model, + locationId: readString(reader, 'pcbinfo.loc_id'), + region: readNumber(reader, 'pcbinfo.region'), + locationName: readString(reader, 'pcbinfo.locname'), + customer: readString(reader, 'pcbinfo.customer'), + company: readString(reader, 'pcbinfo.company'), + systemId: readString(reader, 'pcbinfo.system_id'), + hardwareId: readString(reader, 'pcbinfo.hardware_id'), + licenseId: readString(reader, 'pcbinfo.license_id'), + accountId: readString(reader, 'pcbinfo.account_id'), + boot: readNumber(reader, 'pcbinfo.boot'), + eacoinStatus: readNumber(reader, 'pcbinfo.eacoin_status'), + updateProgress: readNumber(reader, 'pcbinfo.update_progress'), + shopName: readString(reader, 'etc.network.shopname'), + matchingGroup: readString(reader, 'etc.network.matching_group'), + updatedAt: nowMs(), + }, + } as any + ); + + return send.object( + { + next_request_interval_sec: K.ITEM('u64', BigInt(300)), + }, + { status: 0 } + ); +}; + +export const saveLog: EPR = async (info, data, send) => { + const reader = $(data); + const items = reader.elements('logdata'); + await DB.Upsert( + { collection: 'telemetry' } as any, + { + $set: { + collection: 'telemetry', + lastModel: info.model, + lastDataKey: items.length + ? readString(items[items.length - 1], 'data_key') + : '', + updatedAt: nowMs(), + }, + $inc: { receivedLogBatches: 1, receivedLogItems: items.length }, + } as any + ); + return send.success(); +}; diff --git a/dancearound@asphyxia/handlers/motion.ts b/dancearound@asphyxia/handlers/motion.ts new file mode 100644 index 0000000..7d461ea --- /dev/null +++ b/dancearound@asphyxia/handlers/motion.ts @@ -0,0 +1,208 @@ +/// + +declare const Buffer: any; + +import { MotionDocument, MotionEntry, ProfileDocument } from '../models'; +import { + avatarResponse, + createProfile, + dancerGradeResponse, + makeUuid, + motionInfoResponse, + nowMs, + readAvatar, + readNumber, + readStageResult, + readString, +} from '../utils'; + +function readMotionIds(reader: KDataReader): string[] { + const values = reader + .elements('motion_id_list.motion_id') + .map(item => readString(item, '')); + for (const dataItem of reader.elements('data_item')) { + values.push( + ...dataItem + .elements('motion_id_list.motion_id') + .map(item => readString(item, '')) + ); + } + return values.filter( + (value, index) => !!value && values.indexOf(value) === index + ); +} + +async function findProfile(refid: string): Promise { + return ( + (await DB.FindOne(refid, { collection: 'profile' })) || + createProfile(refid, 'PLAYER') + ); +} + +export const saveMotionData: EPR = async (info, data, send) => { + const reader = $(data); + const refid = readString(reader, 'userid.ref_id'); + if (!refid) { + console.error('[Dance Around] save_motiondata is missing userid.ref_id'); + return send.deny(); + } + + const motionReader = reader.element('motiondata'); + const full = motionReader.buffer('motion_full', Buffer.alloc(0)); + const preview = motionReader.buffer('motion_prev', Buffer.alloc(0)); + const entry: MotionEntry = { + motionId: makeUuid(), + active: true, + publishSetting: readNumber(motionReader, 'publish_setting'), + playCount: 1, + totalGv: 0, + playResult: readStageResult(motionReader), + playDate: readNumber(motionReader, 'play_date', nowMs()), + locationId: readString(motionReader, 'loc_id'), + shopName: readString(motionReader, 'shopname'), + avatar: readAvatar(motionReader, 'avatar'), + motionFullBase64: full.toString('base64'), + motionPreviewBase64: preview.toString('base64'), + createdAt: nowMs(), + }; + + const configuredLimit = Number(U.GetConfig('motion_history_limit')); + const historyLimit = Number.isFinite(configuredLimit) + ? Math.max(0, Math.min(100, Math.floor(configuredLimit))) + : 20; + if (historyLimit > 0) { + const document = (await DB.FindOne(refid, { + collection: 'motions', + })) || { + collection: 'motions' as const, + schemaVersion: 1, + entries: [], + updatedAt: 0, + }; + document.entries = [entry, ...(document.entries || [])].slice( + 0, + historyLimit + ); + document.updatedAt = nowMs(); + await DB.Upsert( + refid, + { collection: 'motions' }, + { $set: document } + ); + } + + console.log( + `[Dance Around] accepted motion ${entry.motionId} (${full.length}/${preview.length} bytes)` + ); + return send.object( + { + motion_id: K.ITEM('str', entry.motionId), + }, + { status: 0 } + ); +}; + +export const getMotionInfoList: EPR = async (info, data, send) => { + const reader = $(data); + const refid = readString(reader, 'userid.ref_id'); + if (!refid) { + console.error('[Dance Around] get_motioninfolist is missing userid.ref_id'); + return send.deny(); + } + + const document = await DB.FindOne(refid, { + collection: 'motions', + }); + const entries = document ? document.entries || [] : []; + return send.object( + { + data: entries.map(motionInfoResponse), + }, + { status: 0 } + ); +}; + +export const checkPlayableMotionData: EPR = async (info, data, send) => { + const reader = $(data); + const requested = readMotionIds(reader); + const refid = readString(reader, 'ref_id'); + const document = refid + ? await DB.FindOne(refid, { collection: 'motions' }) + : null; + const available = new Set( + (document ? document.entries || [] : []).map(entry => entry.motionId) + ); + return send.object( + { + data: requested.map(motionId => ({ + motion_id: K.ITEM('str', motionId), + is_playable: K.ITEM('bool', available.has(motionId)), + })), + }, + { status: 0 } + ); +}; + +interface LocatedMotion { + refid: string; + entry: MotionEntry; + profile: ProfileDocument; +} + +async function locateMotions(motionIds: string[]): Promise { + if (!motionIds.length) { + return []; + } + const wanted = new Set(motionIds); + const result: LocatedMotion[] = []; + const documents = await DB.Find(null, { + collection: 'motions', + }); + for (const document of documents) { + const refid = (document as any).__refid as string; + if (!refid) { + continue; + } + const profile = await findProfile(refid); + for (const entry of document.entries || []) { + if (wanted.has(entry.motionId)) { + result.push({ refid, entry, profile }); + } + } + } + return result; +} + +export const getMotionData: EPR = async (info, data, send) => { + const requested = readMotionIds($(data)); + const located = await locateMotions(requested); + return send.object( + { + data: located.map(item => ({ + info: motionInfoResponse(item.entry), + userid: { + dancer_id: K.ITEM('str', item.profile.dancerId), + }, + profile: { + name: K.ITEM('str', item.profile.name), + }, + privacy: { + publish_setting: K.ITEM('s32', item.profile.privacy.publishSetting), + }, + dancer_grade: dancerGradeResponse(item.profile.dancerGrade), + avatar: avatarResponse(item.entry.avatar), + motiondata: { + motion_full: K.ITEM( + 'bin', + Buffer.from(item.entry.motionFullBase64, 'base64') + ), + motion_prev: K.ITEM( + 'bin', + Buffer.from(item.entry.motionPreviewBase64, 'base64') + ), + }, + })), + }, + { status: 0 } + ); +}; diff --git a/dancearound@asphyxia/handlers/profile.ts b/dancearound@asphyxia/handlers/profile.ts new file mode 100644 index 0000000..b9f1a1c --- /dev/null +++ b/dancearound@asphyxia/handlers/profile.ts @@ -0,0 +1,337 @@ +/// + +import { + MusicScoreEntry, + ProfileDocument, + ScoreDocument, + UnlockMusicEntry, +} from '../models'; +import { + avatarResponse, + createProfile, + dancerGradeResponse, + mergeScore, + mergeUnlocks, + musicScoreKey, + nowMs, + nowS64, + playInfoResponse, + playOptionResponse, + readAvatar, + readDeltaGroup, + readMusicScore, + readMusicUnlockGauges, + readNumber, + readPlayInfo, + readPlayOption, + readString, + readUnlocks, + readViewFlags, + stageResultResponse, +} from '../utils'; +import { loadLocalMdb } from '../mdb'; + +async function findProfile(refid: string): Promise { + return ( + (await DB.FindOne(refid, { collection: 'profile' })) || + null + ); +} + +async function upsertProfile( + refid: string, + profile: ProfileDocument +): Promise { + profile.updatedAt = nowMs(); + await DB.Upsert( + refid, + { collection: 'profile' }, + { $set: profile } + ); +} + +async function effectiveUnlocks( + profile: ProfileDocument +): Promise { + if (!U.GetConfig('unlock_all_songs')) { + return profile.unlockedMusics || []; + } + const catalog = await loadLocalMdb(); + return mergeUnlocks( + profile.unlockedMusics || [], + catalog ? catalog.unlocks : [] + ); +} + +function unlockMusicResponse(entries: UnlockMusicEntry[]): any { + return { + music: entries.map(entry => + K.ATTR( + { music_id: `${entry.musicId}` }, + { fumen_type: entry.fumens.map(fumen => K.ITEM('str', fumen)) } + ) + ), + }; +} + +function profileResponse( + profile: ProfileDocument, + unlocks: UnlockMusicEntry[] +): any { + return { + result: K.ITEM('s32', 0), + now_date: K.ITEM('s64', nowS64()), + userid: { + code: K.ITEM('s32', profile.userCode), + dancer_id: K.ITEM('str', profile.dancerId), + }, + profile: { + name: K.ITEM('str', profile.name), + }, + privacy: { + publish_setting: K.ITEM('s32', profile.privacy.publishSetting), + }, + dancer_grade: dancerGradeResponse(profile.dancerGrade), + delta_group: { + delta: (profile.deltaGroup || []).map(delta => ({ + name: K.ITEM('str', delta.name), + income: K.ITEM('s32', delta.income), + expense: K.ITEM('s32', delta.expense), + })), + }, + avatar: avatarResponse(profile.avatar), + playoption: playOptionResponse(profile.playOption), + playinfo: playInfoResponse(profile.playInfo), + unlock_music: unlockMusicResponse(unlocks), + follow_data: { + follower_num: K.ITEM('s32', 0), + data: [], + }, + view_flags: { + view_flag: (profile.viewFlags || []).map(entry => ({ + view_id: K.ITEM('str', entry.viewId), + flag: K.ITEM('s32', entry.flag), + })), + }, + music_unlock_gauges: { + music_unlock_gauge: (profile.musicUnlockGauges || []).map(entry => ({ + event_id: K.ITEM('str', entry.eventId), + progress: K.ITEM('s32', entry.progress), + })), + }, + }; +} + +export const getPlayData: EPR = async (info, data, send) => { + const reader = $(data); + const refid = readString(reader, 'userid.ref_id'); + if (!refid) { + console.error('[Dance Around] get_playdata is missing userid.ref_id'); + return send.deny(); + } + + const profile = await findProfile(refid); + if (!profile) { + return send.object( + { + result: K.ITEM('s32', 1), + now_date: K.ITEM('s64', nowS64()), + }, + { status: 0 } + ); + } + + await DB.Update( + refid, + { collection: 'profile' }, + { $inc: { loginCount: 1 }, $set: { updatedAt: nowMs() } } + ); + + return send.object( + profileResponse(profile, await effectiveUnlocks(profile)), + { status: 0 } + ); +}; + +export const signup: EPR = async (info, data, send) => { + const reader = $(data); + const refid = readString(reader, 'userid.ref_id'); + if (!refid) { + console.error('[Dance Around] sign_up is missing userid.ref_id'); + return send.deny(); + } + + const name = readString(reader, 'profile.name', 'PLAYER'); + const dataId = readString(reader, 'userid.data_id'); + const cardNumber = readString(reader, 'userid.card_no'); + const existing = await findProfile(refid); + const profile = + existing || createProfile(refid, name, dataId, cardNumber, info.model); + profile.name = name || profile.name; + profile.dataId = dataId || profile.dataId; + profile.cardNumber = cardNumber || profile.cardNumber; + await upsertProfile(refid, profile); + console.log( + `[Dance Around] registered player ${profile.name} (${profile.dancerId})` + ); + return send.success(); +}; + +async function mergeScoreEntries( + refid: string, + incoming: MusicScoreEntry[] +): Promise { + if (!incoming.length) { + return; + } + const document = (await DB.FindOne(refid, { + collection: 'scores', + })) || { + collection: 'scores' as const, + schemaVersion: 1, + entries: [], + updatedAt: 0, + }; + + const scores: { [key: string]: MusicScoreEntry } = {}; + for (const entry of document.entries || []) { + scores[musicScoreKey(entry)] = entry; + } + for (const entry of incoming) { + if (entry.musicId <= 0) { + continue; + } + const key = musicScoreKey(entry); + scores[key] = mergeScore(scores[key], entry); + } + + document.entries = Object.keys(scores) + .map(key => scores[key]) + .sort( + (a, b) => a.musicId - b.musicId || a.fumenType.localeCompare(b.fumenType) + ); + document.updatedAt = nowMs(); + await DB.Upsert( + refid, + { collection: 'scores' }, + { $set: document } + ); +} + +export const savePlayData: EPR = async (info, data, send) => { + const reader = $(data); + const refid = readString( + reader, + 'data.userid.ref_id', + readString(reader, 'userid.ref_id') + ); + if (!refid) { + console.error('[Dance Around] save_playdata is missing data.userid.ref_id'); + return send.deny(); + } + + const profile = + (await findProfile(refid)) || + createProfile(refid, 'PLAYER', '', '', info.model); + profile.privacy = { + publishSetting: readNumber( + reader, + 'data.privacy.publish_setting', + profile.privacy.publishSetting + ), + }; + profile.dancerGrade = { + grade: readNumber( + reader, + 'data.dancer_grade.grade', + profile.dancerGrade.grade + ), + gvGauge: readNumber( + reader, + 'data.dancer_grade.gv_gauge', + profile.dancerGrade.gvGauge + ), + achievedGv: readNumber( + reader, + 'data.dancer_grade.achieve_gv', + profile.dancerGrade.achievedGv + ), + receivedGv: 0, + increaseGv: 0, + }; + profile.avatar = readAvatar(reader, 'data.avatar'); + profile.playOption = readPlayOption(reader, 'data.playoption'); + profile.playInfo = readPlayInfo(reader, 'data.playinfo', info.model); + profile.unlockedMusics = mergeUnlocks( + profile.unlockedMusics || [], + readUnlocks(reader, 'data.unlock_music.music') + ); + profile.viewFlags = readViewFlags(reader, 'data.view_flags.view_flag'); + profile.musicUnlockGauges = readMusicUnlockGauges( + reader, + 'data.music_unlock_gauges.music_unlock_gauge' + ); + profile.deltaGroup = readDeltaGroup(reader, 'data.delta_group.delta'); + await upsertProfile(refid, profile); + + const stagedata = reader.elements('data.stagedata.data').map(readMusicScore); + await mergeScoreEntries(refid, stagedata); + console.log( + `[Dance Around] saved profile ${profile.dancerId}; ${stagedata.length} stage result(s)` + ); + return send.success(); +}; + +export const saveMusicScore: EPR = async (info, data, send) => { + const reader = $(data); + const refid = readString( + reader, + 'data.userid.ref_id', + readString(reader, 'userid.ref_id') + ); + if (!refid) { + console.error( + '[Dance Around] save_musicscore is missing data.userid.ref_id' + ); + return send.deny(); + } + + const scoreReader = reader.element('data'); + const score = readMusicScore(scoreReader); + await mergeScoreEntries(refid, [score]); + console.log( + `[Dance Around] saved score ${score.musicId}/${score.fumenType}: ${score.score}` + ); + return send.success(); +}; + +export const getMusicScore: EPR = async (info, data, send) => { + const refid = readString($(data), 'userid.ref_id'); + if (!refid) { + console.error('[Dance Around] get_musicscore is missing userid.ref_id'); + return send.deny(); + } + + const document = await DB.FindOne(refid, { + collection: 'scores', + }); + const entries = document ? document.entries || [] : []; + return send.object( + { + scoredata: { + music: entries.map(score => ({ + ...stageResultResponse(score), + play_cnt: K.ITEM('s32', score.playCount), + play_date: K.ITEM('s64', BigInt(score.playDate)), + bestscore_date: K.ITEM('s64', BigInt(score.bestScoreDate)), + pcb_id: K.ITEM('str', score.pcbId), + loc_id: K.ITEM('str', score.locationId), + shopname: K.ITEM('str', score.shopName), + rec_cnt: K.ITEM('s32', score.recordCount), + gv_score: K.ITEM('s32', score.gvScore), + })), + }, + }, + { status: 0 } + ); +}; diff --git a/dancearound@asphyxia/index.ts b/dancearound@asphyxia/index.ts new file mode 100644 index 0000000..9f63647 --- /dev/null +++ b/dancearound@asphyxia/index.ts @@ -0,0 +1,70 @@ +import { + getPlayData, + signup, + savePlayData, + getMusicScore, + saveMusicScore, +} from './handlers/profile'; +import { + getCommon, + lockMultiLogin, + saveLog, + savePcbData, +} from './handlers/common'; +import { + checkPlayableMotionData, + getMotionData, + getMotionInfoList, + saveMotionData, +} from './handlers/motion'; + +export function register() { + R.GameCode('UDN'); + R.Contributor('Avimitin', 'https://github.com/Avimitin'); + R.ExtraModuleHandler(async () => ['game']); + + R.Config('unlock_all_songs', { + name: 'Unlock all songs', + desc: 'Load and unlock every playable chart from the installed game MDB.', + type: 'boolean', + default: true, + }); + + R.Config('game_path', { + name: 'Game path', + desc: 'Path to the local DANCE aROUND installation.', + type: 'string', + default: '', + }); + + R.Config('motion_history_limit', { + name: 'Motion history limit', + desc: 'Maximum number of saved motion captures per player. Set to 0 to disable storage.', + type: 'integer', + range: [0, 100], + default: 20, + }); + + R.Route('game.get_playdata', getPlayData); + R.Route('game.sign_up', signup); + R.Route('game.get_musicscore', getMusicScore); + R.Route('game.lock_multi_login', lockMultiLogin); + R.Route('game.get_common', getCommon); + R.Route('game.save_playdata', savePlayData); + R.Route('game.save_musicscore', saveMusicScore); + R.Route('game.save_pcbdata', savePcbData); + R.Route('game.save_log', saveLog); + R.Route('game.save_motiondata', saveMotionData); + R.Route('game.get_motioninfolist', getMotionInfoList); + R.Route('game.check_playable_motiondata', checkPlayableMotionData); + R.Route('game.get_motiondata', getMotionData); + + R.Unhandled(async (info, data, send) => { + console.warn( + `[Dance Around] unhandled route ${info.module}.${info.method}` + ); + await send.success(); + }); + + console.log('[Dance Around] UDN network service registered'); +} diff --git a/dancearound@asphyxia/mdb.ts b/dancearound@asphyxia/mdb.ts new file mode 100644 index 0000000..1e7f72a --- /dev/null +++ b/dancearound@asphyxia/mdb.ts @@ -0,0 +1,391 @@ +/// + +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; + } +} diff --git a/dancearound@asphyxia/models.ts b/dancearound@asphyxia/models.ts new file mode 100644 index 0000000..e7c18fb --- /dev/null +++ b/dancearound@asphyxia/models.ts @@ -0,0 +1,166 @@ +export type FumenType = 'BASIC' | 'ADVANCED' | 'MASTER'; + +export interface PrivacySetting { + publishSetting: number; +} + +export interface DancerGrade { + grade: number; + gvGauge: number; + achievedGv: number; + receivedGv: number; + increaseGv: number; +} + +export interface AvatarSetting { + physique: number; + skinColor: number; + face: number; + eyeColor: number; + hairstyle: number; + hairColor: number; + costumeUpper: number; + costumeLower: number; + accessoryHead: number; + accessoryFace: number; + accessoryBody: number; + accessoryHand: number; + baseChara: number; +} + +export interface PlayOptionSetting { + noteSpeed: number; + noteSize: number; + judgeSeType: number; + judgeSeVolume: number; + judgeTiming: number; + noteTiming: number; + guide: number; + invert: number; + stealth: number; + timelineDisplay: number; + avatarAction: number; + timelineModelDirection: number; + avatarModelDirection: number; +} + +export interface PlayInfo { + locationId: string; + modeId: number; + styleId: number; + folderId: number; + musicId: number; + fumenType: FumenType; + startDate: number; + endDate: number; + lightPlayCount: number; + standardPlayCount: number; + trainingPlayCount: number; + pcbId: string; + softcode: string; +} + +export interface UnlockMusicEntry { + musicId: number; + fumens: FumenType[]; +} + +export interface ViewFlagEntry { + viewId: string; + flag: number; +} + +export interface MusicUnlockGaugeEntry { + eventId: string; + progress: number; +} + +export interface DeltaEntry { + name: string; + income: number; + expense: number; +} + +export interface ProfileDocument { + collection: 'profile'; + schemaVersion: number; + name: string; + dataId: string; + cardNumber: string; + userCode: number; + dancerId: string; + privacy: PrivacySetting; + dancerGrade: DancerGrade; + avatar: AvatarSetting; + playOption: PlayOptionSetting; + playInfo: PlayInfo; + unlockedMusics: UnlockMusicEntry[]; + viewFlags: ViewFlagEntry[]; + musicUnlockGauges: MusicUnlockGaugeEntry[]; + deltaGroup: DeltaEntry[]; + loginCount: number; + createdAt: number; + updatedAt: number; +} + +export interface StageResult { + musicId: number; + fumenType: FumenType; + clearStatus: number; + score: number; + rank: number; + combo: number; + perfect: number; + great: number; + good: number; + bad: number; +} + +export interface MusicScoreEntry extends StageResult { + gameStartDate: number; + mode: number; + style: number; + stageNo: number; + playCount: number; + playDate: number; + bestScoreDate: number; + pcbId: string; + locationId: string; + shopName: string; + recordCount: number; + gvScore: number; + dropFrame: number; + dropFrameMax: number; + dropCount: number; + videoKey: string; +} + +export interface ScoreDocument { + collection: 'scores'; + schemaVersion: number; + entries: MusicScoreEntry[]; + updatedAt: number; +} + +export interface MotionEntry { + motionId: string; + active: boolean; + publishSetting: number; + playCount: number; + totalGv: number; + playResult: StageResult; + playDate: number; + locationId: string; + shopName: string; + avatar: AvatarSetting; + motionFullBase64: string; + motionPreviewBase64: string; + createdAt: number; +} + +export interface MotionDocument { + collection: 'motions'; + schemaVersion: number; + entries: MotionEntry[]; + updatedAt: number; +} diff --git a/dancearound@asphyxia/utils.ts b/dancearound@asphyxia/utils.ts new file mode 100644 index 0000000..ed35449 --- /dev/null +++ b/dancearound@asphyxia/utils.ts @@ -0,0 +1,474 @@ +/// + +declare const Buffer: any; + +import { + AvatarSetting, + DancerGrade, + DeltaEntry, + FumenType, + MotionEntry, + MusicScoreEntry, + MusicUnlockGaugeEntry, + PlayInfo, + PlayOptionSetting, + ProfileDocument, + StageResult, + UnlockMusicEntry, + ViewFlagEntry, +} from './models'; + +export const PROFILE_SCHEMA_VERSION = 1; + +export function nowMs(): number { + return Date.now(); +} + +export function nowS64(): bigint { + return BigInt(Date.now()); +} + +export function makeUserCode(refid: string): number { + let hash = 2166136261; + for (let i = 0; i < refid.length; ++i) { + hash ^= refid.charCodeAt(i); + hash = Math.imul(hash, 16777619) >>> 0; + } + return 100000000 + (hash % 900000000); +} + +export function makeDancerId(userCode: number): string { + return `${userCode}`.padStart(9, '0'); +} + +export function makeUuid(): string { + const bytes = Buffer.alloc(16); + let timestamp = Date.now(); + for (let i = 0; i < bytes.length; ++i) { + const timeByte = timestamp & 0xff; + bytes[i] = (Math.floor(Math.random() * 256) ^ timeByte) & 0xff; + timestamp = Math.floor(timestamp / 256); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice( + 12, + 16 + )}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export function defaultAvatar(): AvatarSetting { + return { + physique: 0, + skinColor: 0, + face: 0, + eyeColor: 0, + hairstyle: 0, + hairColor: 0, + costumeUpper: 0, + costumeLower: 0, + accessoryHead: 0, + accessoryFace: 0, + accessoryBody: 0, + accessoryHand: 0, + baseChara: 1, + }; +} + +export function defaultPlayOption(): PlayOptionSetting { + return { + noteSpeed: 0, + noteSize: 0, + judgeSeType: 0, + judgeSeVolume: 5, + judgeTiming: 0, + noteTiming: 0, + guide: 0, + invert: 0, + stealth: 0, + timelineDisplay: 0, + avatarAction: 0, + timelineModelDirection: 0, + avatarModelDirection: 0, + }; +} + +export function defaultPlayInfo(model = ''): PlayInfo { + return { + locationId: '', + modeId: -1, + styleId: 0, + folderId: 0, + musicId: 0, + // The managed response parser throws on values outside the three XRPC + // fumen names, including an empty string on a brand-new profile. + fumenType: 'BASIC', + startDate: 0, + endDate: 0, + lightPlayCount: 0, + standardPlayCount: 0, + trainingPlayCount: 0, + pcbId: '', + softcode: model, + }; +} + +export function defaultDancerGrade(): DancerGrade { + return { + grade: 0, + gvGauge: 0, + achievedGv: 0, + receivedGv: 0, + increaseGv: 0, + }; +} + +export function createProfile( + refid: string, + name: string, + dataId = '', + cardNumber = '', + model = '' +): ProfileDocument { + const createdAt = nowMs(); + const userCode = makeUserCode(refid); + return { + collection: 'profile', + schemaVersion: PROFILE_SCHEMA_VERSION, + name: name || 'PLAYER', + dataId, + cardNumber, + userCode, + dancerId: makeDancerId(userCode), + privacy: { publishSetting: 0 }, + dancerGrade: defaultDancerGrade(), + avatar: defaultAvatar(), + playOption: defaultPlayOption(), + playInfo: defaultPlayInfo(model), + unlockedMusics: [], + viewFlags: [], + musicUnlockGauges: [], + deltaGroup: [], + loginCount: 0, + createdAt, + updatedAt: createdAt, + }; +} + +export function normalizeFumen(value: string): FumenType { + if (value === 'ADVANCED' || value === 'MASTER') { + return value; + } + return 'BASIC'; +} + +export function readNumber( + reader: KDataReader, + path: string, + fallback = 0 +): number { + return reader.number(path, fallback); +} + +export function readString( + reader: KDataReader, + path: string, + fallback = '' +): string { + return reader.str(path, fallback); +} + +export function readAvatar(reader: KDataReader, prefix: string): AvatarSetting { + const p = prefix ? `${prefix}.` : ''; + return { + physique: readNumber(reader, `${p}physique`), + skinColor: readNumber(reader, `${p}skin_color`), + face: readNumber(reader, `${p}face`), + eyeColor: readNumber(reader, `${p}eye_color`), + hairstyle: readNumber(reader, `${p}hairstyle`), + hairColor: readNumber(reader, `${p}hair_color`), + costumeUpper: readNumber(reader, `${p}costume_upper`), + costumeLower: readNumber(reader, `${p}costume_lower`), + accessoryHead: readNumber(reader, `${p}accessory_head`), + accessoryFace: readNumber(reader, `${p}accessory_face`), + accessoryBody: readNumber(reader, `${p}accessory_body`), + accessoryHand: readNumber(reader, `${p}accessory_hand`), + baseChara: readNumber(reader, `${p}base_chara`, 1), + }; +} + +export function readPlayOption( + reader: KDataReader, + prefix: string +): PlayOptionSetting { + const p = prefix ? `${prefix}.` : ''; + return { + noteSpeed: readNumber(reader, `${p}note_speed`), + noteSize: readNumber(reader, `${p}note_size`), + judgeSeType: readNumber(reader, `${p}judge_se_type`), + judgeSeVolume: readNumber(reader, `${p}judge_se_vol`, 5), + judgeTiming: readNumber(reader, `${p}judge_timing`), + noteTiming: readNumber(reader, `${p}note_timing`), + guide: readNumber(reader, `${p}guide`), + invert: readNumber(reader, `${p}invert`), + stealth: readNumber(reader, `${p}stealth`), + timelineDisplay: readNumber(reader, `${p}timeline_disp`), + avatarAction: readNumber(reader, `${p}avatar_action`), + timelineModelDirection: readNumber(reader, `${p}timeline_model_dir`), + avatarModelDirection: readNumber(reader, `${p}avatar_model_dir`), + }; +} + +export function readPlayInfo( + reader: KDataReader, + prefix: string, + model = '' +): PlayInfo { + const p = prefix ? `${prefix}.` : ''; + return { + locationId: readString(reader, `${p}loc_id`), + modeId: readNumber(reader, `${p}mode_id`, -1), + styleId: readNumber(reader, `${p}style_id`), + folderId: readNumber(reader, `${p}folder_id`), + musicId: readNumber(reader, `${p}music_id`), + fumenType: normalizeFumen(readString(reader, `${p}fumen_type`, 'BASIC')), + startDate: readNumber(reader, `${p}start_date`), + endDate: readNumber(reader, `${p}end_date`), + lightPlayCount: readNumber(reader, `${p}light_play_count`), + standardPlayCount: readNumber(reader, `${p}standard_play_count`), + trainingPlayCount: readNumber(reader, `${p}training_play_count`), + pcbId: readString(reader, `${p}pcb_id`), + softcode: readString(reader, `${p}softcode`, model), + }; +} + +export function readStageResult(reader: KDataReader, prefix = ''): StageResult { + const p = prefix ? `${prefix}.` : ''; + return { + musicId: readNumber(reader, `${p}music_id`), + fumenType: normalizeFumen(readString(reader, `${p}fumen_type`, 'BASIC')), + clearStatus: readNumber(reader, `${p}clear_status`), + score: readNumber(reader, `${p}score`), + rank: readNumber(reader, `${p}rank`), + combo: readNumber(reader, `${p}combo`), + perfect: readNumber(reader, `${p}perfect`), + great: readNumber(reader, `${p}great`), + good: readNumber(reader, `${p}good`), + bad: readNumber(reader, `${p}bad`), + }; +} + +export function readMusicScore(reader: KDataReader): MusicScoreEntry { + const playResult = readStageResult(reader); + const playDate = readNumber(reader, 'play_date', nowMs()); + return { + ...playResult, + gameStartDate: readNumber(reader, 'start_date'), + mode: readNumber(reader, 'mode'), + style: readNumber(reader, 'style'), + stageNo: readNumber(reader, 'stage_no'), + playCount: readNumber(reader, 'play_cnt', 1), + playDate, + bestScoreDate: playDate, + pcbId: '', + locationId: readString(reader, 'loc_id'), + shopName: readString(reader, 'shopname'), + recordCount: readNumber(reader, 'rec_cnt'), + gvScore: 0, + dropFrame: readNumber(reader, 'drop_frame'), + dropFrameMax: readNumber(reader, 'drop_frame_max'), + dropCount: readNumber(reader, 'drop_count'), + videoKey: readString(reader, 'video_key'), + }; +} + +export function mergeScore( + existing: MusicScoreEntry | undefined, + incoming: MusicScoreEntry +): MusicScoreEntry { + if (!existing) { + return incoming; + } + + const isNewBest = incoming.score >= existing.score; + const best = isNewBest ? incoming : existing; + return { + ...best, + playCount: Math.max(existing.playCount, incoming.playCount), + playDate: Math.max(existing.playDate, incoming.playDate), + bestScoreDate: isNewBest ? incoming.playDate : existing.bestScoreDate, + clearStatus: Math.max(existing.clearStatus, incoming.clearStatus), + combo: Math.max(existing.combo, incoming.combo), + recordCount: Math.max(existing.recordCount, incoming.recordCount), + gvScore: Math.max(existing.gvScore, incoming.gvScore), + }; +} + +export function musicScoreKey( + score: Pick +): string { + return `${score.musicId}:${score.fumenType}`; +} + +export function readUnlocks( + reader: KDataReader, + path: string +): UnlockMusicEntry[] { + const result: UnlockMusicEntry[] = []; + for (const item of reader.elements(path)) { + const musicId = parseInt(item.attr().music_id || '0', 10); + if (!Number.isFinite(musicId) || musicId <= 0) { + continue; + } + const fumens = item + .elements('fumen_type') + .map(f => normalizeFumen(f.str('', 'BASIC'))); + result.push({ musicId, fumens: uniqueFumens(fumens) }); + } + return result; +} + +export function readViewFlags( + reader: KDataReader, + path: string +): ViewFlagEntry[] { + return reader.elements(path).map(item => ({ + viewId: readString(item, 'view_id'), + flag: readNumber(item, 'flag'), + })); +} + +export function readMusicUnlockGauges( + reader: KDataReader, + path: string +): MusicUnlockGaugeEntry[] { + return reader.elements(path).map(item => ({ + eventId: readString(item, 'event_id'), + progress: readNumber(item, 'progress'), + })); +} + +export function readDeltaGroup( + reader: KDataReader, + path: string +): DeltaEntry[] { + return reader.elements(path).map(item => ({ + name: readString(item, 'name'), + income: readNumber(item, 'extra_income'), + expense: readNumber(item, 'extra_expense'), + })); +} + +export function mergeUnlocks( + left: UnlockMusicEntry[], + right: UnlockMusicEntry[] +): UnlockMusicEntry[] { + const merged: { [musicId: string]: FumenType[] } = {}; + for (const entry of [...left, ...right]) { + const key = `${entry.musicId}`; + merged[key] = uniqueFumens([...(merged[key] || []), ...entry.fumens]); + } + return Object.keys(merged) + .map(key => ({ musicId: parseInt(key, 10), fumens: merged[key] })) + .sort((a, b) => a.musicId - b.musicId); +} + +function uniqueFumens(values: FumenType[]): FumenType[] { + return ['BASIC', 'ADVANCED', 'MASTER'].filter( + value => values.indexOf(value as FumenType) >= 0 + ) as FumenType[]; +} + +export function avatarResponse(avatar: AvatarSetting): any { + return { + physique: K.ITEM('s32', avatar.physique), + skin_color: K.ITEM('s32', avatar.skinColor), + face: K.ITEM('s32', avatar.face), + eye_color: K.ITEM('s32', avatar.eyeColor), + hairstyle: K.ITEM('s32', avatar.hairstyle), + hair_color: K.ITEM('s32', avatar.hairColor), + costume_upper: K.ITEM('s32', avatar.costumeUpper), + costume_lower: K.ITEM('s32', avatar.costumeLower), + accessory_head: K.ITEM('s32', avatar.accessoryHead), + accessory_face: K.ITEM('s32', avatar.accessoryFace), + accessory_body: K.ITEM('s32', avatar.accessoryBody), + accessory_hand: K.ITEM('s32', avatar.accessoryHand), + base_chara: K.ITEM('s32', avatar.baseChara), + }; +} + +export function playOptionResponse(option: PlayOptionSetting): any { + return { + note_speed: K.ITEM('s32', option.noteSpeed), + note_size: K.ITEM('s32', option.noteSize), + judge_se_type: K.ITEM('s32', option.judgeSeType), + judge_se_vol: K.ITEM('s32', option.judgeSeVolume), + judge_timing: K.ITEM('s32', option.judgeTiming), + note_timing: K.ITEM('s32', option.noteTiming), + guide: K.ITEM('s32', option.guide), + invert: K.ITEM('s32', option.invert), + stealth: K.ITEM('s32', option.stealth), + timeline_disp: K.ITEM('s32', option.timelineDisplay), + avatar_action: K.ITEM('s32', option.avatarAction), + timeline_model_dir: K.ITEM('s32', option.timelineModelDirection), + avatar_model_dir: K.ITEM('s32', option.avatarModelDirection), + }; +} + +export function dancerGradeResponse(grade: DancerGrade): any { + return { + grade: K.ITEM('s32', grade.grade), + gv_gauge: K.ITEM('s32', grade.gvGauge), + achieve_gv: K.ITEM('s32', grade.achievedGv), + get_gv: K.ITEM('s32', grade.receivedGv), + increase_gv: K.ITEM('s32', grade.increaseGv), + }; +} + +export function playInfoResponse(info: PlayInfo): any { + return { + loc_id: K.ITEM('str', info.locationId), + mode_id: K.ITEM('s32', info.modeId), + style_id: K.ITEM('s32', info.styleId), + folder_id: K.ITEM('s32', info.folderId), + music_id: K.ITEM('s32', info.musicId), + fumen_type: K.ITEM('str', normalizeFumen(info.fumenType)), + start_date: K.ITEM('s64', BigInt(info.startDate)), + end_date: K.ITEM('s64', BigInt(info.endDate)), + light_play_count: K.ITEM('s32', info.lightPlayCount), + standard_play_count: K.ITEM('s32', info.standardPlayCount), + training_play_count: K.ITEM('s32', info.trainingPlayCount), + pcb_id: K.ITEM('str', info.pcbId), + softcode: K.ITEM('str', info.softcode), + }; +} + +export function stageResultResponse(result: StageResult): any { + return { + music_id: K.ITEM('s32', result.musicId), + fumen_type: K.ITEM('str', result.fumenType), + clear_status: K.ITEM('s32', result.clearStatus), + score: K.ITEM('s32', result.score), + rank: K.ITEM('s32', result.rank), + combo: K.ITEM('s32', result.combo), + perfect: K.ITEM('s32', result.perfect), + great: K.ITEM('s32', result.great), + good: K.ITEM('s32', result.good), + bad: K.ITEM('s32', result.bad), + }; +} + +export function motionInfoResponse(entry: MotionEntry): any { + return { + motion_id: K.ITEM('str', entry.motionId), + is_active: K.ITEM('bool', entry.active), + publish_setting: K.ITEM('s32', entry.publishSetting), + play_cnt: K.ITEM('s32', entry.playCount), + total_gv: K.ITEM('s32', entry.totalGv), + ...stageResultResponse(entry.playResult), + play_date: K.ITEM('s64', BigInt(entry.playDate)), + loc_id: K.ITEM('str', entry.locationId), + shopname: K.ITEM('str', entry.shopName), + }; +}