mirror of
https://github.com/asphyxia-core/plugins.git
synced 2026-09-12 05:05:49 -05:00
Init and finish DEAC plugin
This commit is contained in:
13
deac@asphyxia/README.md
Normal file
13
deac@asphyxia/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# DanceEvolution ARCADE
|
||||
|
||||
Version: **1.0.0**
|
||||
|
||||
Asphyxia CORE plugin for DanceEvolution ARCADE `KDM:J:A:A:2016021900`.
|
||||
|
||||
## Features
|
||||
|
||||
- Player save data and scores
|
||||
- Play history and profile editing
|
||||
- Configurable title INFORMATION
|
||||
- Server-side song and difficulty unlock
|
||||
- Per-player EPASS number display setting
|
||||
825
deac@asphyxia/index.ts
Normal file
825
deac@asphyxia/index.ts
Normal file
@@ -0,0 +1,825 @@
|
||||
// Legacy CORE exposes these at runtime without TypeScript declarations.
|
||||
declare const Buffer: any;
|
||||
declare const BigInt: any;
|
||||
declare const require: any;
|
||||
declare const process: any;
|
||||
|
||||
const Fs = require('fs');
|
||||
const Path = require('path');
|
||||
|
||||
interface StoredRecord {
|
||||
raw: string;
|
||||
bin: string;
|
||||
}
|
||||
|
||||
interface PlayerData {
|
||||
collection: 'data';
|
||||
records?: StoredRecord[];
|
||||
// Compatibility with saves created by v1.0.0.
|
||||
str?: string[];
|
||||
bin?: string[];
|
||||
}
|
||||
|
||||
interface PlayerProfile {
|
||||
collection: 'profile';
|
||||
name?: string;
|
||||
showEpassNumber?: boolean;
|
||||
}
|
||||
|
||||
interface CoreCard {
|
||||
__refid?: string;
|
||||
cid?: string;
|
||||
print?: string;
|
||||
_id?: string;
|
||||
}
|
||||
|
||||
interface LampHistory {
|
||||
songId: number;
|
||||
chart: string;
|
||||
score: number;
|
||||
grade: string;
|
||||
combo: number;
|
||||
fullCombo: boolean;
|
||||
playedAt?: number;
|
||||
}
|
||||
|
||||
interface BestScore {
|
||||
collection: 'score';
|
||||
songId: number;
|
||||
song: string;
|
||||
chart: string;
|
||||
score: number;
|
||||
grade: string;
|
||||
combo: number;
|
||||
fullCombo: boolean;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
const RESULT_OK = () => ({ result: K.ITEM('s32', 0) });
|
||||
const MASTER_DATA_UNLOCKED = ['2011081000', ...Array(47).fill('1')].join(':');
|
||||
const MASTER_DATA_LOCKED = ['2011081000', ...Array(47).fill('0')].join(':');
|
||||
const CHART_NAMES = ['LIGHT', 'STANDARD', 'EXTREME', 'STEALTH', 'MASTER'];
|
||||
const GRADE_NAMES = ['FAILED', 'E', 'D', 'C', 'B', 'A', 'AA', 'AAA'];
|
||||
const MAX_VALID_SCORE = 10000000;
|
||||
const SONG_UNLOCK_FIELD = 17;
|
||||
const EVENT_DIFFICULTY_FIELD = 27;
|
||||
const EVENT_DIFFICULTY_SONG_COUNT = 47; // Song IDs 79 through 125.
|
||||
const ALL_SONGS_OPEN_MASK = '7fffffffffffffff'; // KDM uses 63 bits per bank.
|
||||
|
||||
// Extracted from this build's data/arc/resource_lists.arc. Array index = song ID.
|
||||
const SONG_TITLES = [
|
||||
"INTO YOUR HEART (Ruffage remix)", "A Geisha's Dream", 'La receta',
|
||||
'L’amour et la liberté (Darwin & DJ Silver remix)', 'HYSTERIA 2001',
|
||||
"Keep on movin'", "CAN'T STOP FALLIN' IN LOVE -super euro version-",
|
||||
'exotic ethnic', '桜', 'Secret Rendez-vous', 'MY SUMMER LOVE',
|
||||
'Crazy Control', 'Every Day, Every Night (NM STYLE)', 'Open Your Eyes',
|
||||
'We Can Win the Fight', 'KIMONO♥PRINCESS', 'HIGHER', "Lover's High",
|
||||
'Mess With My Emotions', 'Unity', "BURNIN' THE FLOOR",
|
||||
'My Only Shining Star', 'STILL IN MY HEART', 'SUPER SAMURAI',
|
||||
'Let the beat hit em! (CLASSIC R&B STYLE)', 'Brilliant 2U',
|
||||
'AFRONOVA PRIMEVAL', 'BABY BABY GIMME YOUR LOVE', 'ヘビーローテーション',
|
||||
'ヘビーローテーション (かんたん振り付け)', 'irony',
|
||||
'irony (かんたん振り付け)', 'チュートリアル', '恋愛レボリューション21',
|
||||
'恋愛レボリューション21 (かんたん振り付け)', 'マル・マル・モリ・モリ!',
|
||||
'Brave', 'She is my wife', '女々しくて', 'PONPONPON',
|
||||
'ルカルカ★ナイトフィーバー', 'マジLOVE1000%',
|
||||
'行くぜっ!怪盗少女 -Zver.-', '外周チュートリアル', 'MERRY GO ROUND',
|
||||
'Follow Tomorrow', 'NIGHT OF FIRE', '華爛漫 -Flowers- (2nd EDITION)',
|
||||
'前略、道の上より', 'LOVE&JOY', 'なめこのうた', 'FLOWER',
|
||||
'Mickey (Hawaii version)', '恋愛サーキュレーション', 'Daisuke',
|
||||
'Do The Evolution', 'Wow Wow VENUS', 'おどるポンポコリン',
|
||||
'サイバーサンダーサイダー', 'Sweetiex2', 'Colorful World',
|
||||
'Gravity=Reality', 'メグメグ☆ファイアーエンドレスナイト',
|
||||
'恋するフォーチュンクッキー', 'マジLOVE2000%', '未確認中学生X',
|
||||
'Chu☆Chu☆Tonight', 'インベーダーインベーダー', '朧', 'LOVEマシーン',
|
||||
'ちくわパフェだよ☆CKP', 'Sweet Rain', '回レ!雪月花',
|
||||
'大藝術家 -The Great Artist-', '全城熱愛 -Feel the Love-',
|
||||
'FUJIMORI -祭- FESTIVAL', 'Mermaid girl', '心のプラカード',
|
||||
'ふな ふな ふなっしー♪ ~ふなっしー公式テーマソング~',
|
||||
'Watch Out Pt.2', '凛として咲く花の如く', '轟け!恋のビーンボール!!',
|
||||
'轟け!恋のビーンボール!! (9回裏振付ver)', 'Sakura Sunrise',
|
||||
'Little Star', '罪と罰', 'Reaching for the Stars', 'LUV CAN SAVE U',
|
||||
'チョコレートスマイル', 'TA・DA ☆ YO・SHI', 'ハッピーシンセサイザ',
|
||||
'アルストロメリア (walk with you remix)', 'Kind Lady', 'Gimme a Big Beat',
|
||||
'YESTERDAY', 'Din Don Dan', "I'm so Happy", '無双',
|
||||
];
|
||||
|
||||
function getRefId(data: any): string {
|
||||
const request = $(data).element('data');
|
||||
return request.str('eaid', request.str('refid', ''));
|
||||
}
|
||||
|
||||
function splitCsv(raw: any): any[] {
|
||||
const fields: any[] = [];
|
||||
let start = 0;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
if (raw[i] === 0x2c) {
|
||||
fields.push(raw.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
fields.push(raw.slice(start));
|
||||
return fields;
|
||||
}
|
||||
|
||||
function joinCsv(fields: any[]): any {
|
||||
const chunks: any[] = [];
|
||||
const comma = Buffer.from(',', 'ascii');
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
if (i > 0) chunks.push(comma);
|
||||
chunks.push(fields[i]);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function recordType(record: StoredRecord): string {
|
||||
try {
|
||||
const fields = splitCsv(Buffer.from(record.raw, 'base64'));
|
||||
return fields.length > 1 ? fields[1].toString('ascii') : '';
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function blankStoredRecord(type: string): StoredRecord {
|
||||
const fields: any[] = [];
|
||||
fields.push(Buffer.from('ffffffff', 'ascii'));
|
||||
fields.push(Buffer.from(type, 'ascii'));
|
||||
// KDM's common player-data record consists of 17 hexadecimal integers,
|
||||
// eight floats and eight strings after the two transport fields.
|
||||
for (let i = 0; i < 17; i++) fields.push(Buffer.from(i === 0 ? '1' : '0', 'ascii'));
|
||||
for (let i = 0; i < 8; i++) fields.push(Buffer.from('0.000000', 'ascii'));
|
||||
for (let i = 0; i < 8; i++) fields.push(Buffer.alloc(0));
|
||||
return { raw: joinCsv(fields).toString('base64'), bin: '' };
|
||||
}
|
||||
|
||||
function unlockStoredRecord(record: StoredRecord, type: string): StoredRecord {
|
||||
if (type !== 'DATA02' && type !== 'DATA12') return record;
|
||||
const fields = splitCsv(Buffer.from(record.raw, 'base64'));
|
||||
if (fields.length < 35) return record;
|
||||
|
||||
// IDA: sub_100BE370 reads a 63-bit song-open bank from DATA02/DATA12.
|
||||
// In the wire CSV this is integer field 17 (hexadecimal, without 0x).
|
||||
fields[SONG_UNLOCK_FIELD] = Buffer.from(ALL_SONGS_OPEN_MASK, 'ascii');
|
||||
|
||||
if (type === 'DATA12') {
|
||||
// IDA: sub_100BE7D0 indexes this string by songId - 79. A character
|
||||
// from '0' through '4' is the highest selectable difficulty.
|
||||
const oldLevels = fields[EVENT_DIFFICULTY_FIELD] || Buffer.alloc(0);
|
||||
const levels = Buffer.alloc(Math.max(oldLevels.length, EVENT_DIFFICULTY_SONG_COUNT));
|
||||
oldLevels.copy(levels);
|
||||
levels.fill(0x34, 0, EVENT_DIFFICULTY_SONG_COUNT); // ASCII '4'
|
||||
fields[EVENT_DIFFICULTY_FIELD] = levels;
|
||||
}
|
||||
|
||||
return { raw: joinCsv(fields).toString('base64'), bin: record.bin };
|
||||
}
|
||||
|
||||
function configBoolean(name: string, fallback: boolean): boolean {
|
||||
const value: any = U.GetConfig(name);
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (value === false || value === 0 || String(value).toLowerCase() === 'false') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normaliseCardKey(value: any): string {
|
||||
return String(value || '').replace(/\s/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function findCoreCard(cardId: string): CoreCard | null {
|
||||
const candidates: string[] = [];
|
||||
for (const part of String(cardId || '').split('|')) {
|
||||
const key = normaliseCardKey(part);
|
||||
if (key) candidates.push(key);
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
|
||||
const corePaths = [
|
||||
Path.join(Path.dirname(process.execPath), 'savedata', 'core.db'),
|
||||
Path.join(process.cwd(), 'savedata', 'core.db'),
|
||||
];
|
||||
for (const corePath of corePaths) {
|
||||
try {
|
||||
if (!Fs.existsSync(corePath)) continue;
|
||||
const documents: { [id: string]: any } = {};
|
||||
const lines = String(Fs.readFileSync(corePath, 'utf8')).split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
let document: any;
|
||||
try { document = JSON.parse(line); }
|
||||
catch (err) { continue; }
|
||||
const id = String(document._id || '');
|
||||
if (!id) continue;
|
||||
if (document.$$deleted) delete documents[id];
|
||||
else documents[id] = document;
|
||||
}
|
||||
for (const id of Object.keys(documents)) {
|
||||
const card = documents[id];
|
||||
if (card.__s !== 'card') continue;
|
||||
const keys = [
|
||||
normaliseCardKey(card.__refid),
|
||||
normaliseCardKey(card.cid),
|
||||
normaliseCardKey(card.print),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (keys.indexOf(candidate) >= 0) return card as CoreCard;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[deac] Unable to read CORE card database: ${err}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isScoreRecord(type: string): boolean {
|
||||
return /^DATA(0[1-5]|1[1-5])$/.test(type);
|
||||
}
|
||||
|
||||
function scoreRecordType(songId: number, chart: string): string {
|
||||
const chartIndex = CHART_NAMES.indexOf(chart);
|
||||
if (chartIndex < 0 || songId < 0) return '';
|
||||
const number = (songId < 63 ? 1 : 11) + chartIndex;
|
||||
return `DATA${number < 10 ? '0' : ''}${number}`;
|
||||
}
|
||||
|
||||
function scoreRecordOffset(songId: number): number {
|
||||
return (songId < 63 ? songId : songId - 63) * 8;
|
||||
}
|
||||
|
||||
function mergeScoreBins(oldEncoded: string, newEncoded: string): string {
|
||||
if (!oldEncoded) return newEncoded || '';
|
||||
if (!newEncoded) return oldEncoded;
|
||||
const oldData = Buffer.from(oldEncoded, 'base64');
|
||||
const newData = Buffer.from(newEncoded, 'base64');
|
||||
const result = Buffer.alloc(Math.max(oldData.length, newData.length));
|
||||
oldData.copy(result);
|
||||
|
||||
for (let offset = 0; offset < newData.length; offset += 8) {
|
||||
const length = Math.min(8, newData.length - offset);
|
||||
if (length < 8) {
|
||||
newData.copy(result, offset, offset, offset + length);
|
||||
continue;
|
||||
}
|
||||
const oldPlayed = offset + 5 < oldData.length && oldData[offset + 5] !== 0;
|
||||
const newPlayed = newData[offset + 5] !== 0;
|
||||
if (!oldPlayed) {
|
||||
newData.copy(result, offset, offset, offset + 8);
|
||||
continue;
|
||||
}
|
||||
if (!newPlayed) continue;
|
||||
|
||||
const oldScore = oldData.readUInt32LE(offset);
|
||||
const newScore = newData.readUInt32LE(offset);
|
||||
const oldValid = oldScore > 0 && oldScore <= MAX_VALID_SCORE;
|
||||
const newValid = newScore > 0 && newScore <= MAX_VALID_SCORE;
|
||||
const preferred = (!oldValid && newValid) ||
|
||||
(oldValid === newValid && newScore > oldScore) ? newData : oldData;
|
||||
preferred.copy(result, offset, offset, offset + 8);
|
||||
if (oldValid !== newValid) continue;
|
||||
if (oldValid || newValid) {
|
||||
result.writeUInt32LE(oldValid && newValid
|
||||
? Math.max(oldScore, newScore)
|
||||
: (oldValid ? oldScore : newScore), offset);
|
||||
}
|
||||
result[offset + 4] = Math.max(oldData[offset + 4], newData[offset + 4]);
|
||||
result[offset + 5] = Math.max(oldData[offset + 5], newData[offset + 5]);
|
||||
const oldStats = oldData[offset + 7];
|
||||
const newStats = newData[offset + 7];
|
||||
const grade = Math.max((oldStats >>> 1) & 7, (newStats >>> 1) & 7);
|
||||
result[offset + 7] = ((oldStats | newStats) & 0xe1) |
|
||||
((oldStats | newStats) & 0x10) | (grade << 1);
|
||||
}
|
||||
return result.toString('base64');
|
||||
}
|
||||
|
||||
function mergeStoredRecord(oldRecord: StoredRecord, incoming: StoredRecord): StoredRecord {
|
||||
const type = recordType(incoming).toUpperCase();
|
||||
if (!isScoreRecord(type)) return incoming;
|
||||
return { raw: incoming.raw, bin: mergeScoreBins(oldRecord.bin, incoming.bin) };
|
||||
}
|
||||
|
||||
function makeScoreRecord(records: StoredRecord[], type: string): StoredRecord {
|
||||
for (const template of records) {
|
||||
if (!isScoreRecord(recordType(template).toUpperCase())) continue;
|
||||
const fields = splitCsv(Buffer.from(template.raw, 'base64'));
|
||||
if (fields.length > 1) {
|
||||
fields[1] = Buffer.from(type, 'ascii');
|
||||
return { raw: joinCsv(fields).toString('base64'), bin: '' };
|
||||
}
|
||||
}
|
||||
return {
|
||||
raw: Buffer.from(`ffffffff,${type},`, 'ascii').toString('base64'),
|
||||
bin: '',
|
||||
};
|
||||
}
|
||||
|
||||
function historyMatchCount(type: string, encoded: string, histories: LampHistory[]): number {
|
||||
if (!encoded) return 0;
|
||||
const data = Buffer.from(encoded, 'base64');
|
||||
let matches = 0;
|
||||
for (const history of histories) {
|
||||
if (scoreRecordType(history.songId, history.chart) !== type) continue;
|
||||
const offset = scoreRecordOffset(history.songId);
|
||||
if (offset + 8 > data.length || data[offset + 5] === 0) continue;
|
||||
if (data.readUInt32LE(offset) === Number(history.score)) matches++;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
async function restoreLampsFromHistory(refid: string, records: StoredRecord[]): Promise<boolean> {
|
||||
const histories = await DB.Find<LampHistory>(refid, { collection: 'play_history' });
|
||||
let changed = false;
|
||||
|
||||
// A score is never remotely close to a 32-bit Unix timestamp. Old versions
|
||||
// could slide profile bytes into a score slot, producing values such as
|
||||
// 1475432616. Remove those eight-byte blocks before deriving lamps/scores.
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const type = recordType(records[i]).toUpperCase();
|
||||
if (!isScoreRecord(type) || !records[i].bin) continue;
|
||||
const scoreData = Buffer.from(records[i].bin, 'base64');
|
||||
let recordChanged = false;
|
||||
for (let offset = 0; offset + 8 <= scoreData.length; offset += 8) {
|
||||
const score = scoreData.readUInt32LE(offset);
|
||||
if (score > MAX_VALID_SCORE) {
|
||||
scoreData.fill(0, offset, offset + 8);
|
||||
recordChanged = true;
|
||||
}
|
||||
}
|
||||
if (recordChanged) {
|
||||
records[i] = { raw: records[i].raw, bin: scoreData.toString('base64') };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Versions before 1.1.0 omitted <NODATA> response slots. That could copy one
|
||||
// physical difficulty blob into another. Exact non-empty duplicates cannot
|
||||
// be legitimate; retain the interpretation supported by actual play history.
|
||||
for (let left = 0; left < records.length; left++) {
|
||||
const leftType = recordType(records[left]).toUpperCase();
|
||||
if (!isScoreRecord(leftType) || !records[left].bin) continue;
|
||||
for (let right = left + 1; right < records.length; right++) {
|
||||
const rightType = recordType(records[right]).toUpperCase();
|
||||
if (!isScoreRecord(rightType) || leftType === rightType) continue;
|
||||
if (!records[right].bin || records[left].bin !== records[right].bin) continue;
|
||||
const leftMatches = historyMatchCount(leftType, records[left].bin, histories);
|
||||
const rightMatches = historyMatchCount(rightType, records[right].bin, histories);
|
||||
if (leftMatches > rightMatches) {
|
||||
records[right] = { raw: records[right].raw, bin: '' };
|
||||
changed = true;
|
||||
} else if (rightMatches > leftMatches) {
|
||||
records[left] = { raw: records[left].raw, bin: '' };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const positions: { [type: string]: number } = {};
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const type = recordType(records[i]).toUpperCase();
|
||||
if (type) positions[type] = i;
|
||||
}
|
||||
for (const history of histories) {
|
||||
const songId = Number(history.songId);
|
||||
const score = Number(history.score);
|
||||
const type = scoreRecordType(songId, String(history.chart || '').toUpperCase());
|
||||
if (!type || score <= 0 || score > MAX_VALID_SCORE) continue;
|
||||
let position = positions[type];
|
||||
if (position === undefined) {
|
||||
position = records.length;
|
||||
positions[type] = position;
|
||||
records.push(makeScoreRecord(records, type));
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const offset = scoreRecordOffset(songId);
|
||||
const patch = Buffer.alloc(offset + 8);
|
||||
patch.writeUInt32LE(score, offset);
|
||||
patch[offset + 4] = Number(history.combo || 0) & 0xff;
|
||||
patch[offset + 5] = 4;
|
||||
const grade = Math.max(0, GRADE_NAMES.indexOf(String(history.grade || '').toUpperCase()));
|
||||
patch[offset + 7] = (grade << 1) | (history.fullCombo ? 0x10 : 0);
|
||||
const merged = mergeScoreBins(records[position].bin, patch.toString('base64'));
|
||||
if (merged !== records[position].bin) {
|
||||
records[position] = { raw: records[position].raw, bin: merged };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function normaliseRecords(playerData: PlayerData | null): StoredRecord[] {
|
||||
if (!playerData) return [];
|
||||
if (playerData.records && Array.isArray(playerData.records)) return playerData.records;
|
||||
|
||||
const records: StoredRecord[] = [];
|
||||
const legacyStr = playerData.str || [];
|
||||
const legacyBin = playerData.bin || [];
|
||||
for (let i = 0; i < legacyStr.length; i++) {
|
||||
records.push({
|
||||
// Old text may already be damaged, but this keeps v1.0.0 saves loadable.
|
||||
raw: Buffer.from(legacyStr[i], 'utf-8').toString('base64'),
|
||||
bin: legacyBin[i] || '',
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function songTitle(songId: number): string {
|
||||
return SONG_TITLES[songId] || `歌曲 #${songId}`;
|
||||
}
|
||||
|
||||
function decodeProfileName(records: StoredRecord[]): string | null {
|
||||
for (const record of records) {
|
||||
if (recordType(record) !== 'DATA01') continue;
|
||||
try {
|
||||
const fields = splitCsv(Buffer.from(record.raw, 'base64'));
|
||||
// Two transport fields precede DATA01's payload; name offset is 25.
|
||||
if (fields.length <= 27) return null;
|
||||
return String(U.DecodeString(fields[27], 'shift_jis')).replace(/\0/g, '').trim();
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function storePlayHistory(refid: string, record: StoredRecord) {
|
||||
if (recordType(record) !== 'RDAT01' || !record.bin) return;
|
||||
const history = Buffer.from(record.bin, 'base64');
|
||||
|
||||
// The first three 32-byte chunks are the attempts from this credit.
|
||||
for (let offset = 0; offset < Math.min(history.length, 96); offset += 32) {
|
||||
if (offset + 16 > history.length) break;
|
||||
const score = history.readUInt32LE(offset);
|
||||
const params = history.readUInt32LE(offset + 4);
|
||||
const lowTime = history.readUInt32LE(offset + 8);
|
||||
const highTime = history.readUInt32LE(offset + 12);
|
||||
const playedAt = lowTime + highTime * 0x100000000;
|
||||
if (!score && !params && !playedAt) continue;
|
||||
|
||||
const songId = params & 0xff;
|
||||
const chartIndex = (params >>> 8) & 0x0f;
|
||||
if (chartIndex >= CHART_NAMES.length) continue;
|
||||
const grade = (params >>> 27) & 0x07;
|
||||
const attempt = {
|
||||
collection: 'play_history',
|
||||
songId,
|
||||
song: songTitle(songId),
|
||||
chart: CHART_NAMES[chartIndex],
|
||||
score,
|
||||
grade: GRADE_NAMES[grade],
|
||||
combo: (params >>> 12) & 0x03ff,
|
||||
fullCombo: ((params >>> 30) & 0x03) !== 0,
|
||||
playedAt,
|
||||
};
|
||||
await DB.Upsert(
|
||||
refid,
|
||||
{ collection: 'play_history', songId, chart: attempt.chart, score, playedAt },
|
||||
attempt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function matchingHistory(
|
||||
histories: LampHistory[], songId: number, chart: string, score: number
|
||||
): LampHistory | null {
|
||||
let best: LampHistory | null = null;
|
||||
for (const history of histories) {
|
||||
if (Number(history.songId) !== songId ||
|
||||
String(history.chart || '').toUpperCase() !== chart ||
|
||||
Number(history.score) !== score) continue;
|
||||
const historyGrade = GRADE_NAMES.indexOf(String(history.grade || '').toUpperCase());
|
||||
const bestGrade = best
|
||||
? GRADE_NAMES.indexOf(String(best.grade || '').toUpperCase()) : -1;
|
||||
if (!best ||
|
||||
(!!history.fullCombo !== !!best.fullCombo
|
||||
? !!history.fullCombo
|
||||
: Number(history.combo || 0) !== Number(best.combo || 0)
|
||||
? Number(history.combo || 0) > Number(best.combo || 0)
|
||||
: historyGrade !== bestGrade
|
||||
? historyGrade > bestGrade
|
||||
: Number(history.playedAt || 0) > Number(best.playedAt || 0))) {
|
||||
best = history;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function expandStoredCombo(lowByte: number, fullCombo: boolean): number {
|
||||
// DATAxx only persists the low eight bits. A full combo of 256 is stored as
|
||||
// 0 and 258 as 2. RDAT is authoritative when present; this conservative
|
||||
// fallback repairs wrapped FC values after RDAT has rotated out.
|
||||
return fullCombo && lowByte < 64 ? lowByte + 256 : lowByte;
|
||||
}
|
||||
|
||||
async function storeBestScores(
|
||||
refid: string, record: StoredRecord, histories: LampHistory[]
|
||||
) {
|
||||
if (!record.bin) return;
|
||||
const type = recordType(record);
|
||||
const match = /^DATA(0[1-5]|1[1-5])$/.exec(type);
|
||||
if (!match) return;
|
||||
|
||||
const number = parseInt(match[1], 10);
|
||||
const chartIndex = number >= 11 ? number - 11 : number - 1;
|
||||
const firstSong = number >= 11 ? 63 : 0;
|
||||
const data = Buffer.from(record.bin, 'base64');
|
||||
for (let offset = 0; offset + 8 <= data.length; offset += 8) {
|
||||
const playMarker = data[offset + 5];
|
||||
if (!playMarker) continue;
|
||||
const songId = firstSong + offset / 8;
|
||||
const score = data.readUInt32LE(offset);
|
||||
if (score <= 0 || score > MAX_VALID_SCORE) continue;
|
||||
const chart = CHART_NAMES[chartIndex];
|
||||
const stats = data[offset + 7];
|
||||
const storedFullCombo = (stats & 0x10) !== 0;
|
||||
const history = matchingHistory(histories, songId, chart, score);
|
||||
const best: BestScore = {
|
||||
collection: 'score',
|
||||
songId,
|
||||
song: songTitle(songId),
|
||||
chart,
|
||||
score,
|
||||
grade: history ? String(history.grade) : GRADE_NAMES[(stats >>> 1) & 0x07],
|
||||
combo: history ? Number(history.combo) : expandStoredCombo(data[offset + 4], storedFullCombo),
|
||||
fullCombo: history ? !!history.fullCombo : storedFullCombo,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await DB.Upsert(refid, { collection: 'score', songId, chart: best.chart }, best);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateDerivedData(refid: string, records: StoredRecord[]) {
|
||||
const name = decodeProfileName(records);
|
||||
if (name !== null) {
|
||||
const profile = await DB.FindOne<PlayerProfile>(refid, { collection: 'profile' }) ||
|
||||
{ collection: 'profile' } as PlayerProfile;
|
||||
profile.name = name;
|
||||
await DB.Upsert(refid, { collection: 'profile' }, profile);
|
||||
}
|
||||
// RDAT has the complete 10-bit combo while DATAxx keeps only its low byte.
|
||||
// Store every history entry first so score derivation can always join it.
|
||||
for (const record of records) await storePlayHistory(refid, record);
|
||||
const histories = await DB.Find<LampHistory>(refid, { collection: 'play_history' });
|
||||
|
||||
// Scores are a derived WebUI index. Rebuilding prevents stale entries from
|
||||
// an old, shifted difficulty slot from surviving after the raw save is fixed.
|
||||
await DB.Remove<BestScore>(refid, { collection: 'score' });
|
||||
for (const record of records) await storeBestScores(refid, record, histories);
|
||||
}
|
||||
|
||||
async function migrateDerivedData() {
|
||||
const players = await DB.Find<any>(null, { collection: 'data' });
|
||||
for (const player of players) {
|
||||
const refid = String(player.__refid || '');
|
||||
if (!refid) continue;
|
||||
const records = normaliseRecords(player as PlayerData);
|
||||
if (await restoreLampsFromHistory(refid, records)) {
|
||||
await DB.Upsert(refid, { collection: 'data' }, { collection: 'data', records });
|
||||
}
|
||||
await updateDerivedData(refid, records);
|
||||
}
|
||||
}
|
||||
|
||||
const savePlayerData: EPR = async (info, data, send) => {
|
||||
const refid = getRefId(data);
|
||||
if (!refid) return send.deny();
|
||||
|
||||
const existing = await DB.FindOne<PlayerData>(refid, { collection: 'data' });
|
||||
const records = normaliseRecords(existing);
|
||||
const positions: { [type: string]: number } = {};
|
||||
for (let i = 0; i < records.length; i++) positions[recordType(records[i])] = i;
|
||||
|
||||
for (const item of $(data).elements('data.record.d')) {
|
||||
const encoded = item.str('', '');
|
||||
if (!encoded) continue;
|
||||
// Store KDM's exact Base64. Decoding the CP932 CSV through UTF-8 corrupts
|
||||
// the player name when the record is returned on the next credit.
|
||||
const incoming: StoredRecord = { raw: encoded, bin: item.str('bin1', '') };
|
||||
const type = recordType(incoming);
|
||||
if (type && positions[type] !== undefined) {
|
||||
const position = positions[type];
|
||||
records[position] = mergeStoredRecord(records[position], incoming);
|
||||
}
|
||||
else {
|
||||
if (type) positions[type] = records.length;
|
||||
records.push(incoming);
|
||||
}
|
||||
}
|
||||
|
||||
await DB.Upsert(refid, { collection: 'data' }, { collection: 'data', records });
|
||||
await updateDerivedData(refid, records);
|
||||
return send.object(RESULT_OK());
|
||||
};
|
||||
|
||||
const loadPlayerData: EPR = async (info, data, send) => {
|
||||
const refid = getRefId(data);
|
||||
if (!refid) return send.deny();
|
||||
const playerData = await DB.FindOne<PlayerData>(refid, { collection: 'data' });
|
||||
const stored = normaliseRecords(playerData);
|
||||
const records: any[] = [];
|
||||
|
||||
if (await restoreLampsFromHistory(refid, stored)) {
|
||||
await DB.Upsert(refid, { collection: 'data' }, { collection: 'data', records: stored });
|
||||
}
|
||||
|
||||
// KDM identifies received profile blobs by their array position, not by a
|
||||
// type stored inside each returned blob. Always mirror recv_csv exactly and
|
||||
// emit <NODATA> placeholders for missing types, otherwise later difficulty
|
||||
// records slide into earlier slots (for example DATA14 -> DATA11).
|
||||
const recvCsv = $(data).element('data').str('recv_csv', '');
|
||||
const requestedTypes: string[] = [];
|
||||
const recvFields = recvCsv.split(',');
|
||||
for (let i = 0; i < recvFields.length; i += 2) {
|
||||
const type = String(recvFields[i] || '').toUpperCase();
|
||||
if (type) requestedTypes.push(type);
|
||||
}
|
||||
|
||||
const storedByType: { [type: string]: StoredRecord } = {};
|
||||
for (const saved of stored) {
|
||||
const type = recordType(saved).toUpperCase();
|
||||
if (type) storedByType[type] = saved;
|
||||
}
|
||||
|
||||
const responseTypes = requestedTypes.length
|
||||
? requestedTypes
|
||||
: Object.keys(storedByType);
|
||||
const unlockAll = configBoolean('unlock_all_songs', true);
|
||||
for (const type of responseTypes) {
|
||||
let saved = storedByType[type];
|
||||
// Synthesize a missing unlock bank for new/partial profiles. This is only
|
||||
// a response copy: the player's real progression and score blobs remain
|
||||
// untouched in the database.
|
||||
if (!saved && unlockAll && (type === 'DATA02' || type === 'DATA12')) {
|
||||
saved = blankStoredRecord(type);
|
||||
}
|
||||
if (!saved) {
|
||||
records.push(K.ITEM('str', '<NODATA>'));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const responseRecord = unlockAll ? unlockStoredRecord(saved, type) : saved;
|
||||
const raw = Buffer.from(responseRecord.raw, 'base64');
|
||||
const first = raw.indexOf(0x2c);
|
||||
const second = first < 0 ? -1 : raw.indexOf(0x2c, first + 1);
|
||||
if (second < 0) {
|
||||
records.push(K.ITEM('str', '<NODATA>'));
|
||||
continue;
|
||||
}
|
||||
// Strip only the two ASCII transport fields; preserve all CP932 bytes.
|
||||
const record: any = K.ITEM('str', raw.slice(second + 1).toString('base64'));
|
||||
if (responseRecord.bin) record.bin1 = K.ITEM('str', responseRecord.bin);
|
||||
records.push(record);
|
||||
} catch (err) {
|
||||
console.warn('[deac] Ignoring an invalid stored player-data record.');
|
||||
records.push(K.ITEM('str', '<NODATA>'));
|
||||
}
|
||||
}
|
||||
|
||||
// Lazily builds the WebUI views for saves created before this version.
|
||||
await updateDerivedData(refid, stored);
|
||||
return send.object({
|
||||
result: K.ITEM('s32', 0),
|
||||
player: { record_num: K.ITEM('u32', records.length), record: { d: records } },
|
||||
});
|
||||
};
|
||||
|
||||
async function updatePlayerName(refid: string, requestedName: string) {
|
||||
const playerData = await DB.FindOne<PlayerData>(refid, { collection: 'data' });
|
||||
if (!playerData) return;
|
||||
const records = normaliseRecords(playerData);
|
||||
const name = requestedName.replace(/[\r\n\0]/g, '').slice(0, 10);
|
||||
if (!name) return;
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
if (recordType(records[i]) !== 'DATA01') continue;
|
||||
const fields = splitCsv(Buffer.from(records[i].raw, 'base64'));
|
||||
if (fields.length <= 27) return;
|
||||
fields[27] = U.EncodeString(name, 'shift_jis');
|
||||
records[i] = { raw: joinCsv(fields).toString('base64'), bin: records[i].bin };
|
||||
await DB.Upsert(refid, { collection: 'data' }, { collection: 'data', records });
|
||||
const profile = await DB.FindOne<PlayerProfile>(refid, { collection: 'profile' }) ||
|
||||
{ collection: 'profile' } as PlayerProfile;
|
||||
profile.name = name;
|
||||
await DB.Upsert(refid, { collection: 'profile' }, profile);
|
||||
// Name editing is also a convenient migration point for v1.0.0 saves.
|
||||
await updateDerivedData(refid, records);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEpassDisplay(refid: string, requestedValue: any) {
|
||||
if (!refid) return;
|
||||
const profile = await DB.FindOne<PlayerProfile>(refid, { collection: 'profile' }) ||
|
||||
{ collection: 'profile' } as PlayerProfile;
|
||||
profile.showEpassNumber = !(requestedValue === false || requestedValue === 0 ||
|
||||
String(requestedValue).toLowerCase() === 'false' || String(requestedValue) === '0');
|
||||
await DB.Upsert(refid, { collection: 'profile' }, profile);
|
||||
}
|
||||
|
||||
const convertCardNumber: EPR = async (info, data, send) => {
|
||||
const cardId = $(data).str('data.card_id', '');
|
||||
const parts = cardId.split('|');
|
||||
// For KDM, CORE passes the resolved RefID as card_id. Resolve that RefID
|
||||
// through savedata/core.db so the game receives the human-facing print
|
||||
// number and the per-profile visibility setting is read from the right card.
|
||||
const coreCard = findCoreCard(cardId);
|
||||
const refid = coreCard && coreCard.__refid
|
||||
? String(coreCard.__refid)
|
||||
: (parts.length > 1 ? parts[parts.length - 1] : parts[0]);
|
||||
const profile = refid
|
||||
? await DB.FindOne<PlayerProfile>(refid, { collection: 'profile' })
|
||||
: null;
|
||||
const showNumber = !profile || profile.showEpassNumber !== false;
|
||||
const printNumber = coreCard && coreCard.print ? String(coreCard.print) : parts[0];
|
||||
return send.object({
|
||||
result: K.ITEM('s32', 0),
|
||||
// KDM groups a 16-character card number into blocks of four on screen.
|
||||
data: { card_number: K.ITEM('str', showNumber ? printNumber : '****************') },
|
||||
});
|
||||
};
|
||||
|
||||
export function register() {
|
||||
R.GameCode('KDM');
|
||||
R.Config('information_text', {
|
||||
type: 'string',
|
||||
default: 'Thank you for your playing.',
|
||||
name: 'Title INFORMATION text',
|
||||
desc: 'Title-screen message. Use Shift-JIS characters; blank hides the message.',
|
||||
});
|
||||
R.Config('unlock_all_songs', {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
name: 'Unlock all songs and difficulties',
|
||||
desc: 'Server-side switch for opening every song and chart difficulty.',
|
||||
});
|
||||
|
||||
R.WebUIEvent('updateDeacName', async (data: any) => {
|
||||
try {
|
||||
await updatePlayerName(String(data.refid || ''), String(data.name || ''));
|
||||
} catch (err) {
|
||||
console.warn(`[deac] Unable to update player name: ${err}`);
|
||||
}
|
||||
});
|
||||
|
||||
R.WebUIEvent('updateDeacEpassDisplay', async (data: any) => {
|
||||
try {
|
||||
await updateEpassDisplay(String(data.refid || ''), data.showEpassNumber);
|
||||
} catch (err) {
|
||||
console.warn(`[deac] Unable to update EPASS display setting: ${err}`);
|
||||
}
|
||||
});
|
||||
|
||||
R.Route('eventlog.write', async (info, data, send) => send.object({
|
||||
gamesession: K.ITEM('s64', BigInt(1)), logsendflg: K.ITEM('s32', 0),
|
||||
logerrlevel: K.ITEM('s32', 0), evtidnosendflg: K.ITEM('s32', 0),
|
||||
}));
|
||||
|
||||
R.Route('system.getmaster', async (info, data, send) => {
|
||||
const information = String(U.GetConfig('information_text') || '');
|
||||
const unlockAll = configBoolean('unlock_all_songs', true);
|
||||
const masterData = unlockAll ? MASTER_DATA_UNLOCKED : MASTER_DATA_LOCKED;
|
||||
// KDM stores strdata2 in the second half of its EVENTMSG record. The title
|
||||
// parser reads the visible message after the second comma, so two header
|
||||
// fields are required before the configured text.
|
||||
const eventMessage = `0,0,${information}`;
|
||||
return send.object({
|
||||
result: K.ITEM('s32', 1),
|
||||
strdata1: K.ITEM(
|
||||
'str',
|
||||
Buffer.from(masterData, 'utf-8').toString('base64')
|
||||
),
|
||||
strdata2: K.ITEM('str', U.EncodeString(eventMessage, 'shift_jis').toString('base64')),
|
||||
// Use the request time so WebUI changes are not hidden by KDM's cached
|
||||
// master revision until CORE itself is restarted.
|
||||
updatedate: K.ITEM('u64', BigInt(String(Math.floor(Date.now() / 1000)))),
|
||||
});
|
||||
});
|
||||
|
||||
R.Route('playerdata.usergamedata_send', savePlayerData);
|
||||
R.Route('playerdata.usergamedata_recv', loadPlayerData);
|
||||
R.Route('playerdata.usergamedata_condrecv', loadPlayerData);
|
||||
R.Route('playerdata.usergamedata_inheritance', async (info, data, send) => send.object(RESULT_OK()));
|
||||
R.Route('playerdata.usergamedata_scorerank', async (info, data, send) => send.object(RESULT_OK()));
|
||||
R.Route('system.convcardnumber', convertCardNumber);
|
||||
|
||||
for (const method of [
|
||||
'matching.request', 'matching.wait', 'matching.finish',
|
||||
'system.getlocationiplist', 'system.xrpcproxy',
|
||||
'esoc.read', 'esoc.write',
|
||||
]) R.Route(method, true);
|
||||
|
||||
R.Unhandled((info, data, send) => {
|
||||
if (info.module !== 'eventlog') {
|
||||
console.warn(`[deac] Unhandled request: ${info.model}/${info.module}.${info.method}`);
|
||||
}
|
||||
return send.success();
|
||||
});
|
||||
|
||||
// Repair stale score indexes immediately on CORE startup, without requiring
|
||||
// every card to be used once before the WebUI becomes correct.
|
||||
migrateDerivedData().catch((err: any) => {
|
||||
console.warn(`[deac] Unable to rebuild derived score data: ${err}`);
|
||||
});
|
||||
}
|
||||
139
deac@asphyxia/webui/profile_deac.pug
Normal file
139
deac@asphyxia/webui/profile_deac.pug
Normal file
@@ -0,0 +1,139 @@
|
||||
//DATA//
|
||||
profile: DB.FindOne(refid, { collection: 'profile' })
|
||||
playHistory: DB.Find(refid, { collection: 'play_history' })
|
||||
scores: DB.Find(refid, { collection: 'score' })
|
||||
|
||||
-
|
||||
const chartPages = ['LIGHT', 'STANDARD', 'EXTREME', 'MASTER', 'STEALTH']
|
||||
const scoreView = query && query.view === 'scores'
|
||||
const requestedChart = query && query.chart ? String(query.chart).toUpperCase() : 'LIGHT'
|
||||
const activeChart = chartPages.indexOf(requestedChart) >= 0 ? requestedChart : 'LIGHT'
|
||||
const displayedHistory = (playHistory || [])
|
||||
.sort((a, b) => (b.playedAt || 0) - (a.playedAt || 0))
|
||||
.slice(0, 100)
|
||||
const displayedScores = (scores || [])
|
||||
.filter(item => item.chart === activeChart)
|
||||
.sort((a, b) => a.songId - b.songId)
|
||||
const profileUrl = `/plugin/deac@asphyxia/profile?refid=${encodeURIComponent(refid)}`
|
||||
const scoresUrl = chart => `${profileUrl}&view=scores&chart=${chart}`
|
||||
function formatTime(value) {
|
||||
if (!value) return '-'
|
||||
try { return new Date(value).toLocaleString('zh-CN', { hour12: false }) }
|
||||
catch (err) { return String(value) }
|
||||
}
|
||||
|
||||
if !scoreView
|
||||
.card
|
||||
.card-header
|
||||
p.card-header-title
|
||||
span.icon
|
||||
i.mdi.mdi-account-edit
|
||||
| DanceEvolution Player Profile
|
||||
.card-content
|
||||
form(method='post' action='/emit/updateDeacName')
|
||||
input(type='hidden' name='refid' value=refid)
|
||||
.field
|
||||
label.label Player Name
|
||||
.control
|
||||
input.input(type='text' name='name' maxlength='10' required value=(profile && profile.name ? profile.name : ''))
|
||||
p.help Up to 10 characters. The new Shift-JIS name takes effect on the next card login.
|
||||
.field
|
||||
button.button.is-primary(type='submit')
|
||||
span.icon
|
||||
i.mdi.mdi-check
|
||||
span Save Name
|
||||
|
||||
form(method='post' action='/emit/updateDeacEpassDisplay')
|
||||
input(type='hidden' name='refid' value=refid)
|
||||
.field
|
||||
label.label Show EPASS Card Number at Login
|
||||
.control
|
||||
.select
|
||||
select(name='showEpassNumber')
|
||||
option(value='true' selected=(!profile || profile.showEpassNumber !== false)) Show
|
||||
option(value='false' selected=(profile && profile.showEpassNumber === false)) Hide
|
||||
p.help The default is Show. Set this to Hide to suppress the card number when this profile logs in.
|
||||
.field
|
||||
button.button.is-primary(type='submit')
|
||||
span.icon
|
||||
i.mdi.mdi-check
|
||||
span Save Card Display
|
||||
|
||||
.card
|
||||
.card-header
|
||||
p.card-header-title
|
||||
span.icon
|
||||
i.mdi.mdi-history
|
||||
| Recent Play History
|
||||
.card-content
|
||||
.buttons.is-right
|
||||
a.button.is-link(href=scoresUrl('LIGHT'))
|
||||
span.icon
|
||||
i.mdi.mdi-trophy
|
||||
span View All Scores
|
||||
if displayedHistory.length
|
||||
.table-container
|
||||
table.table.is-striped.is-fullwidth.is-hoverable
|
||||
thead
|
||||
tr
|
||||
th Time
|
||||
th Song
|
||||
th Chart
|
||||
th Score
|
||||
th Grade
|
||||
th Combo
|
||||
th FC
|
||||
tbody
|
||||
each item in displayedHistory
|
||||
tr
|
||||
td #{formatTime(item.playedAt)}
|
||||
td #{item.song || ('Song #' + item.songId)}
|
||||
td #{item.chart}
|
||||
td #{item.score}
|
||||
td #{item.grade}
|
||||
td #{item.combo}
|
||||
td #{item.fullCombo ? 'Yes' : 'No'}
|
||||
else
|
||||
p No play history yet. Records will appear after completing a credit with this card.
|
||||
else
|
||||
.card
|
||||
.card-header
|
||||
p.card-header-title
|
||||
span.icon
|
||||
i.mdi.mdi-trophy
|
||||
| Song Best Scores
|
||||
.card-content
|
||||
.buttons
|
||||
a.button(href=profileUrl)
|
||||
span.icon
|
||||
i.mdi.mdi-arrow-left
|
||||
span Back to Play History
|
||||
|
||||
.tabs.is-boxed.is-fullwidth
|
||||
ul
|
||||
each chart in chartPages
|
||||
li(class=(chart === activeChart ? 'is-active' : ''))
|
||||
a(href=scoresUrl(chart)) #{chart}
|
||||
|
||||
if displayedScores.length
|
||||
.table-container
|
||||
table.table.is-striped.is-fullwidth.is-hoverable
|
||||
thead
|
||||
tr
|
||||
th ID
|
||||
th Song
|
||||
th Best Score
|
||||
th Grade
|
||||
th Combo
|
||||
th FC
|
||||
tbody
|
||||
each item in displayedScores
|
||||
tr
|
||||
td #{item.songId}
|
||||
td #{item.song || ('Song #' + item.songId)}
|
||||
td #{item.score}
|
||||
td #{item.grade}
|
||||
td #{item.combo}
|
||||
td #{item.fullCombo ? 'Yes' : 'No'}
|
||||
else
|
||||
.notification.is-light No #{activeChart} scores have been saved yet.
|
||||
Reference in New Issue
Block a user