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), + }; +}