Skip to content

Instantly share code, notes, and snippets.

@NikhilNarayana
Last active November 5, 2022 18:22
Show Gist options
  • Save NikhilNarayana/d45e328e9ea47127634f2faf575e8dcf to your computer and use it in GitHub Desktop.
Save NikhilNarayana/d45e328e9ea47127634f2faf575e8dcf to your computer and use it in GitHub Desktop.
This script is deprecated, go check out Project Clippi
const fs = require('fs');
const _ = require('lodash');
const path = require('path');
const crypto = require('crypto');
const moment = require('moment');
const slp = require('@slippi/slippi-js');
const SlippiGame = slp.SlippiGame; // npm install @slippi/slippi-js
const basePath = path.join(__dirname, 'slp/'); // this var is "<directory your script is in>/slp"
const outputFilename = "./combos.json";
const filterSet = "combos"; // can be either combos or conversions, conversions are from when a player is put into a punish state to when they escape or die
const dolphin = {
"mode": "queue",
"replay": "",
"isRealTimeMode": false,
"outputOverlayFiles": true,
"commandId": `${crypto.randomBytes(3 * 4).toString('hex')}`,
"queue": []
};
const fdCGers = [9, 12, 13, 22]; // Marth, Peach, Pikachu, and Doc
const filterByNames = []; // add names as strings to this array (checks both netplay name and nametags). `["Nikki", "Metonym", "metonym"]`
const filterByCharacters = []; //add character names as strings. Use the regular or shortnames from here: https://github.com/project-slippi/slp-parser-js/blob/master/src/melee/characters.ts
var minimumComboPercent = 60; // this decides the threshold for combos
var originalMin = minimumComboPercent; // we use this to reset the threshold
// Removal Statistics
var numWobbles = 0;
var numCG = 0;
var numCPU = 0;
var puffMiss = 0;
var badFiles = 0;
var noCombos = 0;
var notSingles = 0;
// just to provide variety in case some people combo a lot in the same game
function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
// allow putting files in folders
function walk(dir) {
let results = [];
let list = fs.readdirSync(dir);
_.each(list, (file) => {
file = path.join(dir, file);
let stat = fs.statSync(file);
if (stat && stat.isDirectory()) {
// Recurse into a subdirectory
results = results.concat(walk(file));
} else if (path.extname(file) === ".slp"){
results.push(file);
}
});
return results;
}
function filterCombos(combos, settings, metadata) {
return _.filter(combos, (combo) => {
var wobbles = [];
let pummels = 0;
let chaingrab = false;
minimumComboPercent = originalMin; // reset
let player = _.find(settings.players, (player) => player.playerIndex === combo.playerIndex);
if (filterByNames.length > 0) {
var matches = []
_.each(filterByNames, (filterName) => {
const netplayName = _.get(metadata, ["players", player.playerIndex, "names", "netplay"], null) || null;
const playerTag = _.get(player, "nametag") || null;
const names = [netplayName, playerTag];
matches.push(_.includes(names, filterName));
});
const filteredName = _.some(matches, (match) => match);
if (!filteredName) return filteredName;
}
if (filterByCharacters.length > 0) {
var matches = []
_.each(filterByCharacters, (filterChar) => {
const charName = slp.characters.getCharacterInfo(player.characterId).name;
const charShortName = slp.characters.getCharacterInfo(player.characterId).shortName;
const names = [charName, charShortName];
matches.push(_.includes(names, filterChar));
});
const filteredChar = _.some(matches, (match) => match);
if (!filteredChar) return filteredChar;
}
if (player.characterId === 15) {
minimumComboPercent += 25;
} else if (player.characterId === 14) { // check for a wobble (8 pummels or more in a row)
_.each(combo.moves, ({moveId}) => {
if (moveId === 52) {
pummels++;
} else {
wobbles.push(pummels);
pummels = 0;
}
});
wobbles.push(pummels);
} else if (_.includes(fdCGers, player.characterId)) {
const upthrowpummel = _.filter(combo.moves, ({moveId}) => moveId === 55 || moveId === 52).length;
const numMoves = combo.moves.length;
chaingrab = upthrowpummel / numMoves >= .8;
}
const wobbled = _.some(wobbles, (pummelCount) => pummelCount > 8);
const threshold = (combo.endPercent - combo.startPercent) > minimumComboPercent;
const totalDmg = _.sumBy(combo.moves, ({damage}) => damage);
const largeSingleHit = _.some(combo.moves, ({damage}) => damage/totalDmg >= .8);
if (wobbled) numWobbles++;
if (chaingrab) numCG++;
if (player.characterId === 15 && !threshold && (combo.endPercent - combo.startPercent) > originalMin) puffMiss++;
return !wobbled && !chaingrab && !largeSingleHit && combo.didKill && threshold;
});
}
function limitChar(combos, charID, maxCombos) {
// this function will return a new array with only {maxCombos} of the most damaging combos by {charID}
var charCombos = [...combos]; // create copy of the combos array
charCombos = _.filter(combos, (combo) => combo.additional.characterId === charID);
charCombos = _.orderBy(charCombos, (combo) => combo.additional.damageDone, 'desc');
charCombos = _.slice(charCombos, maxCombos);
return _.without(combos, ...charCombos);
}
function limitTime(combos, minutes) {
// this function will return a new array with the most damaging combos until the length of the combos is close to {minutes}
const frames = minutes * 3600;
const allCombos = [...combos]; // create copy of the combos array
var total = 0;
combos = _.orderBy(combos, (combo) => combo.additional.damageDone, 'asc'); // keep most damaging combos, use asc because pop only works on the end
while (total < frames) {
var combo = combos.pop();
total += (combo.endFrame - combo.startFrame);
}
return _.without(allCombos, ...combos);
}
function getCombos() {
let checkPercent = .1;
let files = walk(basePath);
console.log(`${files.length} files found, starting to filter for ${minimumComboPercent}% combos`);
_.each(files, (file, i) => {
try {
const game = new SlippiGame(file);
// since it is less intensive to get the settings and metadata we do that first
const settings = game.getSettings();
const metadata = game.getMetadata();
// skip to next file if CPU exists
const cpu = _.some(settings.players, (player) => player.type != 0)
const notsingles = settings.players.length != 2;
if (cpu) {
numCPU++;
return;
} else if (notsingles) {
notSingles++;
return;
}
// Calculate stats and pull out the combos
const stats = game.getStats();
const originalCombos = _.get(stats, filterSet);
// filter out any non-killing combos and low percent combos
const combos = filterCombos(originalCombos, settings, metadata);
// create objects that will populate the queue and make sure they stay within the bounds of each file
_.each(combos, ({startFrame, endFrame, playerIndex, endPercent, startPercent}) => {
let player = _.find(settings.players, (player) => player.playerIndex === playerIndex);
let opponent = _.find(settings.players, (player) => player.playerIndex !== playerIndex);
let properTime = moment(_.get(metadata, "startAt", ""));
/*
adding a buffer is helpful in editing and for dealing with audio playback during FFW.
the buffer is basically to give us some room before the combo to cut and some time after the combo to ease out into the next combo
FFWing is a built in function of the game engine where it skips drawing but audio will playback in real time
*/
let x = {
path: file,
startFrame: startFrame - 240 > -123 ? startFrame - 240 : -123,
endFrame: endFrame + 180 < metadata.lastFrame ? endFrame + 180 : metadata.lastFrame,
gameStartAt: properTime,
gameStation: _.get(metadata, "consoleNick", ""),
additional: {
characterId: player.characterId,
characterName: slp.characters.getCharacterInfo(player.characterId).name,
opponentCharacterId: opponent.characterId,
opponentCharacterName: slp.characters.getCharacterInfo(opponent.characterId).name,
damageDone: endPercent-startPercent,
}
};
dolphin.queue.push(x);
});
combos.length === 0 ? noCombos++ : console.log(`File ${i+1} | ${combos.length} combo(s) found in ${path.basename(file)}`);
} catch (err) {
fs.appendFileSync("./log.txt", `${err.stack}\n\n`);
badFiles++;
console.log(`File ${i+1} | ${file} is bad`);
}
if (i/files.length >= checkPercent && checkPercent != 1) {
console.log(`About ${(checkPercent * 100).toFixed(0)}% of files have been checked`);
checkPercent += .1;
}
});
// dolphin.queue = shuffle(dolphin.queue); // not needed as of Slippi 2.0.0
// dolphin.queue = limitChar(dolphin.queue, 14, 5); // this example limits ICs to 5 combos
// dolphin.queue = limitTime(dolphin.queue, 10); // this example limits us to 10 minutes of combos
// dolphin.queue = _.sortBy(dolphin.queue, (x) => x.gameStartAt); // usable in r19
fs.writeFileSync(outputFilename, JSON.stringify(dolphin));
console.log(`${badFiles} bad file(s) ignored`);
console.log(`${numCPU} game(s) with CPUs removed`);
console.log(`${numWobbles} wobble(s) removed`);
console.log(`${numCG} chaingrab(s) removed`);
console.log(`${puffMiss} Puff combo(s) removed\n`);
console.log(`${dolphin.queue.length} good combo(s) found`)
console.log(`${((noCombos+badFiles) / (files.length - badFiles - numCPU - notSingles) * 100).toFixed(2)}% of usable files had no valid combos`);
}
// getCombos();
console.log("This script is deprecated for user use. Developers can still mess with the code if they want to learn something.");
console.log("If you want to make a Slippi Combo video, use Project Clippi (https://github.com/vinceau/project-clippi/blob/master/README.md)");
@NikhilNarayana
Copy link
Author

