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