From 5008d82eaf83b916f3b0158bb6d5311d6de7e8a7 Mon Sep 17 00:00:00 2001 From: R2DLiu Date: Mon, 29 Jun 2020 23:00:35 -0400 Subject: [PATCH] slippi game ported and restructured somewhat. TODO: refactor --- CMakeLists.txt | 2 + Externals/Slippi/CMakeLists.txt | 21 ++ Externals/Slippi/SlippiGame.cpp | 557 ++++++++++++++++++++++++++++++ Externals/Slippi/SlippiGame.h | 142 ++++++++ Externals/nlohmann/CMakeLists.txt | 0 Externals/nlohmann/json.hpp | 0 Source/Core/Core/CMakeLists.txt | 1 + 7 files changed, 723 insertions(+) create mode 100644 Externals/Slippi/CMakeLists.txt create mode 100644 Externals/Slippi/SlippiGame.cpp create mode 100644 Externals/Slippi/SlippiGame.h create mode 100644 Externals/nlohmann/CMakeLists.txt create mode 100644 Externals/nlohmann/json.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 26484938a3..e80d07e1db 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -545,6 +545,8 @@ else() endif() add_subdirectory(Externals/glslang) add_subdirectory(Externals/imgui) +add_subdirectory(Externals/Slippi) +include_directories(Externals/Slippi) find_package(pugixml) if(NOT pugixml_FOUND) diff --git a/Externals/Slippi/CMakeLists.txt b/Externals/Slippi/CMakeLists.txt new file mode 100644 index 0000000000..f4b2f1c172 --- /dev/null +++ b/Externals/Slippi/CMakeLists.txt @@ -0,0 +1,21 @@ +project(Slippi + VERSION 1.0.0) + +set(SRCS + SlippiGame.h + SlippiGame.cpp +) +cmake_minimum_required(VERSION 3.14 FATAL_ERROR) + +set(CMAKE_CXX_STANDARD 17) +add_definitions(-std=c++17) + +if(NOT MSVC) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -Wextra -Wno-unused-parameter") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -Wno-unused-parameter") +endif() + +add_library(Slippi STATIC ${SRCS}) + +target_include_directories(Slippi PUBLIC "../nlohmann") + diff --git a/Externals/Slippi/SlippiGame.cpp b/Externals/Slippi/SlippiGame.cpp new file mode 100644 index 0000000000..c67b5f182a --- /dev/null +++ b/Externals/Slippi/SlippiGame.cpp @@ -0,0 +1,557 @@ +#include "SlippiGame.h" + +namespace Slippi { + // TODO: maybe refactor with std::byte and std::filesystem + + //********************************************************************** + //* Event Handlers * + //********************************************************************** + // The read operators will read a value and increment the index so the next read will read in the correct location + uint8_t readByte(uint8_t* a, int& idx, uint32_t maxSize, uint8_t defaultValue) { + if (idx >= (int)maxSize) { + idx += 1; + return defaultValue; + } + + return a[idx++]; + } + + uint16_t readHalf(uint8_t* a, int& idx, uint32_t maxSize, uint16_t defaultValue) { + if (idx >= (int)maxSize) { + idx += 2; + return defaultValue; + } + + uint16_t value = a[idx] << 8 | a[idx + 1]; + idx += 2; + return value; + } + + uint32_t readWord(uint8_t* a, int& idx, uint32_t maxSize, uint32_t defaultValue) { + if (idx >= (int)maxSize) { + idx += 4; + return defaultValue; + } + + uint32_t value = a[idx] << 24 | a[idx + 1] << 16 | a[idx + 2] << 8 | a[idx + 3]; + idx += 4; + return value; + } + + float readFloat(uint8_t* a, int& idx, uint32_t maxSize, float defaultValue) { + uint32_t bytes = readWord(a, idx, maxSize, *(uint32_t*)(&defaultValue)); + return *(float*)(&bytes); + } + + void handleGameInit(Game &game, uint32_t maxSize) { + int idx = 0; + + // Read version number + for (int i = 0; i < 4; i++) { + game.version[i] = readByte(data, idx, maxSize, 0); + } + + // Read entire game info header + for (int i = 0; i < GAME_INFO_HEADER_SIZE; i++) { + game.settings.header[i] = readWord(data, idx, maxSize, 0); + } + + // Load random seed + game.settings.randomSeed = readWord(data, idx, maxSize, 0); + + // Read UCF toggle bytes + bool shouldRead = game.version[0] >= 1; + for (int i = 0; i < UCF_TOGGLE_SIZE; i++) { + uint32_t value = shouldRead ? readWord(data, idx, maxSize, 0) : 0; + game.settings.ucfToggles[i] = value; + } + + // Read nametag for each player + std::array, 4> playerNametags; + for (int i = 0; i < 4; i++) { + for (int j = 0; j < NAMETAG_SIZE; j++) { + playerNametags[i][j] = readHalf(data, idx, maxSize, 0); + } + } + + // Read isPAL byte + game.settings.isPAL = readByte(data, idx, maxSize, 0); + + // Read isFrozenPS byte + game.settings.isFrozenPS = readByte(data, idx, maxSize, 0); + + // Pull header data into struct + int player1Pos = 24; // This is the index of the first players character info + std::array gameInfoHeader = game.settings.header; + for (int i = 0; i < 4; i++) { + // this is the position in the array that this player's character info is stored + int pos = player1Pos + (9 * i); + + uint32_t playerInfo = gameInfoHeader[pos]; + uint8_t playerType = (playerInfo & 0x00FF0000) >> 16; + if (playerType == 0x3) { + // Player type 3 is an empty slot + continue; + } + + PlayerSettings p; + + // Get player settings + p.controllerPort = i; + p.characterId = playerInfo >> 24; + p.playerType = playerType; + p.characterColor = playerInfo & 0xFF; + p.nametag = playerNametags[i]; + + //Add player settings to result + game.settings.players[i] = p; + } + + game.settings.stage = gameInfoHeader[3] & 0xFFFF; + + auto majorVersion = game.version[0]; + auto minorVersion = game.version[1]; + if (majorVersion > 3 || (majorVersion == 3 && minorVersion >= 1)) { + // After version 3.1.0 we added a dynamic gecko loading process. These + // are needed before starting the game. areSettingsLoaded will be set + // to true when they are received + game.areSettingsLoaded = false; + } + else if (majorVersion > 1 || (majorVersion == 1 && minorVersion >= 6)) { + // Indicate settings loaded immediately if after version 1.6.0 + // Sheik game info was added in this version and so we no longer + // need to wait + game.areSettingsLoaded = true; + } + } + + void handleGeckoList(Game &game, uint32_t maxSize) { + game.settings.geckoCodes.clear(); + game.settings.geckoCodes.insert(game.settings.geckoCodes.end(), data, data + maxSize); + + // File is good to load + game.areSettingsLoaded = true; + } + + void handleFrameStart(Game &game, uint32_t maxSize) { + int idx = 0; + + //Check frame count + int32_t frameCount = readWord(data, idx, maxSize, 0); + game.frameCount = frameCount; + + auto frameUniquePtr = std::make_unique(); + FrameData* frame = frameUniquePtr.get(); + + frame->frame = frameCount; + frame->randomSeedExists = true; + frame->randomSeed = readWord(data, idx, maxSize, 0); + + // Add frame to game. The frames are stored in multiple ways because + // for games with rollback, the same frame may be replayed multiple times + frame->numSinceStart = game.frames.size(); + game.frames.push_back(std::move(frameUniquePtr)); + game.framesByIndex[frameCount] = frame; + } + + void handlePreFrameUpdate(Game &game, uint8_t const preFrameUpdate, uint32_t maxSize) { + int idx = 0; + + //Check frame count + int32_t frameCount = readWord(data, idx, maxSize, 0); + game.frameCount = frameCount; + + auto frameUniquePtr = std::make_unique(); + FrameData* frame = frameUniquePtr.get(); + bool isNewFrame = true; + + if (game.framesByIndex.count(frameCount)) { + // If this frame already exists, get the current frame + frame = game.frames.back().get(); + isNewFrame = false; + } + + frame->frame = frameCount; + + PlayerFrameData p; + + uint8_t playerSlot = readByte(data, idx, maxSize, 0); + uint8_t isFollower = readByte(data, idx, maxSize, 0); + + //Load random seed for player frame update + p.randomSeed = readWord(data, idx, maxSize, 0); + + //Load player data + p.animation = readHalf(data, idx, maxSize, 0); + p.locationX = readFloat(data, idx, maxSize, 0); + p.locationY = readFloat(data, idx, maxSize, 0); + p.facingDirection = readFloat(data, idx, maxSize, 0); + + //Controller information + p.joystickX = readFloat(data, idx, maxSize, 0); + p.joystickY = readFloat(data, idx, maxSize, 0); + p.cstickX = readFloat(data, idx, maxSize, 0); + p.cstickY = readFloat(data, idx, maxSize, 0); + p.trigger = readFloat(data, idx, maxSize, 0); + p.buttons = readWord(data, idx, maxSize, 0); + + //Raw controller information + p.physicalButtons = readHalf(data, idx, maxSize, 0); + p.lTrigger = readFloat(data, idx, maxSize, 0); + p.rTrigger = readFloat(data, idx, maxSize, 0); + + if (preFrameUpdate >= 59) { + p.joystickXRaw = readByte(data, idx, maxSize, 0); + } + + uint32_t noPercent = 0xFFFFFFFF; + p.percent = readFloat(data, idx, maxSize, *(float*)(&noPercent)); + + // Add player data to frame + std::unordered_map* target; + target = isFollower ? &frame->followers : &frame->players; + + // Set the player data for the player or follower + target->operator[](playerSlot) = p; + + // Add frame to game + if (isNewFrame) { + frame->numSinceStart = game.frames.size(); + game.frames.push_back(std::move(frameUniquePtr)); + game.framesByIndex[frameCount] = frame; + } + } + + void handlePostFrameUpdate(Game &game, uint32_t maxSize) { + int idx = 0; + + //Check frame count + int32_t frameCount = readWord(data, idx, maxSize, 0); + + FrameData* frame; + if (game.framesByIndex.count(frameCount)) { + // If this frame already exists, get the current frame + frame = game.frames.back().get(); + } + + // As soon as a post frame update happens, we know we have received all the inputs + // This is used to determine if a frame is ready to be used for a replay (for mirroring) + frame->inputsFullyFetched = true; + + uint8_t playerSlot = readByte(data, idx, maxSize, 0); + uint8_t isFollower = readByte(data, idx, maxSize, 0); + + PlayerFrameData* p = isFollower ? &frame->followers[playerSlot] : &frame->players[playerSlot]; + + p->internalCharacterId = readByte(data, idx, maxSize, 0); + + // Check if a player started as sheik and update + if (frameCount == GAME_FIRST_FRAME && p->internalCharacterId == GAME_SHEIK_INTERNAL_ID) { + game.settings.players[playerSlot].characterId = GAME_SHEIK_EXTERNAL_ID; + } + + // Set settings loaded if this is the last character + if (frameCount == GAME_FIRST_FRAME) { + uint8_t lastPlayerIndex = 0; + for (auto it = frame->players.begin(); it != frame->players.end(); ++it) { + if (it->first <= lastPlayerIndex) { + continue; + } + + lastPlayerIndex = it->first; + } + + if (playerSlot >= lastPlayerIndex) { + game.areSettingsLoaded = true; + } + } + } + + void handleGameEnd(Game &game, uint32_t maxSize) { + int idx = 0; + + game.winCondition = readByte(data, idx, maxSize, 0); + } + + // This function gets the position where the raw data starts + int getRawDataPosition(std::ifstream* f) { + char buffer[2]; + f->seekg(0, std::ios::beg); + f->read(buffer, 2); + + if (buffer[0] == 0x36) { + return 0; + } + + if (buffer[0] != '{') { + // TODO: Do something here to cause an error + return 0; + } + + // TODO: Read ubjson file to find the "raw" element and return the start of it + // TODO: For now since raw is the first element the data will always start at 15 + return 15; + } + + uint32_t getRawDataLength(std::ifstream* f, int position, int fileSize) { + if (position == 0) { + return fileSize; + } + + char buffer[4]; + f->seekg(position - 4, std::ios::beg); + f->read(buffer, 4); + + uint8_t* byteBuf = (uint8_t*)& buffer[0]; + uint32_t length = byteBuf[0] << 24 | byteBuf[1] << 16 | byteBuf[2] << 8 | byteBuf[3]; + return length; + } + + std::unordered_map getMessageSizes(std::ifstream* f, int position) { + char buffer[2]; + f->seekg(position, std::ios::beg); + f->read(buffer, 2); + if (buffer[0] != EVENT_PAYLOAD_SIZES) { + return {}; + } + + int payloadLength = buffer[1]; + std::unordered_map messageSizes = { + { EVENT_PAYLOAD_SIZES, payloadLength } + }; + + std::vector messageSizesBuffer(payloadLength - 1); + f->read(&messageSizesBuffer[0], payloadLength - 1); + for (int i = 0; i < payloadLength - 1; i += 3) { + uint8_t command = messageSizesBuffer[i]; + + // Extract the bytes in u8s. Without this the chars don't or together well + uint8_t byte1 = messageSizesBuffer[i + 1]; + uint8_t byte2 = messageSizesBuffer[i + 2]; + + uint16_t size = byte1 << 8 | byte2; + messageSizes[command] = size; + } + + return messageSizes; + } + + bool SlippiGame::AreSettingsLoaded() { + processData(); + return game.areSettingsLoaded; + }; + + bool SlippiGame::DoesFrameExist(int32_t frame) { + processData(); + return (bool)game.framesByIndex.count(frame); + }; + + std::array SlippiGame::GetVersion() { + return game.version; + } + + FrameData* SlippiGame::GetFrame(int32_t frame) { + // Get the frame we want + return game.framesByIndex.at(frame); + }; + + FrameData* SlippiGame::GetFrameAt(uint32_t pos) { + if (pos >= game.frames.size()) { + return nullptr; + } + + // Get the frame we want + return game.frames[pos].get(); + }; + + int32_t SlippiGame::GetLatestIndex() { + processData(); + return game.frameCount; + }; + + GameSettings* SlippiGame::GetSettings() { + processData(); + return &game.settings; + }; + + bool SlippiGame::DoesPlayerExist(int8_t port) { + return game.settings.players.find(port) != game.settings.players.end(); + }; + + bool SlippiGame::IsProcessingComplete() { + return isProcessingComplete; + } + + void SlippiGame::processData() { + if (isProcessingComplete) { + // If we have finished processing this file, return + return; + } + + // This function will process as much data as possible + int startPos = (int)file->tellg(); + file->seekg(startPos); + if (startPos == 0) { + file->seekg(0, std::ios::end); + int len = (int)file->tellg(); + if (len < 2) { + // If we can't read message sizes payload size yet, return + return; + } + + int rawDataPos = getRawDataPosition(file.get()); + int rawDataLen = len - rawDataPos; + if (rawDataLen < 2) { + // If we don't have enough raw data yet to read the replay file, return + // Reset to begining so that the startPos condition will be hit again + file->seekg(0); + return; + } + + startPos = rawDataPos; + + char buffer[2]; + file->seekg(startPos); + file->read(buffer, 2); + file->seekg(startPos); + auto messageSizesSize = (int)buffer[1]; + if (rawDataLen < messageSizesSize) { + // If we haven't received the full payload sizes message, return + // Reset to begining so that the startPos condition will be hit again + file->seekg(0); + return; + } + + asmEvents = getMessageSizes(file.get(), rawDataPos); + } + + // Read everything to the end + file->seekg(0, std::ios::end); + int endPos = (int)file->tellg(); + int sizeToRead = endPos - startPos; + file->seekg(startPos); + //log << "Size to read: " << sizeToRead << "\n"; + //log << "Start Pos: " << startPos << "\n"; + //log << "End Pos: " << endPos << "\n\n"; + if (sizeToRead <= 0) { + return; + } + + std::vector newData(sizeToRead); + file->read(&newData[0], sizeToRead); + + int newDataPos = 0; + while (newDataPos < sizeToRead) { + auto command = newData[newDataPos]; + auto payloadSize = asmEvents[command]; + + //char buff[100]; + //snprintf(buff, sizeof(buff), "%x", command); + //log << "Command: " << buff << " | Payload Size: " << payloadSize << "\n"; + + auto remainingLen = sizeToRead - newDataPos; + if (remainingLen < ((int)payloadSize + 1)) { + // Here we don't have enough data to read the whole payload + // Will be processed after getting more data (hopefully) + file->seekg(-remainingLen, std::ios::cur); + return; + } + + data = (uint8_t*)& newData[newDataPos + 1]; + + uint8_t isSplitComplete = false; + uint32_t outerPayloadSize = payloadSize; + + // Handle a split message, combining in until we possess the entire message + if (command == EVENT_SPLIT_MESSAGE) { + if (shouldResetSplitMessageBuf) + { + splitMessageBuf.clear(); + shouldResetSplitMessageBuf = false; + } + + int _ = 0; + uint16_t blockSize = readHalf(&data[SPLIT_MESSAGE_INTERNAL_DATA_LEN], _, payloadSize, 0); + splitMessageBuf.insert(splitMessageBuf.end(), data, data + blockSize); + + isSplitComplete = data[SPLIT_MESSAGE_INTERNAL_DATA_LEN + 3]; + if (isSplitComplete) + { + // Transform this message into a different message + command = data[SPLIT_MESSAGE_INTERNAL_DATA_LEN + 2]; + data = &splitMessageBuf[0]; + payloadSize = asmEvents[command]; + shouldResetSplitMessageBuf = true; + } + } + + switch (command) { + case EVENT_GAME_INIT: + handleGameInit(game, payloadSize); + break; + case EVENT_GECKO_LIST: + handleGeckoList(game, payloadSize); + break; + case EVENT_FRAME_START: + handleFrameStart(game, payloadSize); + break; + case EVENT_PRE_FRAME_UPDATE: + handlePreFrameUpdate(game, asmEvents[EVENT_PRE_FRAME_UPDATE], payloadSize); + break; + case EVENT_POST_FRAME_UPDATE: + handlePostFrameUpdate(game, payloadSize); + break; + case EVENT_GAME_END: + handleGameEnd(game, payloadSize); + isProcessingComplete = true; + break; + case 0x55: + // This is sort of a hack to prevent this functioning + // from processing the metadata as raw data. 0x55 is 'U' + // which is the first character after the raw data in the + // ubjson file format + //log.close(); + isProcessingComplete = true; + file->seekg(-remainingLen, std::ios::cur); + return; + } + + payloadSize = isSplitComplete ? outerPayloadSize : payloadSize; + newDataPos += payloadSize + 1; + } + } + + std::unique_ptr SlippiGame::FromFile(std::string path) { + auto result = std::make_unique(); + result->path = path; + +#ifdef _WIN32 + // On Windows, we need to convert paths to std::wstring to deal with UTF-8 + + // TODO: codecvt is deprecated. C++17 msvc support std::filesystem::u8path + // TODO: c++20 std::filesystem::path natively supports utf8 + std::wstring convertedPath = std::wstring_convert>().from_bytes(path); + result->file = std::make_unique(convertedPath, std::ios::in | std::ios::binary); +#else + result->file = std::make_unique(path, std::ios::in | std::ios::binary); +#endif + + //result->log.open("log.txt"); + if (!result->file->is_open()) { + return nullptr; + } + + //int fileLength = (int)file.tellg(); + //int rawDataPos = getRawDataPosition(&file); + //uint32_t rawDataLength = getRawDataLength(&file, rawDataPos, fileLength); + //asmEvents = getMessageSizes(&file, rawDataPos); + + //std::vector rawData(rawDataLength); + //file.seekg(rawDataPos, std::ios::beg); + //file.read(&rawData[0], rawDataLength); + + //SlippiGame* result = processFile((uint8_t*)&rawData[0], rawDataLength); + + return std::move(result); + } +} diff --git a/Externals/Slippi/SlippiGame.h b/Externals/Slippi/SlippiGame.h new file mode 100644 index 0000000000..479d503c68 --- /dev/null +++ b/Externals/Slippi/SlippiGame.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slippi { + const uint8_t EVENT_SPLIT_MESSAGE = 0x10; + const uint8_t EVENT_PAYLOAD_SIZES = 0x35; + const uint8_t EVENT_GAME_INIT = 0x36; + const uint8_t EVENT_PRE_FRAME_UPDATE = 0x37; + const uint8_t EVENT_POST_FRAME_UPDATE = 0x38; + const uint8_t EVENT_GAME_END = 0x39; + const uint8_t EVENT_FRAME_START = 0x3A; + const uint8_t EVENT_GECKO_LIST = 0x3D; + + const uint8_t GAME_INFO_HEADER_SIZE = 78; + const uint8_t UCF_TOGGLE_SIZE = 8; + const uint8_t NAMETAG_SIZE = 8; + const int32_t GAME_FIRST_FRAME = -123; + const int32_t PLAYBACK_FIRST_SAVE = -122; + const uint8_t GAME_SHEIK_INTERNAL_ID = 0x7; + const uint8_t GAME_SHEIK_EXTERNAL_ID = 0x13; + + const uint32_t SPLIT_MESSAGE_INTERNAL_DATA_LEN = 512; + + static uint8_t* data; + + typedef struct { + // Every player update has its own rng seed because it might change in between players + uint32_t randomSeed; + + uint8_t internalCharacterId; + uint16_t animation; + float locationX; + float locationY; + float facingDirection; + uint8_t stocks; + float percent; + float shieldSize; + uint8_t lastMoveHitId; + uint8_t comboCount; + uint8_t lastHitBy; + + //Controller information + float joystickX; + float joystickY; + float cstickX; + float cstickY; + float trigger; + uint32_t buttons; //This will include multiple "buttons" pressed on special buttons. For example I think pressing z sets 3 bits + + //This is extra controller information + uint16_t physicalButtons; //A better representation of what a player is actually pressing + float lTrigger; + float rTrigger; + + uint8_t joystickXRaw; + } PlayerFrameData; + + typedef struct { + int32_t frame; + uint32_t numSinceStart; + bool randomSeedExists = false; + uint32_t randomSeed; + bool inputsFullyFetched = false; + std::unordered_map players; + std::unordered_map followers; + } FrameData; + + typedef struct { + //Static data + uint8_t characterId; + uint8_t characterColor; + uint8_t playerType; + uint8_t controllerPort; + std::array nametag; + } PlayerSettings; + + typedef struct { + uint16_t stage; //Stage ID + uint32_t randomSeed; + std::array header; + std::array ucfToggles; + std::unordered_map players; + uint8_t isPAL; + uint8_t isFrozenPS; + std::vector geckoCodes; + } GameSettings; + + typedef struct { + std::array version; + std::unordered_map framesByIndex; + std::vector> frames; + GameSettings settings; + bool areSettingsLoaded = false; + + int32_t frameCount; // Current/last frame count + + //From OnGameEnd event + uint8_t winCondition; + } Game; + + class SlippiGame { + public: + bool AreSettingsLoaded(); + bool DoesFrameExist(int32_t frame); + std::array GetVersion(); + FrameData* GetFrame(int32_t frame); + FrameData* GetFrameAt(uint32_t pos); + int32_t GetLatestIndex(); + GameSettings* GetSettings(); + bool DoesPlayerExist(int8_t port); + bool IsProcessingComplete(); + private: + Game game; + std::unique_ptr file; + std::vector rawData; + std::string path; + std::ofstream log; + std::vector splitMessageBuf; + std::unordered_map asmEvents = { + { EVENT_GAME_INIT, 320 }, + { EVENT_PRE_FRAME_UPDATE, 58 }, + { EVENT_POST_FRAME_UPDATE, 33 }, + { EVENT_GAME_END, 1 }, + { EVENT_FRAME_START, 8 } + }; + bool shouldResetSplitMessageBuf = false; + bool isProcessingComplete = false; + + void processData(); + + static std::unique_ptr FromFile(std::string path); + }; +} diff --git a/Externals/nlohmann/CMakeLists.txt b/Externals/nlohmann/CMakeLists.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Externals/nlohmann/json.hpp b/Externals/nlohmann/json.hpp new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Source/Core/Core/CMakeLists.txt b/Source/Core/Core/CMakeLists.txt index 74ea1bfd86..4cb949a234 100644 --- a/Source/Core/Core/CMakeLists.txt +++ b/Source/Core/Core/CMakeLists.txt @@ -558,6 +558,7 @@ PUBLIC videoogl videosoftware videovulkan + Slippi PRIVATE fmt::fmt