NikhilNarayana commented May 24, 2019

I'm deprecating this script in favor of Project Clippi. There will be no future updates to this script

This script will stay up for educational purposes.

Get Slippi Combos

This is not an official Project Slippi script (you can check the project-slippi repo to find Fizzi's, but mine might be a bit more user friendly), I'm just open sourcing my personal work.

The above code is available under the GPLv3 License. Do not distribute binaries/modified code without releasing the source code.

I've only ever tested this process on Windows, so if you have issues on macOS or Linux I will be unable to provide support.

Filters

  • Less than the minimumComboPercentage threshold
  • Combos that don't kill
  • Wobbles (8 or more pummels)
  • Chaingrabs (80% of moves used in a combo are either a pummel or upthrow for characters listed on line 24)
  • Large Single Hits (A single move does 80% of the total damage in the combo)
  • Puff (she has to do 25% more than the normal minimum combo percentage)
  • In-Game Tags and Netplay Names (edit line 26 with the names you would like to filter by)
  • Characters (edit line 27)

Steps

Making a combo video is still a multi-step process, hopefully it will be simpler in the future.

High Level

  1. Read the readme for slp-parser-js (https://github.com/project-slippi/slp-parser-js) to extract combos from slp files
  2. Filter on the combos you want to include and generate a json file to use as an input for Dolphin that looks like the following: https://gist.github.com/JLaferri/a4f67390d7d5a39be89857c3375985c9
  3. Make sure your JSON is valid using a validator if you are unsure. If your JSON is not valid you will not get past Waiting for game...
  4. Boot Dolphin-Slippi latest version (the one used for desktop app playback) using the command Dolphin.exe -i "C:/path/to/comboinput.json" (or the similar version for your OS)
  5. Start melee and record the output with OBS or similar.
  6. Trim out all the parts in between clips.

Script Specific

  1. Install NodeJS (latest node10 version).
  2. Download this script and put it in a folder.
  3. Open a Command Prompt/Terminal window, navigate to the folder where you stored the script, and run
    npm install slp-parser-js moment. You should see a node_modules folder if it worked.
  4. Create a folder called slp inside the folder from the last step and put all your Slippi files in it.
  5. Open the script and consider editing lines with a maybe replace this comment. Do not delete anything, only update the values
  6. Run the script using node <filename> to generate a combos.json file.
  7. Boot the Slippi-Dolphin used for desktop app playback (check the settings page to find the path) using the command Dolphin.exe -i "C:/path/to/combos.json" (the macOS equivalent would be Dolphin.app/Contents/MacOS/Dolphin -i /path/to/combos.json).
  8. Adjust the Dolphin settings to your preference.
  9. Start melee and record the output with OBS or similar.
  10. Trim out all the parts in between clips.

Possible Issues

  1. No Combos
    Lower the threshold, change filterSet to be conversions (line 13), or just git gud scrub

  2. node_modules folder is not created
    You will have to run npm init in your working directory first. This is hit or miss, sometimes node_modules gets created just fine, but other times it fails.

Suggested "Upgrades"

  • To speed up the waiting time between combos, consider increasing the emulated clock speed in Dolphin if you have a good enough computer.
  • If you don't have a good enough computer to record in OBS and emulate Dolphin, consider using single core emulation and frame dumping. It will take longer, but guarantees good quality and 60fps.

FAQ & Notes

  • How do I disable a filter?
    Remove a conditional from line 125 that corresponds to what you want to not filter out. If you want to remove the puff threshold increase, delete line 100.
  • Can you add xyz filter?
    I could, but I won't unless I think it becomes too prevalent in combo videos generated by this script.
  • How do I remove all stuff between the combos quickly?
    If you have experience with ffmpeg then I highly suggest learning how to trim black frames. Otherwise you will have to do it manually in your video editor of choice. I know a few people are huge fans of avidemux because you can jump by keyframes. I use Davinci Resolve because it supports auto cutting by frames if you use the scene cut detection feature.
  • When I start Melee I get sent to the Character Select Screen.
    You have to use the Playback Dolphin included in the Slippi Desktop App.
  • How do I can I show the station name and time of the replay in OBS?
    If you create a Slippi folder in the folder you are running Dolphin from, text files will appear in the Slippi folder that contain the station name and the time of the replay. Then just make text sources in OBS that read from a file.
  • How should I credit you?
    Drop a link to this script and Fizzi's Patreon in your video description. None of this would be possible without the years of work he has put into Slippi.

If you are seeing clips that clearly aren't combos or don't kill, please send me the SLP file on discord (find me on the Slippi server), thank you.

Credits

  • Fizzi36 - Project Slippi Creator
  • DomHymes - Provided example code for wobble detection
  • and all the people that have reported issues

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment