// ReSkateEmotePacker: builds the chat emote pack ReSkate.dll bakes in (assets/emotes) from a // folder of pictures and GIFs. Every file becomes one emote named after the file; animated // GIFs keep their frames and timing. Output: emotes.json + emotes.png in Better Chat's format // (Extension/UI/Overlay/chat_emotes.h), within the limits ReSkate's loader accepts. // // ReSkateEmotePacker [output folder] [--size ] [--max-frames ] // [--max-width ] [--name ] // // Dropping a folder on the exe writes the pack next to it, in "_pack". #include "Engine/Core/Json/json.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include using Microsoft::WRL::ComPtr; namespace fs = std::filesystem; using dingosdk::Json; namespace { // ReSkate's loader limits (chat_emotes.cpp): frames at most 256 x 256, names of letters, // digits and _ up to 32 characters, 4096 frames in all, a texture at most 8192 x 8192. constexpr int max_side = 256, max_texture = 8192; constexpr std::size_t max_name = 32, max_total_frames = 4096; struct Options { fs::path input, output; int size = 56; // emote height in pixels (wide emotes may be shorter) int max_width = 2048; // atlas width std::size_t max_frames = 64; // per animated emote; longer GIFs are thinned evenly std::string name = "ReSkate"; }; struct Image { int width{}, height{}; std::vector rgba; // straight alpha }; struct Frame { Image image; unsigned time{}; // ms int x{}, y{}; // in the atlas }; struct Emote { std::string name, file; std::vector frames; }; void check(HRESULT result, const char *what) { if (FAILED(result)) { char text[160]; std::snprintf(text, sizeof text, "%s failed (0x%08lx)", what, static_cast(result)); throw std::runtime_error(text); } } // Kept for the life of the process: releasing it after COM has shut down would crash on exit. IWICImagingFactory &factory() { static IWICImagingFactory *value = [] { IWICImagingFactory *f{}; check(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&f)), "Starting Windows Imaging"); return f; }(); return *value; } Image to_rgba(IWICBitmapSource *source) { ComPtr converter; check(factory().CreateFormatConverter(&converter), "Creating a format converter"); check(converter->Initialize(source, GUID_WICPixelFormat32bppRGBA, WICBitmapDitherTypeNone, nullptr, 0, WICBitmapPaletteTypeCustom), "Converting to RGBA"); UINT w{}, h{}; check(converter->GetSize(&w, &h), "Reading the size"); Image image{static_cast(w), static_cast(h)}; image.rgba.resize(static_cast(w) * h * 4); check(converter->CopyPixels(nullptr, w * 4, static_cast(image.rgba.size()), image.rgba.data()), "Reading pixels"); return image; } std::optional metadata_number(IWICMetadataQueryReader *reader, const wchar_t *path) { PROPVARIANT value; PropVariantInit(&value); std::optional result; if (reader && SUCCEEDED(reader->GetMetadataByName(path, &value))) { if (value.vt == VT_UI1) result = value.bVal; else if (value.vt == VT_UI2) result = value.uiVal; else if (value.vt == VT_UI4) result = value.ulVal; } PropVariantClear(&value); return result; } // Every frame of a picture as it shows: a GIF's frames are composited onto its canvas with // their offsets, transparency and disposal, as a browser plays them. Other formats: frame 1. std::vector decode(const fs::path &file) { ComPtr decoder; check(factory().CreateDecoderFromFilename(file.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnDemand, &decoder), "Opening the picture"); GUID container{}; decoder->GetContainerFormat(&container); UINT count{}; check(decoder->GetFrameCount(&count), "Counting frames"); std::vector frames; if (container != GUID_ContainerFormatGif || count <= 1) { ComPtr frame; check(decoder->GetFrame(0, &frame), "Reading the picture"); frames.push_back({to_rgba(frame.Get()), 0}); return frames; } ComPtr global; decoder->GetMetadataQueryReader(&global); const int width = static_cast(metadata_number(global.Get(), L"/logscrdesc/Width").value_or(0)); const int height = static_cast(metadata_number(global.Get(), L"/logscrdesc/Height").value_or(0)); if (width <= 0 || height <= 0 || width > 4096 || height > 4096) throw std::runtime_error("the GIF has no usable canvas size"); std::vector canvas(static_cast(width) * height * 4, 0), saved; struct Rect { int x{}, y{}, w{}, h{}; } previous; unsigned previous_disposal{}; for (UINT i = 0; i < count; ++i) { ComPtr frame; check(decoder->GetFrame(i, &frame), "Reading a GIF frame"); ComPtr reader; frame->GetMetadataQueryReader(&reader); const auto piece = to_rgba(frame.Get()); const Rect rect{static_cast(metadata_number(reader.Get(), L"/imgdesc/Left").value_or(0)), static_cast(metadata_number(reader.Get(), L"/imgdesc/Top").value_or(0)), piece.width, piece.height}; const auto delay = metadata_number(reader.Get(), L"/grctlext/Delay").value_or(10); const auto disposal = metadata_number(reader.Get(), L"/grctlext/Disposal").value_or(0); // The previous frame leaves the canvas as its disposal says. if (i > 0 && previous_disposal == 2) for (int y = std::max(previous.y, 0); y < std::min(previous.y + previous.h, height); ++y) for (int x = std::max(previous.x, 0); x < std::min(previous.x + previous.w, width); ++x) std::fill_n(canvas.begin() + (static_cast(y) * width + x) * 4, 4, static_cast(0)); else if (i > 0 && previous_disposal == 3 && !saved.empty()) canvas = saved; if (disposal == 3) saved = canvas; for (int y = 0; y < piece.height; ++y) for (int x = 0; x < piece.width; ++x) { const int cx = rect.x + x, cy = rect.y + y; if (cx < 0 || cy < 0 || cx >= width || cy >= height) continue; const auto *src = piece.rgba.data() + (static_cast(y) * piece.width + x) * 4; if (src[3] == 0) continue; // transparent pixels leave what is underneath std::copy_n(src, 4, canvas.begin() + (static_cast(cy) * width + cx) * 4); } // Browsers show delays under 20 ms as 100 ms. frames.push_back({{width, height, canvas}, delay < 2 ? 100U : delay * 10U}); previous = rect; previous_disposal = disposal; } return frames; } // Scaled to `height` (wide pictures to at most 256 wide), in premultiplied alpha so edges do // not darken. Image scale(const Image &image, int height) { int h = height, w = std::max(1, static_cast(std::lround(static_cast(image.width) * height / image.height))); if (w > max_side) { h = std::max(1, static_cast(std::lround(static_cast(h) * max_side / w))); w = max_side; } if (w == image.width && h == image.height) return image; ComPtr bitmap; check(factory().CreateBitmapFromMemory(image.width, image.height, GUID_WICPixelFormat32bppRGBA, image.width * 4, static_cast(image.rgba.size()), const_cast(image.rgba.data()), &bitmap), "Loading pixels"); ComPtr premultiplied; check(factory().CreateFormatConverter(&premultiplied), "Creating a format converter"); check(premultiplied->Initialize(bitmap.Get(), GUID_WICPixelFormat32bppPRGBA, WICBitmapDitherTypeNone, nullptr, 0, WICBitmapPaletteTypeCustom), "Premultiplying"); ComPtr scaler; check(factory().CreateBitmapScaler(&scaler), "Creating a scaler"); check(scaler->Initialize(premultiplied.Get(), w, h, w < image.width ? WICBitmapInterpolationModeFant : WICBitmapInterpolationModeHighQualityCubic), "Scaling"); return to_rgba(scaler.Get()); } // Consecutive identical frames become one, showing for their combined time; more than // `limit` frames are thinned evenly, each kept frame showing for the ones it replaces. std::vector tidy(std::vector frames, std::size_t limit) { std::vector merged; for (auto &frame : frames) { if (!merged.empty() && merged.back().image.rgba == frame.image.rgba) merged.back().time += frame.time; else merged.push_back(std::move(frame)); } if (merged.size() <= limit) return merged; std::vector thinned; for (std::size_t i = 0; i < limit; ++i) { const auto from = i * merged.size() / limit, to = (i + 1) * merged.size() / limit; Frame kept = std::move(merged[from]); for (auto j = from + 1; j < to; ++j) kept.time += merged[j].time; thinned.push_back(std::move(kept)); } return thinned; } std::string emote_name(const fs::path &file) { std::string name; for (const char c : file.stem().string()) name += std::isalnum(static_cast(c)) ? c : '_'; if (name.size() > max_name) name.resize(max_name); return name.empty() ? std::string("emote") : name; } std::string lowered(std::string text) { for (auto &c : text) c = static_cast(std::tolower(static_cast(c))); return text; } // Rows of frames across the atlas, tallest rows first. int pack(std::vector &emotes, int width) { std::vector frames; for (auto &e : emotes) for (auto &f : e.frames) frames.push_back(&f); std::stable_sort(frames.begin(), frames.end(), [](const Frame *a, const Frame *b) { return a->image.height > b->image.height; }); constexpr int gap = 1; int x = 0, y = 0, row = 0; for (auto *f : frames) { if (x > 0 && x + f->image.width > width) { x = 0; y += row + gap; row = 0; } f->x = x; f->y = y; x += f->image.width + gap; row = std::max(row, f->image.height); } return y + row; } void write_png(const fs::path &file, int width, int height, const std::vector &rgba) { // The PNG encoder takes BGRA everywhere. std::vector bgra(rgba); for (std::size_t i = 0; i < bgra.size(); i += 4) std::swap(bgra[i], bgra[i + 2]); ComPtr stream; check(factory().CreateStream(&stream), "Creating the output file"); check(stream->InitializeFromFilename(file.c_str(), GENERIC_WRITE), "Opening the output file"); ComPtr encoder; check(factory().CreateEncoder(GUID_ContainerFormatPng, nullptr, &encoder), "Creating the PNG encoder"); check(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache), "Starting the PNG"); ComPtr frame; check(encoder->CreateNewFrame(&frame, nullptr), "Adding the PNG image"); check(frame->Initialize(nullptr), "Starting the PNG image"); check(frame->SetSize(width, height), "Sizing the PNG"); WICPixelFormatGUID format = GUID_WICPixelFormat32bppBGRA; check(frame->SetPixelFormat(&format), "Choosing the PNG format"); if (format != GUID_WICPixelFormat32bppBGRA) throw std::runtime_error("the PNG encoder does not take 32-bit BGRA"); check(frame->WritePixels(height, width * 4, static_cast(bgra.size()), bgra.data()), "Writing the PNG pixels"); check(frame->Commit(), "Finishing the PNG image"); check(encoder->Commit(), "Finishing the PNG"); } Options parse(int argc, wchar_t **argv) { Options o; std::vector positional; for (int i = 1; i < argc; ++i) { const std::wstring arg = argv[i]; const auto value = [&]() -> std::wstring { if (i + 1 >= argc) throw std::runtime_error("missing a value after an option"); return argv[++i]; }; if (arg == L"--size") o.size = std::stoi(value()); else if (arg == L"--max-frames") o.max_frames = static_cast(std::stoul(value())); else if (arg == L"--max-width") o.max_width = std::stoi(value()); else if (arg == L"--name") o.name = fs::path(value()).string(); else if (arg == L"--help" || arg == L"-h" || arg == L"/?") throw std::invalid_argument(""); else positional.emplace_back(arg); } if (positional.empty() || positional.size() > 2) throw std::invalid_argument(""); o.input = fs::absolute(positional[0]); o.output = positional.size() == 2 ? fs::absolute(positional[1]) : o.input.parent_path() / (o.input.filename().wstring() + L"_pack"); if (o.size < 8 || o.size > max_side) throw std::runtime_error("--size must be 8 to 256"); if (o.max_width < max_side || o.max_width > max_texture) throw std::runtime_error("--max-width must be 256 to 8192"); if (o.max_frames < 1 || o.max_frames > 512) throw std::runtime_error("--max-frames must be 1 to 512"); return o; } int run(const Options &o) { if (!fs::is_directory(o.input)) throw std::runtime_error("not a folder: " + o.input.string()); std::vector files; for (const auto &entry : fs::directory_iterator(o.input)) { const auto extension = lowered(entry.path().extension().string()); if (entry.is_regular_file() && (extension == ".png" || extension == ".gif" || extension == ".jpg" || extension == ".jpeg" || extension == ".bmp" || extension == ".webp")) files.push_back(entry.path()); } std::sort(files.begin(), files.end()); if (files.empty()) throw std::runtime_error("no .png, .gif, .jpg, .bmp or .webp files in " + o.input.string()); std::vector emotes; std::set names, folded; std::size_t total{}, failed{}; for (const auto &file : files) { try { auto name = emote_name(file); for (int n = 2; names.contains(name); ++n) { const auto suffix = "_" + std::to_string(n); name = emote_name(file).substr(0, max_name - suffix.size()) + suffix; } auto frames = decode(file); const bool animated = frames.size() > 1; for (auto &f : frames) f.image = scale(f.image, o.size); frames = tidy(std::move(frames), o.max_frames); if (!animated || frames.size() == 1) frames.front().time = 0; if (total + frames.size() > max_total_frames) throw std::runtime_error("the pack would pass ReSkate's " + std::to_string(max_total_frames) + " frames"); total += frames.size(); if (!folded.insert(lowered(name)).second) std::printf(" note: %s differs from another emote only in case; typing picks the first.\n", name.c_str()); names.insert(name); std::printf(" :%s: %dx%d%s\n", name.c_str(), frames.front().image.width, frames.front().image.height, frames.size() > 1 ? (", " + std::to_string(frames.size()) + " frames").c_str() : ""); emotes.push_back({name, file.filename().string(), std::move(frames)}); } catch (const std::exception &e) { ++failed; std::printf(" skipped %s: %s\n", file.filename().string().c_str(), e.what()); } } if (emotes.empty()) throw std::runtime_error("no pictures could be read"); const int height = pack(emotes, o.max_width); if (height > max_texture) throw std::runtime_error("the atlas would be " + std::to_string(height) + " pixels tall (ReSkate takes 8192): use a smaller --size or --max-frames"); std::vector atlas(static_cast(o.max_width) * height * 4, 0); auto emote_list = Json::object(); for (const auto &e : emotes) { for (const auto &f : e.frames) for (int y = 0; y < f.image.height; ++y) std::copy_n(f.image.rgba.data() + static_cast(y) * f.image.width * 4, f.image.width * 4, atlas.begin() + (static_cast(f.y + y) * o.max_width + f.x) * 4); auto entry = Json::object(); entry["size"] = Json::array({e.frames.front().image.width, e.frames.front().image.height}); if (e.frames.size() == 1) { entry["pos"] = Json::array({e.frames.front().x, e.frames.front().y}); } else { auto list = Json::array(); for (const auto &f : e.frames) { auto frame = Json::object(); frame["time"] = f.time; frame["pos"] = Json::array({f.x, f.y}); list.push_back(std::move(frame)); } entry["frames"] = std::move(list); } emote_list[e.name] = std::move(entry); } fs::create_directories(o.output); write_png(o.output / "emotes.png", o.max_width, height, atlas); auto document = Json::object(); document["name"] = o.name; document["texture"] = "emotes.png"; document["emotes"] = std::move(emote_list); { const auto text = document.dump(2); FILE *out{}; if (_wfopen_s(&out, (o.output / "emotes.json").c_str(), L"wb") != 0 || !out) throw std::runtime_error("cannot write emotes.json"); std::fwrite(text.data(), 1, text.size(), out); std::fclose(out); } std::printf("\n%zu emotes (%zu frames) in a %dx%d atlas%s.\nWrote %s\\emotes.json and emotes.png.\n" "Copy both into the repository's assets\\emotes and rebuild ReSkate.dll.\n", emotes.size(), total, o.max_width, height, failed ? (", " + std::to_string(failed) + " skipped").c_str() : "", o.output.string().c_str()); return failed ? 2 : 0; } } // namespace int wmain(int argc, wchar_t **argv) { std::setvbuf(stdout, nullptr, _IONBF, 0); // progress shows as it happens, even through a pipe const bool own_console = [] { DWORD processes[2]; return GetConsoleProcessList(processes, 2) == 1; // started by double-click or drag-and-drop }(); int code = 1; if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED))) { std::printf("Windows Imaging could not start.\n"); } else { try { code = run(parse(argc, argv)); } catch (const std::invalid_argument &) { std::printf("ReSkateEmotePacker: builds ReSkate's chat emote pack from a folder of pictures and GIFs.\n\n" " ReSkateEmotePacker [output folder] [--size 56] [--max-frames 64]\n" " [--max-width 2048] [--name ReSkate]\n\n" "Each file becomes :FileName: (letters, digits and _). Animated GIFs keep their frames.\n" "Writes emotes.json and emotes.png (default: _pack next to the folder).\n"); } catch (const std::exception &e) { std::printf("Error: %s\n", e.what()); } CoUninitialize(); } if (own_console) { std::printf("\nPress Enter to close."); std::getchar(); } return code; }