mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-01 19:38:13 +03:00
543 lines
12 KiB
TypeScript
543 lines
12 KiB
TypeScript
import { integer } from "tachi-common";
|
|
import {
|
|
BGMObject,
|
|
BPMObject,
|
|
BSSObject,
|
|
CNObject,
|
|
EOCObject,
|
|
IIDXChartEvents,
|
|
IIDXParserContext,
|
|
IIDXParserOptions,
|
|
MeasureBarObject,
|
|
MeterObject,
|
|
NoteObject,
|
|
ParsedIIDXChart,
|
|
SampleObject,
|
|
ScratchObject,
|
|
TimingWindowsObject,
|
|
} from "./types";
|
|
import fs from "fs/promises";
|
|
import logger from "../../../../logger";
|
|
import path from "path";
|
|
|
|
interface EventParserContext {
|
|
isDP?: boolean;
|
|
songTitle?: string;
|
|
}
|
|
|
|
interface Directory {
|
|
offset: number;
|
|
length: number;
|
|
}
|
|
|
|
function ParseDirectoriesHeader(fileData: Buffer): Directory[] {
|
|
// .1 format has 12 directory entries at the start
|
|
// of the file.
|
|
// these are all 8 bytes long, and all next to
|
|
// eachother.
|
|
const dirsBuffer = fileData.subarray(0, 12 * 8);
|
|
|
|
const dirs: Array<{ offset: integer; length: integer }> = [];
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
const dirBuffer = dirsBuffer.subarray(i * 8, (i + 1) * 8);
|
|
|
|
// the first 4 bytes indicate the offset
|
|
// the other 4 indicate how many bytes
|
|
// this chart takes up.
|
|
const dir = {
|
|
offset: dirBuffer.readIntLE(0, 4),
|
|
length: dirBuffer.readIntLE(4, 4),
|
|
};
|
|
|
|
dirs.push(dir);
|
|
}
|
|
|
|
return dirs;
|
|
}
|
|
|
|
type Event =
|
|
| { type: "NOTE"; body: NoteObject }
|
|
| { type: "BGM"; body: BGMObject }
|
|
| { type: "BPM"; body: BPMObject }
|
|
| { type: "BSS"; body: BSSObject }
|
|
| { type: "CN"; body: CNObject }
|
|
| { type: "HBSS"; body: BSSObject }
|
|
| { type: "HCN"; body: CNObject }
|
|
| { type: "SCRATCH"; body: ScratchObject }
|
|
| { type: "EOC"; body: EOCObject }
|
|
| { type: "SAMPLE"; body: SampleObject }
|
|
| { type: "TIMINGWINDOW"; body: TimingWindowsObject }
|
|
| { type: "MEASUREBAR"; body: MeasureBarObject }
|
|
| { type: "METER"; body: MeterObject };
|
|
|
|
const IIDX_TYPE_TO_RGC: Record<number, string> = {
|
|
0: "NOTE",
|
|
1: "NOTE",
|
|
2: "SAMPLE",
|
|
3: "SAMPLE",
|
|
4: "BPM",
|
|
5: "METER",
|
|
6: "EOC",
|
|
7: "BGM",
|
|
8: "TIMINGWINDOW",
|
|
12: "MEASUREBAR",
|
|
13: "HCN_INDICATOR",
|
|
16: "NOTECOUNT",
|
|
};
|
|
|
|
const IIDX_TIMING_TO_RGC: Record<
|
|
number,
|
|
"lateBad" | "lateGood" | "lateGreat" | "earlyGreat" | "earlyGood" | "earlyBad"
|
|
> = {
|
|
0: "lateBad",
|
|
1: "lateGood",
|
|
2: "lateGreat",
|
|
3: "earlyGreat",
|
|
4: "earlyGood",
|
|
5: "earlyBad",
|
|
};
|
|
|
|
function ParseEvent(
|
|
noteData: Buffer,
|
|
parseContext: EventParserContext,
|
|
options: IIDXParserOptions
|
|
): Event | null {
|
|
const data = {
|
|
ticks: noteData.readIntLE(0, 4),
|
|
type: noteData.readIntLE(4, 1),
|
|
param: noteData.readIntLE(5, 1),
|
|
val: noteData.readIntLE(6, 2),
|
|
};
|
|
|
|
// A second EOF indicator (the real one) is that there is an event placed at -1 miliseconds.
|
|
// return null, and DO NOT add this as an object.
|
|
if (data.ticks < 0 || data.ticks >= 2147483647) {
|
|
return null;
|
|
}
|
|
|
|
let type = IIDX_TYPE_TO_RGC[data.type];
|
|
|
|
let event: Event;
|
|
|
|
const ms = data.ticks * ((options.tickrate || 1000) / 1000);
|
|
|
|
// Disambiguate NOTE type, which may indicate other RGC types.
|
|
// THIS IS DIFFERENT TO THE IMMEDIATELY FOLLOWING STATEMENT!
|
|
if (type === "NOTE") {
|
|
// if val is nonzero, this note is a hold note
|
|
// with ticks of (length);
|
|
if (data.val !== 0) {
|
|
type = options.isHellCharge ? "HCN" : "CN";
|
|
}
|
|
|
|
// scratch overrides
|
|
if (data.param === 7) {
|
|
// if hold on scratch column, we have BSS.
|
|
if (data.val !== 0) {
|
|
type = options.isHellCharge ? "HBSS" : "BSS";
|
|
} else {
|
|
type = "SCRATCH";
|
|
}
|
|
}
|
|
}
|
|
|
|
if (type === "NOTE") {
|
|
const obj: NoteObject = {
|
|
ms,
|
|
col: data.param,
|
|
};
|
|
|
|
// if this note was on P2 side, we need to
|
|
// add 7 to its column.
|
|
if (data.type === 1) {
|
|
obj.col += 7;
|
|
}
|
|
|
|
if (data.type === 1) {
|
|
if (obj.col > 13 || obj.col < 0) {
|
|
throw TypeError(`Invalid column of ${obj.col}. Must be between 0 and 13.`);
|
|
}
|
|
} else {
|
|
if (obj.col > 6 || obj.col < 0) {
|
|
throw TypeError(`Invalid column of ${obj.col}. Must be between 0 and 6.`);
|
|
}
|
|
}
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "CN" || type === "HCN") {
|
|
if (type === "CN" && options.errOnCN) {
|
|
throw new TypeError(
|
|
`CN found inside ${
|
|
parseContext.songTitle || "UNKNOWN CHART"
|
|
}, but errOnCN was enabled.`
|
|
);
|
|
} else if (type === "HCN" && options.errOnHCN) {
|
|
throw new TypeError(
|
|
`HCN found inside ${
|
|
parseContext.songTitle || "UNKNOWN CHART"
|
|
}, but errOnHCN was enabled.`
|
|
);
|
|
}
|
|
|
|
const obj: CNObject = {
|
|
ms,
|
|
col: data.param,
|
|
msEnd: data.val + ms,
|
|
};
|
|
|
|
if (data.type === 1) {
|
|
obj.col += 7;
|
|
}
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "SCRATCH") {
|
|
const obj: ScratchObject = {
|
|
ms,
|
|
};
|
|
|
|
if (parseContext.isDP) {
|
|
if (data.param === 7) {
|
|
obj.col = 0;
|
|
} else if (data.param === 14) {
|
|
obj.col = 1;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "BSS" || type === "HBSS") {
|
|
const obj: BSSObject = {
|
|
ms,
|
|
msEnd: ms + data.val,
|
|
};
|
|
|
|
if (parseContext.isDP) {
|
|
if (data.param === 7) {
|
|
obj.col = 0;
|
|
} else if (data.param === 14) {
|
|
obj.col = 1;
|
|
} else {
|
|
logger.warn(
|
|
`(${
|
|
parseContext.songTitle ?? "UNKNOWN"
|
|
}) Unknown column of scratchObj sent to EventParser: ${
|
|
data.param
|
|
}, expected 7 or 14. Skipping.`
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "SAMPLE") {
|
|
const obj: SampleObject = {
|
|
ms,
|
|
col: data.param,
|
|
sound: data.val,
|
|
};
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "BPM") {
|
|
if (data.param === 0) {
|
|
logger.warn(
|
|
`(${
|
|
parseContext.songTitle ?? "UNKNOWN"
|
|
}) Invalid value of 0 for denominator in BPM event (numerator was ${
|
|
data.val
|
|
}). Skipping.`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const obj: BPMObject = {
|
|
ms,
|
|
bpm: data.val / data.param,
|
|
};
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "METER") {
|
|
const obj: MeterObject = {
|
|
ms,
|
|
num: data.val,
|
|
denom: data.param,
|
|
};
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "BGM") {
|
|
const obj: BGMObject = {
|
|
ms,
|
|
pan: data.param,
|
|
sound: data.val,
|
|
};
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "TIMINGWINDOW") {
|
|
const window = IIDX_TIMING_TO_RGC[data.param];
|
|
|
|
if (!window) {
|
|
logger.warn(
|
|
`(${
|
|
parseContext.songTitle ?? "UNKNOWN"
|
|
}) Unknown type of timingWindow: ${window}, from .1 param ${data.param}. Skipping.`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const obj: TimingWindowsObject = {
|
|
ms,
|
|
window,
|
|
// for whatever reason, the value for TIMINGWINDOW is a signed int8,
|
|
// rather than the int16 every other thing expects.
|
|
// this means, suboptimally, we are reading noteData twice.
|
|
// performance is hardly a concern though, as you can blast 100k
|
|
// charts with this in a couple seconds.
|
|
val: noteData.readIntLE(6, 1),
|
|
};
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "EOC") {
|
|
const obj: EOCObject = {
|
|
ms,
|
|
};
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "MEASUREBAR") {
|
|
const obj: MeasureBarObject = {
|
|
ms,
|
|
};
|
|
|
|
if (parseContext.isDP) {
|
|
if (data.param !== 0 && data.param !== 1) {
|
|
logger.warn(
|
|
`(${parseContext.songTitle ?? "UNKNOWN"}) Invalid parameter for measurebar of ${
|
|
data.param
|
|
}. Defaulting to 0 (left hand side).`
|
|
);
|
|
obj.side = 0;
|
|
} else {
|
|
obj.side = data.param;
|
|
}
|
|
}
|
|
|
|
event = {
|
|
type,
|
|
body: obj,
|
|
};
|
|
} else if (type === "NOTECOUNT") {
|
|
// You might think: Hey! This is super convenient for just quickly getting
|
|
// the notecount of a chart, right?
|
|
// Well, uh, no. It's not correct. CNs are treated as one note, which is
|
|
// *absolutely* not the case in game.
|
|
// This thing is **utterly** useless, as you have to count the amount of CNs
|
|
// anyway. At that point, why not count the notes?
|
|
|
|
return null;
|
|
} else if (type === "HCN_INDICATOR") {
|
|
// this chart is using HCNs.
|
|
options.isHellCharge = true;
|
|
return null;
|
|
} else if (data.type === 11) {
|
|
// it's some sort of unknown BGA-related thing. Only seems to happen in older
|
|
// charts. Maybe it was some sort of diverging bga instruction at one point?
|
|
return null;
|
|
} else {
|
|
logger.warn(
|
|
`(${parseContext.songTitle ?? "UNKNOWN"}) Unknown event type in .1: ${
|
|
data.type
|
|
} at ${ms.toFixed(2)}ms. Ignoring.`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
function ParseEvents(
|
|
fileData: Buffer,
|
|
dir: Directory,
|
|
context: EventParserContext,
|
|
options: IIDXParserOptions
|
|
) {
|
|
const events: IIDXChartEvents = {
|
|
BGM: [],
|
|
BPM: [],
|
|
BSS: [],
|
|
HCN: [],
|
|
CN: [],
|
|
HBSS: [],
|
|
NOTE: [],
|
|
SAMPLE: [],
|
|
METER: [],
|
|
TIMINGWINDOW: [],
|
|
MEASUREBAR: [],
|
|
EOC: [],
|
|
SCRATCH: [],
|
|
};
|
|
|
|
const data = fileData.subarray(dir.offset, dir.offset + dir.length);
|
|
|
|
// charts are a series of 8 byte "events".
|
|
// we are going to iterate through [data], pulling
|
|
// said 8 bytes, and parsing them.
|
|
// i am assuming that dir.length is always
|
|
// a multiple of 8.
|
|
for (let i = 0; i < dir.length; i += 8) {
|
|
const noteBuffer = data.subarray(i, i + 8);
|
|
|
|
const event = ParseEvent(noteBuffer, context, options);
|
|
|
|
if (!event) {
|
|
continue;
|
|
}
|
|
|
|
// @ts-expect-error invariant error but we're right.
|
|
events[event.type].push(event.body);
|
|
}
|
|
|
|
return events;
|
|
}
|
|
|
|
/**
|
|
* Converts the index of a directory to the corresponding difficulty name.
|
|
*/
|
|
const IIDX_DIFFICULTY_TO_RGC: Record<
|
|
number,
|
|
"BEGINNER" | "NORMAL" | "HYPER" | "ANOTHER" | "LEGGENDARIA" | null
|
|
> = {
|
|
0: "HYPER",
|
|
1: "NORMAL",
|
|
2: "ANOTHER",
|
|
3: "BEGINNER",
|
|
4: "LEGGENDARIA", // ^^ SP ^^
|
|
5: null, // vv DP vv
|
|
6: "HYPER",
|
|
7: "NORMAL",
|
|
8: "ANOTHER",
|
|
9: null,
|
|
10: "LEGGENDARIA",
|
|
11: null,
|
|
};
|
|
|
|
export function ParseDotOne(
|
|
fileData: Buffer,
|
|
context: IIDXParserContext = {},
|
|
options: IIDXParserOptions = {}
|
|
) {
|
|
const charts: Array<ParsedIIDXChart> = [];
|
|
|
|
const dirs = ParseDirectoriesHeader(fileData);
|
|
|
|
// with all our dirs we now need to parse each chart.
|
|
for (let i = 0; i < 12; i++) {
|
|
const dir = dirs[i];
|
|
|
|
if (!dir || (dir.offset === 0 && dir.length === 0)) {
|
|
logger.verbose(`Skipped directory ${i}, as there was no data.`);
|
|
continue; // no data.
|
|
}
|
|
|
|
const difficulty = IIDX_DIFFICULTY_TO_RGC[i];
|
|
|
|
if (!difficulty) {
|
|
logger.warn(
|
|
`(${
|
|
context.songTitle ?? "UNKNOWN"
|
|
}) Data present inside directory ${i}, but this corresponds to an unknown difficulty. Ignoring.`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const eventContext: EventParserContext = {
|
|
isDP: i > 5,
|
|
songTitle: context?.songTitle,
|
|
};
|
|
|
|
if (!eventContext.songTitle) {
|
|
logger.warn(
|
|
"No filename provided in context.filename to IIDXParser, subsequent warnings will have no way of referring to the chart."
|
|
);
|
|
}
|
|
|
|
const events = ParseEvents(fileData, dir, eventContext, options);
|
|
|
|
const chart: ParsedIIDXChart = {
|
|
playtype: i > 5 ? "DP" : "SP",
|
|
title: context?.songTitle ?? null,
|
|
artist: context?.songArtist ?? null,
|
|
genre: context?.genre ?? null,
|
|
level: context?.level ?? null,
|
|
marquee: context?.marquee ?? null,
|
|
difficulty,
|
|
notecount: getNotecount(events),
|
|
events,
|
|
};
|
|
|
|
charts.push(chart);
|
|
}
|
|
|
|
return charts;
|
|
}
|
|
|
|
function getNotecount(events: IIDXChartEvents) {
|
|
// note: account for HMSS at some point
|
|
return (
|
|
events.NOTE.length +
|
|
events.SCRATCH.length +
|
|
events.CN.length * 2 +
|
|
events.HCN.length * 2 +
|
|
events.BSS.length * 2 +
|
|
events.HBSS.length * 2
|
|
);
|
|
}
|
|
|
|
export async function ParseDotOneFile(
|
|
filepath: string,
|
|
context: IIDXParserContext = {},
|
|
options: IIDXParserOptions = {}
|
|
) {
|
|
const data = await fs.readFile(filepath);
|
|
|
|
return ParseDotOne(data, context, options);
|
|
}
|
|
|
|
// if invoked as a script, parse the dotone and spit it out to debug.json.
|
|
if (require.main === module) {
|
|
(async () => {
|
|
const res = await ParseDotOneFile(process.argv.slice(2).join(" "));
|
|
|
|
await fs.writeFile(path.join(__dirname, "debug.json"), JSON.stringify(res, null, "\t"));
|
|
})();
|
|
}
|