430 lines
11 KiB
TypeScript
430 lines
11 KiB
TypeScript
import { readdir } from "node:fs/promises";
|
|
import Fraction from "fraction.js";
|
|
import type { RawSimfile } from "./parseSimfile";
|
|
import {
|
|
determineBeat,
|
|
mergeSimilarBpmRanges,
|
|
normalizedDifficultyMap,
|
|
} from "./util";
|
|
|
|
const metaTagsToConsume = ["title", "artist"];
|
|
|
|
const dwiToSMDirection: Record<string, Arrow["direction"]> = {
|
|
0: "0000",
|
|
1: "1100", // down-left
|
|
2: "0100", // down
|
|
3: "0101", // down-right
|
|
4: "1000", // left
|
|
6: "0001", // right
|
|
7: "1010", // up-left
|
|
8: "0010", // up
|
|
9: "0011", // up-right
|
|
A: "0110", // up-down jump
|
|
B: "1001", // left-right jump
|
|
};
|
|
|
|
const smToDwiDirection = Object.entries(dwiToSMDirection).reduce<
|
|
Record<string, string>
|
|
>((building, entry) => {
|
|
building[entry[1]] = entry[0];
|
|
return building;
|
|
}, {});
|
|
|
|
type ArrowParseResult = {
|
|
arrows: Arrow[];
|
|
freezes: FreezeBody[];
|
|
};
|
|
|
|
function combinePadsIntoOneStream(
|
|
p1: ArrowParseResult,
|
|
p2: ArrowParseResult,
|
|
): ArrowParseResult {
|
|
const arrows = p1.arrows
|
|
.concat(p2.arrows)
|
|
.sort((a, b) => a.offset - b.offset);
|
|
|
|
const combinedArrows = arrows.reduce<Arrow[]>((building, arrow, i, rest) => {
|
|
const prevArrow = rest[i - 1];
|
|
|
|
// since previous offset matches, the previous one already
|
|
// grabbed and combined with this arrow, throw it away
|
|
if (prevArrow?.offset === arrow.offset) {
|
|
return building;
|
|
}
|
|
|
|
const nextArrow = rest[i + 1];
|
|
|
|
if (nextArrow?.offset === arrow.offset) {
|
|
return building.concat({
|
|
...arrow,
|
|
direction: arrow.direction + nextArrow.direction,
|
|
} as Arrow);
|
|
}
|
|
|
|
return building.concat({
|
|
...arrow,
|
|
direction: p1.arrows.includes(arrow)
|
|
? `${arrow.direction}0000`
|
|
: `0000${arrow.direction}`,
|
|
} as Arrow);
|
|
}, []);
|
|
|
|
const bumpedP2Freezes = p2.freezes.map((f) => {
|
|
return {
|
|
...f,
|
|
direction: f.direction + 4,
|
|
} as FreezeBody;
|
|
});
|
|
|
|
const freezes = p1.freezes
|
|
.concat(bumpedP2Freezes)
|
|
.sort((a, b) => a.startOffset - b.startOffset);
|
|
|
|
return {
|
|
arrows: combinedArrows,
|
|
freezes,
|
|
};
|
|
}
|
|
|
|
function findFirstNonEmptyMeasure(
|
|
p1Notes: string,
|
|
p2Notes: string | undefined,
|
|
): number {
|
|
let i = 0;
|
|
|
|
while (
|
|
p1Notes.startsWith("00000000") &&
|
|
(!p2Notes || p2Notes.startsWith("00000000"))
|
|
) {
|
|
p1Notes = p1Notes.substring(8);
|
|
p2Notes = p2Notes?.substring(8);
|
|
i += 8;
|
|
}
|
|
|
|
return i;
|
|
}
|
|
|
|
function parseArrowStream(
|
|
notes: string,
|
|
firstNonEmptyMeasureIndex: number,
|
|
): ArrowParseResult {
|
|
const arrows: Arrow[] = [];
|
|
const freezes: FreezeBody[] = [];
|
|
|
|
const currentFreezeDirections: string[] = [];
|
|
const openFreezes: Record<
|
|
FreezeBody["direction"],
|
|
Partial<FreezeBody> | null
|
|
> = {
|
|
0: null,
|
|
1: null,
|
|
2: null,
|
|
3: null,
|
|
4: null,
|
|
5: null,
|
|
6: null,
|
|
7: null,
|
|
};
|
|
|
|
let curOffset = new Fraction(0);
|
|
// dwi's default increment is 8th notes
|
|
let curMeasureFraction = new Fraction(1).div(8);
|
|
|
|
for (
|
|
let i = firstNonEmptyMeasureIndex;
|
|
i < notes.length && notes[i] !== ";";
|
|
++i
|
|
) {
|
|
let note = notes[i];
|
|
const nextNote = notes[i + 1];
|
|
|
|
const smDirection = dwiToSMDirection[note];
|
|
|
|
// give the current note a chance to conclude any freezes that may be pending
|
|
if (smDirection) {
|
|
const smDirectionSplit = smDirection.split("");
|
|
for (let d = 0; d < smDirection.length; ++d) {
|
|
if (
|
|
smDirection[d] === "1" &&
|
|
openFreezes[d as FreezeBody["direction"]]
|
|
) {
|
|
const of = openFreezes[d as FreezeBody["direction"]];
|
|
of!.endOffset = curOffset.n / curOffset.d + 0.25;
|
|
freezes.push(of as FreezeBody);
|
|
openFreezes[d as FreezeBody["direction"]] = null;
|
|
smDirectionSplit[d] = "0";
|
|
}
|
|
}
|
|
|
|
note = smToDwiDirection[smDirectionSplit.join("")];
|
|
}
|
|
|
|
if (nextNote === "!") {
|
|
// B!602080B
|
|
// this means the freeze starts with B (left and right), but then only right (6) has the freeze body
|
|
// during the freeze there is down (2) then up (8), concluding with the second B
|
|
|
|
const freezeNote = notes[i + 2];
|
|
|
|
const smDirection = dwiToSMDirection[freezeNote];
|
|
|
|
for (let d = 0; d < smDirection.length; ++d) {
|
|
if (smDirection[d] === "1") {
|
|
openFreezes[d as FreezeBody["direction"]] = {
|
|
direction: d as FreezeBody["direction"],
|
|
startOffset: curOffset.n / curOffset.d,
|
|
};
|
|
}
|
|
}
|
|
|
|
// the head of a freeze is still an arrow
|
|
arrows.push({
|
|
direction: dwiToSMDirection[note].replace(
|
|
/1/g,
|
|
"2",
|
|
) as Arrow["direction"],
|
|
beat: determineBeat(curOffset),
|
|
offset: curOffset.n / curOffset.d,
|
|
});
|
|
|
|
// remember the direction to know when to close the freeze
|
|
currentFreezeDirections.push(freezeNote);
|
|
|
|
// move past the exclamation and trailing note
|
|
i += 2;
|
|
curOffset = curOffset.add(curMeasureFraction);
|
|
} else if (note === "(") {
|
|
curMeasureFraction = new Fraction(1).div(16);
|
|
} else if (note === "[") {
|
|
curMeasureFraction = new Fraction(1).div(24);
|
|
} else if (note === "{") {
|
|
curMeasureFraction = new Fraction(1).div(64);
|
|
} else if (note === "`") {
|
|
curMeasureFraction = new Fraction(1).div(192);
|
|
} else if ([")", "]", "}", "'"].includes(note)) {
|
|
curMeasureFraction = new Fraction(1).div(8);
|
|
} else if (note === "0") {
|
|
curOffset = curOffset.add(curMeasureFraction);
|
|
} else {
|
|
const direction = dwiToSMDirection[note];
|
|
|
|
if (direction) {
|
|
arrows.push({
|
|
direction,
|
|
beat: determineBeat(curOffset),
|
|
offset: curOffset.n / curOffset.d,
|
|
});
|
|
}
|
|
|
|
curOffset = curOffset.add(curMeasureFraction);
|
|
}
|
|
}
|
|
|
|
return { arrows, freezes };
|
|
}
|
|
|
|
async function findBanner(titlePath: string): Promise<string> {
|
|
const files = await readdir(titlePath);
|
|
|
|
const bannerFile = files.find(
|
|
(f) => f.endsWith(".png") && f.indexOf("-bg.png") === -1,
|
|
);
|
|
|
|
return bannerFile ?? null;
|
|
}
|
|
|
|
async function parseDwi(dwi: string, titlePath?: string): Promise<RawSimfile> {
|
|
let bpm: string | null = null;
|
|
let changebpm: string | null = null;
|
|
let displaybpm: string | null = null;
|
|
let stops: string | null = null;
|
|
|
|
const lines = dwi.split("\n").map((l) => l.trim());
|
|
|
|
let i = 0;
|
|
|
|
const sc: Partial<RawSimfile> = {
|
|
charts: {},
|
|
availableTypes: [],
|
|
banner: titlePath ? await findBanner(titlePath) : null,
|
|
};
|
|
|
|
function parseNotes(mode: "single" | "double", rawNotes: string) {
|
|
const values = rawNotes.split(":");
|
|
const difficulty = normalizedDifficultyMap[values[0].toLowerCase()];
|
|
const feet = Number(values[1]);
|
|
const notes = values[2];
|
|
const playerTwoNotes = values[3];
|
|
|
|
const firstNonEmptyMeasureIndex = findFirstNonEmptyMeasure(
|
|
notes,
|
|
playerTwoNotes,
|
|
);
|
|
|
|
let arrowResult = parseArrowStream(notes, firstNonEmptyMeasureIndex);
|
|
|
|
if (mode === "double") {
|
|
const playerTwoResult = parseArrowStream(
|
|
playerTwoNotes,
|
|
firstNonEmptyMeasureIndex,
|
|
);
|
|
|
|
arrowResult = combinePadsIntoOneStream(arrowResult, playerTwoResult);
|
|
}
|
|
|
|
sc.availableTypes!.push({
|
|
slug: `${mode}-${difficulty}`,
|
|
mode,
|
|
difficulty: difficulty as any,
|
|
feet,
|
|
});
|
|
|
|
sc.charts![`${mode}-${difficulty}`] = {
|
|
arrows: arrowResult.arrows,
|
|
freezes: arrowResult.freezes,
|
|
bpm: determineBpm(firstNonEmptyMeasureIndex),
|
|
stops: determineStops(firstNonEmptyMeasureIndex),
|
|
};
|
|
}
|
|
|
|
function determineStops(emptyOffset: number) {
|
|
if (!stops) {
|
|
return [];
|
|
}
|
|
|
|
return stops.split(",").map((s) => {
|
|
const [eigthNoteS, stopDurationS] = s.split("=");
|
|
|
|
return {
|
|
offset: Number(eigthNoteS) * (1 / 16) - emptyOffset * (1 / 8),
|
|
duration: Number(stopDurationS),
|
|
};
|
|
});
|
|
}
|
|
|
|
function determineBpm(emptyOffset: number) {
|
|
let finalBpms: Bpm[] = [];
|
|
|
|
if (bpm && !Number.isNaN(Number(bpm))) {
|
|
finalBpms = [{ startOffset: 0, endOffset: null, bpm: Number(bpm) }];
|
|
}
|
|
|
|
if (changebpm) {
|
|
if (!finalBpms) {
|
|
throw new Error("parseDwi: a simfile has changebpm but not bpm");
|
|
}
|
|
|
|
const entries = changebpm.split(",");
|
|
const additionalBpms = entries.map((bpmES, i, a) => {
|
|
const [eigthNoteS, bpmVS] = bpmES.split("=");
|
|
const nextEigthNoteS = a[i + 1]?.split("=")[0] ?? null;
|
|
|
|
const startOffset =
|
|
Number(eigthNoteS) * (1 / 16) - emptyOffset * (1 / 8);
|
|
let endOffset = null;
|
|
|
|
if (nextEigthNoteS) {
|
|
endOffset = Number(nextEigthNoteS) * (1 / 16) - emptyOffset * (1 / 8);
|
|
}
|
|
|
|
return {
|
|
startOffset,
|
|
endOffset,
|
|
bpm: Number(bpmVS),
|
|
};
|
|
});
|
|
|
|
finalBpms = finalBpms.concat(additionalBpms);
|
|
finalBpms[0].endOffset = finalBpms[1].startOffset;
|
|
}
|
|
|
|
if (!finalBpms) {
|
|
throw new Error("parseDwi, determineBpm: failed to get bpm");
|
|
}
|
|
|
|
finalBpms = mergeSimilarBpmRanges(finalBpms);
|
|
|
|
if (displaybpm) {
|
|
if (!Number.isNaN(Number(displaybpm))) {
|
|
sc.displayBpm = displaybpm;
|
|
} else if (displaybpm.indexOf("..") > -1) {
|
|
const [min, max] = displaybpm.split("..");
|
|
sc.displayBpm = `${min}-${max}`;
|
|
} else {
|
|
// displayBpm is allowed to be '*', I know of no simfiles
|
|
// that actually do that though
|
|
sc.displayBpm = displaybpm;
|
|
}
|
|
} else {
|
|
const minBpm = Math.min(...finalBpms.map((b) => b.bpm));
|
|
const maxBpm = Math.max(...finalBpms.map((b) => b.bpm));
|
|
|
|
if (minBpm === maxBpm) {
|
|
sc.displayBpm = Math.round(minBpm).toString();
|
|
} else {
|
|
sc.displayBpm = `${Math.round(minBpm)}-${Math.round(maxBpm)}`;
|
|
}
|
|
}
|
|
|
|
return finalBpms;
|
|
}
|
|
|
|
function parseTag(lines: string[], index: number): number {
|
|
const line = lines[index];
|
|
|
|
const r = /#([A-Za-z]+):([^;]*)/;
|
|
const result = r.exec(line);
|
|
|
|
if (result) {
|
|
const tag = result[1].toLowerCase();
|
|
const value = result[2];
|
|
|
|
if (metaTagsToConsume.includes(tag)) {
|
|
// @ts-ignore
|
|
sc[tag] = value;
|
|
} else if (tag === "displaybpm") {
|
|
displaybpm = value;
|
|
} else if (tag === "bpm") {
|
|
bpm = value;
|
|
} else if (tag === "changebpm") {
|
|
changebpm = value;
|
|
} else if (tag === "freeze") {
|
|
stops = value;
|
|
} else if (tag === "single" || tag === "double") {
|
|
parseNotes(tag, value);
|
|
}
|
|
}
|
|
|
|
return index + 1;
|
|
}
|
|
|
|
try {
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
|
|
if (!line.length || line.startsWith("//")) {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if (line.startsWith("#")) {
|
|
i = parseTag(lines, i);
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
|
|
if (!displaybpm && !bpm) {
|
|
throw new Error(`No BPM found for ${titlePath}`);
|
|
}
|
|
|
|
return sc as RawSimfile;
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
const stack = e instanceof Error ? e.stack : "";
|
|
|
|
throw new Error(`error, ${message}, ${stack}, parsing ${dwi}`);
|
|
}
|
|
}
|
|
|
|
export { parseDwi };
|