Merge pull request #89 from Avimitin/dance-around-support

Add DANCE aROUND game service
This commit is contained in:
Freddie W
2026-09-06 18:46:46 +08:00
committed by GitHub
8 changed files with 1780 additions and 0 deletions

View File

@@ -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.

View File

@@ -0,0 +1,127 @@
/// <reference lib="es2020.bigint" />
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();
};

View File

@@ -0,0 +1,208 @@
/// <reference lib="es2020.bigint" />
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<ProfileDocument> {
return (
(await DB.FindOne<ProfileDocument>(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<MotionDocument>(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<MotionDocument>(
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<MotionDocument>(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<MotionDocument>(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<LocatedMotion[]> {
if (!motionIds.length) {
return [];
}
const wanted = new Set(motionIds);
const result: LocatedMotion[] = [];
const documents = await DB.Find<MotionDocument>(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 }
);
};

View File

@@ -0,0 +1,337 @@
/// <reference lib="es2020.bigint" />
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<ProfileDocument | null> {
return (
(await DB.FindOne<ProfileDocument>(refid, { collection: 'profile' })) ||
null
);
}
async function upsertProfile(
refid: string,
profile: ProfileDocument
): Promise<void> {
profile.updatedAt = nowMs();
await DB.Upsert<ProfileDocument>(
refid,
{ collection: 'profile' },
{ $set: profile }
);
}
async function effectiveUnlocks(
profile: ProfileDocument
): Promise<UnlockMusicEntry[]> {
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<ProfileDocument>(
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<void> {
if (!incoming.length) {
return;
}
const document = (await DB.FindOne<ScoreDocument>(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<ScoreDocument>(
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<ScoreDocument>(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 }
);
};

View File

@@ -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');
}

391
dancearound@asphyxia/mdb.ts Normal file
View File

@@ -0,0 +1,391 @@
/// <reference types="node" />
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<LocalMdbCatalog | null> {
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;
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,474 @@
/// <reference lib="es2020.bigint" />
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<StageResult, 'musicId' | 'fumenType'>
): 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),
};
}