mc-benchmark
Version:
Build charts about load time of Minecraft modpack.
276 lines • 11.6 kB
JavaScript
import process from 'node:process';
import chalkWeak from 'chalk';
import Color from 'color';
import ColorHash from 'color-hash';
import { consola } from 'consola';
import { sum } from './utils.js';
const chalk = chalkWeak.constructor({ level: process.stderr.isTTY ? 3 : 0 });
// @ts-expect-error default
const colorHash = new (ColorHash.default ?? ColorHash)({ lightness: [0.2625, 0.375, 0.4875] });
function escapeRegex(str) {
return str.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&');
}
export const loaderSteps = {
'Construction': /Construction - /,
'Loading Resources': /Loading Resources - (?:FMLFileResourcePack:)?/,
'PreInitialization': /PreInitialization - /,
'Initialization': /Initialization - /,
'InterModComms': /InterModComms\$IMC - /,
'PostInitialization': /PostInitialization - /,
'LoadComplete': /LoadComplete - /,
'ModIdMapping': /ModIdMapping - /,
};
const fml_steps_rgx = `(?<stepName>${Object.values(loaderSteps).map(l => l.source).join('|')})`;
// ------------------------------------------------
const mcFinishLoadingRgx = /(\[FML\]: Bar Finished: Loading took|\[VintageFix\]: Game launch took|\[Universal Tweaks\]: The game loaded in approximately) (\S*)(s| seconds)/g;
export function timeToSeconds(timeStr) {
const [hours, minutes, seconds] = timeStr.split(':').map(Number);
return hours * 3600 + minutes * 60 + seconds;
}
export function getTimeline(debug_log, lines) {
const timelineSteps = {
Mixins: {
rgx: /\[Client thread\/INFO\] \[FML\]: -- System Details --/,
stamp: 'Window appear',
},
Construction: {
rgx: /Sending event FMLPreInitializationEvent to mod minecraft/,
},
PreInit: {
rgx: /Sending event FMLInitializationEvent to mod minecraft/,
},
Init: {
rgx: mcFinishLoadingRgx,
},
};
const timeline = [];
const timeStampRgx = /^\[(\d+:\d+:\d+)\] /;
const prevTimeStampMatch = debug_log.match(timeStampRgx);
const prevTimeStamp = prevTimeStampMatch ? prevTimeStampMatch[1] : '00:00:00';
let prevMoment = timeToSeconds(prevTimeStamp);
let pointer = 0;
for (const [name, { rgx, stamp }] of Object.entries(timelineSteps)) {
for (let i = pointer; i < lines.length; i++) {
const line = lines[i];
if (!line.match(rgx))
continue;
const timeStamp = line.match(timeStampRgx)[1];
const moment = timeToSeconds(timeStamp);
timeline.push([name, moment - prevMoment, stamp]);
prevMoment = moment;
pointer = i + 1;
break;
}
}
return timeline;
}
export async function getMods(debug_log, lines, _crafttweaker_log) {
let result = {};
const fullSearchRgx = new RegExp(`${escapeRegex('[Client thread/DEBUG] [FML]: Bar Step: ')}${fml_steps_rgx}(?<modName>.+) took (?<time>\\d+.\\d+)s`, 'gim');
const fullLog = [...debug_log.matchAll(fullSearchRgx)];
consola.start(`Parsing mods [${fullLog.length}]`);
for (const { groups } of fullLog) {
const { stepName, modName, time: timeStr } = groups;
// Skip Forge steps
if (['Minecraft Forge', 'Forge Mod Loader'].includes(modName))
continue;
result[modName] ??= {
steps: [...Object.keys(loaderSteps), 0.0].map(() => 0.0),
color: colorHash.hex(modName).slice(1),
};
const stepIndex = Object.values(loaderSteps).findIndex(rgx => stepName.match(rgx));
const time = Number.parseFloat(timeStr);
result[modName].steps[stepIndex !== -1 ? stepIndex : Object.keys(loaderSteps).length] += time;
}
const modIdToName = {};
// Get file for every mod
for (const { groups } of debug_log.matchAll(
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/no-misleading-capturing-group
/\[Client thread\/DEBUG\] \[FML\]: \t(?<modId>[^(]+)\((?!API: )(?<modName>.+):[^)]+\): (?<fileName>.+?\.jar) \(.*\)/gi)) {
const modId = groups.modId.trim();
const modName = groups.modName.trim();
modIdToName[modId] = modName;
const modWithFile = result[modName];
if (modWithFile) {
modWithFile.fileName = groups.fileName;
}
else {
consola.info(`"${chalk.hex('558855').bold(modName)}" `
+ `from "${chalk.hex('558855')(groups.fileName)}" `
+ `file, but not a mod`);
}
}
// -------------------------------------------
// Additional parts
// -------------------------------------------
const vfBakeEvents = [...debug_log.matchAll(/\[VintageFix\]: \s+FMLMod:(?<modId>[^{]+)\{[^}]+\}: (?<time>\d+(\.\d+)?) (?<unit>ms|s)/gi)];
for (const { groups } of vfBakeEvents) {
const modId = groups.modId.trim();
const modName = modIdToName[modId] ?? modId;
const time = groups.unit === 'ms'
? Number.parseFloat(groups.time) / 1000
: Number.parseFloat(groups.time);
await addParts(result, {
name: '[VF ModelBake]',
rgx: modName,
step: 'Other',
time,
});
}
await addParts(result, {
name: '[JEI Ingredient Filter]',
rgx: /(Just|Had) Enough Items/i,
step: 'LoadComplete',
time: toSeconds(debug_log.match(/\[(?:jei|Had\s?Enough\s?Items)\]: Building ingredient filter (?:and search trees )?took (?<num>\d+\.\d+) (?<time>m?s)/)?.groups),
});
const jeiPlugins = Object.values(await getJeiPlugins(debug_log));
if (jeiPlugins.length) {
await addParts(result, {
name: '[JEI Plugins]',
rgx: /(Just|Had) Enough Items/i,
step: 'LoadComplete',
time: sum(jeiPlugins),
});
}
await addParts(result, {
name: '[Oredict Melting]',
rgx: /Tinkers' Construct|Tinkers' Antique/,
step: 'Other',
time: Number.parseFloat(debug_log.match(/Oredict melting recipes finished in (\d+\.\d+) ms/)?.[1] ?? '0') / 1000,
});
const duration = logLineDuration(lines, '\\[tconstruct-TextureGen\\]: Generated \\d+ Textures for Materials');
if (duration > 0) {
await addParts(result, {
name: '[TCon Textures]',
rgx: /^Tinkers' Construct|Tinkers' Antique/,
step: 'Other',
time: duration,
});
}
// Sort object
result = Object.fromEntries(Object.entries(result)
.sort(([, { steps: a }], [, { steps: b }]) => sum(b) - sum(a)));
consola.success('Parsing mods done');
return result;
}
export function getMcLoadTime(debug_log) {
const listOfLoadTime = [...debug_log.matchAll(mcFinishLoadingRgx)].map(([, , v]) => Number.parseFloat(v));
return Math.max(0, ...listOfLoadTime);
}
export function getJeiPlugins(debug_log) {
const pluginRgx = /\[(?:jei|Had\s?Enough\s?Items)\]: Registered +plugin: (?<name>.*) in (?<time>\d+) ms/g;
const jeiPlugins = [];
for (const { groups } of debug_log.matchAll(pluginRgx)) {
const { name, time } = groups;
// Filter plugins with same name (happens with /ct jeiReload command)
if (jeiPlugins.find(o => o.name === name))
continue;
jeiPlugins.push({ name, time: Number.parseInt(time) / 1000 });
}
jeiPlugins.sort(({ time: a }, { time: b }) => b - a);
const result = Object.fromEntries(jeiPlugins
.map(o => [o.name, o.time]));
if (Object.keys(result).length === 0) {
consola.info(`Cannot find any of ${chalk.hex('c2aa0e')('JEI')} plugins`);
}
return result;
}
async function addParts(mods, part) {
// eslint-disable-next-line unicorn/prefer-number-properties
if (!part.time || isNaN(part.time)) {
return consola.info(`Didnt add mod `
+ `"${chalk.hex('c2aa0e')(part.name)}" part of mod `
+ `${chalk.hex('94852e')(part.rgx)} since it took 0 sec or less`);
}
const entry = Object.entries(mods).find(([modName]) => typeof part.rgx === 'string'
? modName === part.rgx
: part.rgx.test(modName));
if (!entry) {
return consola.warn(`Could not find part `
+ `"${chalk.hex('c2aa0e')(part.name)}" with regex `
+ `${chalk.hex('94852e')(part.rgx)}`);
}
const [modName, mod] = entry;
const fmlStepsKeys = Object.keys(loaderSteps);
if (part.step === 'Other') {
mod.steps[fmlStepsKeys.length] += part.time;
}
else {
const stepIndex = fmlStepsKeys.indexOf(part.step);
if (mod.steps[stepIndex] - part.time < 0) {
consola.info(`${chalk.hex('558855').bold(modName)} `
+ `step ${part.step} `
+ `time: ${chalk.hex('558855')(mod.steps[stepIndex])}s, but `
+ `'${chalk.hex('558855').bold(part.name)}' is `
+ `${chalk.hex('558855')(part.time)}s.`);
}
else {
mod.steps[stepIndex] -= part.time;
}
}
mod.parts ??= [];
mod.parts.push({
color: Color(`#${mod.color}`).darken(0.15).hex().slice(1),
time: part.time,
name: part.name,
});
}
function toSeconds(groups) {
if (!groups)
return 0;
return groups.time === 's'
? Number.parseFloat(groups.num)
: Number.parseFloat(groups.num) / 1000.0;
}
const fmlStuffRegexps = [
/\[Client thread\/DEBUG\] \[FML\]: Bar Finished: (?<name>.*) took (?<time>\d+\.\d+)s/g,
/(?<name>\[VintageFix\]: Texture search) took (?<time>\d+\.\d+) s, total of(?<r1> \d+) collected (?<r2>sprites)/g,
/\[Client thread\/INFO\] \[VintageFix\]: (?<name>Preloaded \d+ sprites) in (?<time>\d+(\.\d+)?) s/g,
];
export function getFmlStuff(debug_log) {
const bars = {};
for (const rgx of fmlStuffRegexps) {
for (const { groups } of debug_log.matchAll(rgx)) {
const { name, time, ...desc } = groups;
const namePure = name
.replace(/\$.*/, '') // Metadata
.replace(/ - done/, ''); // done message
if (/Applying remove (?:recipes without ingredients|recipe actions).*|^Loading$/.test(namePure))
continue;
const timePure = Number.parseFloat(time);
const nameAndDesc = namePure + Object.values(desc).join(' ');
bars[nameAndDesc] ??= 0;
bars[nameAndDesc] += timePure;
}
}
const fmlStuffBars = Object.entries(bars)
.filter(([name]) => !Object.values(loaderSteps)
.some(rgx => name.match(rgx.source.replace(/ - .*/, ''))));
let colPointer = Color('orange').rotate(-20).darken(0.4);
return fmlStuffBars
// .filter(([,time]) => time >= 0.01)
.map(([name, time]) => ({
color: (colPointer = colPointer.rotate(4)).hex().slice(1),
name,
time,
}));
}
function logLineDuration(lines, rgxText) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const currTime = line.match(new RegExp(`^\\[(\\d+:\\d+:\\d+)\\] \\[[^\\]]+\\] ${rgxText}`))?.[1];
if (!currTime)
continue;
for (let j = i - 1; j >= 0; j--) {
const prevTime = lines[j].match(/^\[(\d+:\\d+:\\d+)\]/)?.[1];
if (!prevTime)
continue;
const diffTime = timeToSeconds(currTime) - timeToSeconds(prevTime);
if (diffTime > 0)
return diffTime;
}
break;
}
return 0;
}
//# sourceMappingURL=parse.js.map