Files
DarkflameServer/dDashboardServer/routes/ClientAssets.cpp
Aaron Kimbrell e42fd45c1f feat(dashboard): glitter sparkles and flecks in the UGC 3D views
The UGC page's model view now draws the glitter sparkle shapes (look
SPARKLE) flashing as the client's Distortion Directional shader makes
them: two layers of sparkles sliding at the client's rates (a tile in
24 s at three quarters the scale, a tile in 48 s), shown only where both
have one. Flecks are drawn as flat flakes of the fleck size and varied
brightness (addGlitter in scenery-core.js), no longer drifting (the game
never moves them). The LXFML views (the UGC page's second view, the
property and zone views) draw both on the glitter colors from
window.LDD_GLITTER, which now carries every glitter setting and is no
longer cached, so changing a glitter setting on the settings page
previews on any model's LXFML view without making it again.

Check in the dashboard (not the game): open a glitter model on the UGC
page; both views show still flecks and sparkles flashing on the glitter
bricks only; change glitter_sparkle_amount / glitter_fleck_size in the
settings and reopen the model: the LXFML view follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:40:11 -05:00

582 lines
25 KiB
C++

#include "ClientAssets.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <map>
#include <set>
#include <sstream>
#include <unordered_map>
#include "OnceCache.h"
#include "UgcBricks.h"
#include "UgcRoutes.h"
#include "RouteUtils.h"
#include "CDClientDatabase.h"
#include "Game.h"
#include "Logger.h"
#include "Locale.h"
#include "dConfig.h"
#include "eHTTPMethod.h"
#include "GeneralUtils.h"
#include "Process.h"
using namespace RouteUtils;
namespace {
constexpr uint32_t MAX_ITEMS_PER_REQUEST = 500;
constexpr size_t MAX_LISTING_ENTRIES = 2000;
std::map<LOT, nlohmann::json> g_ItemCache;
std::map<LOT, std::vector<int>> g_ItemSetsByLot; // lot -> set ids containing it
bool g_ItemSetsLoaded = false;
// Read once (client_location only changes with a restart), so worker threads never read the settings
std::filesystem::path ClientRes() {
static const std::filesystem::path res = [] {
const auto client = Game::config ? Game::config->GetValue("client_location") : std::string{};
return client.empty() ? std::filesystem::path{} : std::filesystem::path(client) / "res";
}();
return res;
}
// Lowercase, forward slashes, no leading "../" or "res/"
std::string NormalizeAssetPath(std::string path) {
std::replace(path.begin(), path.end(), '\\', '/');
std::transform(path.begin(), path.end(), path.begin(), ::tolower);
while (path.starts_with("../")) path = path.substr(3);
if (path.starts_with("res/")) path = path.substr(4);
while (path.starts_with("/")) path = path.substr(1);
return path;
}
/**
* Where a normalized (lowercase) res/ path is on disk. Unpacked clients keep their original mixed case, so on
* case-sensitive file systems each part is matched ignoring case.
*/
std::filesystem::path ResolveResIn(const std::filesystem::path& res, const std::string& normalized) {
auto current = res;
std::error_code ec;
for (const auto& part : GeneralUtils::SplitString(normalized, '/')) {
if (part.empty()) continue;
if (std::filesystem::exists(current / part, ec)) {
current /= part;
continue;
}
std::filesystem::path match;
if (std::filesystem::is_directory(current, ec)) {
for (const auto& entry : std::filesystem::directory_iterator(current, ec)) {
auto name = entry.path().filename().string();
std::transform(name.begin(), name.end(), name.begin(), ::tolower);
if (name == part) {
match = entry.path();
break;
}
}
}
current = match.empty() ? current / part : match;
}
return current;
}
std::filesystem::path ResolveRes(const std::string& normalized) {
return ResolveResIn(ClientRes(), normalized);
}
std::optional<std::string> ReadFile(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file) return std::nullopt;
// One read of the whole file: copying through stream iterators is slow (very slow in debug builds)
const auto size = file.tellg();
if (size < 0) return std::nullopt;
std::string data(static_cast<size_t>(size), '\0');
file.seekg(0);
if (!file.read(data.data(), size)) return std::nullopt;
return data;
}
std::string Phrase(const std::string& key) {
const auto& phrase = Locale::GetPhrase(key);
return phrase;
}
struct Stats {
int imagination{ 0 };
int life{ 0 };
int armor{ 0 };
nlohmann::json skills = nlohmann::json::array();
};
std::string SkillDescription(int skillId) {
auto text = Phrase("SkillBehavior_" + std::to_string(skillId) + "_descriptionUI");
const std::pair<const char*, const char*> replacements[] = {
{"%(DamageCombo)", "Damage Combo: "}, {"%(AltCombo)", "\nSkeleton Combo: "},
{"%(Description)", "\n"}, {"%(ChargeUp)", "\nCharge-up: "}
};
for (const auto& [from, to] : replacements) {
for (auto pos = text.find(from); pos != std::string::npos; pos = text.find(from, pos)) text.replace(pos, std::strlen(from), to);
}
return text;
}
void AddSkill(Stats& stats, CppSQLite3Query& row) {
const int imagination = row.getIntField("imBonusUI", 0);
const int life = row.getIntField("lifeBonusUI", 0);
const int armor = row.getIntField("armorBonusUI", 0);
stats.imagination += imagination;
stats.life += life;
stats.armor += armor;
// Stat bonus skills only restate the numbers above ("+2 AP"), so list just the other skills
if (imagination || life || armor) return;
const int skillId = row.getIntField("skillID", 0);
const auto description = SkillDescription(skillId);
if (!description.empty()) stats.skills.push_back({ {"id", skillId}, {"icon", row.getIntField("skillIcon", 0)}, {"description", description} });
}
nlohmann::json StatsJson(const Stats& stats) {
return { {"imagination", stats.imagination}, {"life", stats.life}, {"armor", stats.armor}, {"skills", stats.skills} };
}
nlohmann::json SetBonus(int skillSetId) {
Stats stats;
auto stmt = CDClientDatabase::CreatePreppedStmt(
"SELECT sb.skillID, sb.skillIcon, sb.imBonusUI, sb.lifeBonusUI, sb.armorBonusUI FROM ItemSetSkills iss "
"JOIN SkillBehavior sb ON sb.skillID = iss.SkillID WHERE iss.SkillSetID = ?;");
stmt.bind(1, skillSetId);
auto row = stmt.execQuery();
while (!row.eof()) {
AddSkill(stats, row);
row.nextRow();
}
return StatsJson(stats);
}
void LoadItemSets() {
if (g_ItemSetsLoaded) return;
g_ItemSetsLoaded = true;
auto rows = CDClientDatabase::ExecuteQuery("SELECT setID, itemIDs FROM ItemSets;");
while (!rows.eof()) {
const int setId = rows.getIntField("setID");
for (const auto& part : GeneralUtils::SplitString(rows.getStringField("itemIDs", ""), ',')) {
std::string trimmed = part;
trimmed.erase(0, trimmed.find_first_not_of(' '));
if (const auto lot = GeneralUtils::TryParse<LOT>(trimmed)) g_ItemSetsByLot[*lot].push_back(setId);
}
rows.nextRow();
}
}
nlohmann::json ItemSet(LOT lot) {
LoadItemSets();
const auto it = g_ItemSetsByLot.find(lot);
if (it == g_ItemSetsByLot.end()) return nullptr;
auto stmt = CDClientDatabase::CreatePreppedStmt(
"SELECT setID, kitRank, kitImage, skillSetWith2, skillSetWith3, skillSetWith4, skillSetWith5, skillSetWith6 FROM ItemSets WHERE setID = ?;");
stmt.bind(1, it->second.front());
auto row = stmt.execQuery();
if (row.eof()) return nullptr;
const int setId = row.getIntField("setID");
nlohmann::json set{
{"id", setId},
{"name", Phrase("ItemSets_" + std::to_string(setId) + "_kitName")},
{"rank", row.getIntField("kitRank", 0)},
{"icon", row.getIntField("kitImage", 0)},
{"bonuses", nlohmann::json::array()}
};
const char* columns[] = { "skillSetWith2", "skillSetWith3", "skillSetWith4", "skillSetWith5", "skillSetWith6" };
for (int i = 0; i < 5; i++) {
const int skillSet = row.getIntField(columns[i], 0);
if (skillSet > 0) set["bonuses"].push_back({ {"pieces", i + 2}, {"stats", SetBonus(skillSet)} });
}
return set;
}
}
namespace {
struct ObjectNames {
std::string name;
std::string displayName;
};
// Every object's name and displayName, read once: the CDClient has no index on Objects.id, so looking a LOT up
// scans the whole table, and pages name thousands of items at once
std::unordered_map<LOT, ObjectNames> ReadObjectNames() {
std::unordered_map<LOT, ObjectNames> table;
try {
auto row = CDClientDatabase::ExecuteQuery("SELECT id, name, displayName FROM Objects;");
for (; !row.eof(); row.nextRow()) {
// The first row wins, as the LIMIT 1 lookups this replaces did
table.try_emplace(static_cast<LOT>(row.getIntField("id")), ObjectNames{ row.getStringField("name", ""), row.getStringField("displayName", "") });
}
} catch (const std::exception& ex) {
LOG("Failed to read object names: %s", ex.what());
}
return table;
}
// Read at startup (ClientAssets::Preload), so worker threads only read it and never query the CDClient
const std::unordered_map<LOT, ObjectNames>& ObjectNameTable() {
static const auto table = ReadObjectNames();
return table;
}
// Where TextureAsPng keeps a texture it converted
std::filesystem::path PngCachePath(const std::string& normalized, uint32_t maxSize, bool opaque) {
std::string cacheName = normalized + "_" + std::to_string(maxSize) + (opaque ? "_opaque" : "") + ".png";
std::replace(cacheName.begin(), cacheName.end(), '/', '_');
std::replace(cacheName.begin(), cacheName.end(), ' ', '_');
return std::filesystem::path("dDashboardServer") / "icon_cache" / cacheName;
}
// ImageMagick conversions by cache file name: whether it worked (a failed one isn't tried again)
OnceCache<std::string, bool> g_Conversions;
}
namespace ClientAssets {
std::optional<std::string> ObjectName(LOT lot) {
const auto& table = ObjectNameTable();
const auto it = table.find(lot);
if (it == table.end()) return std::nullopt;
return it->second.name;
}
std::string ItemName(LOT lot) {
const auto& localized = Locale::GetPhrase("Objects_" + std::to_string(lot) + "_name");
if (!localized.empty()) return localized;
const auto& table = ObjectNameTable();
const auto it = table.find(lot);
if (it == table.end()) return "LOT " + std::to_string(lot);
return it->second.displayName.empty() ? it->second.name : it->second.displayName;
}
nlohmann::json ItemInfo(LOT lot) {
if (const auto cached = g_ItemCache.find(lot); cached != g_ItemCache.end()) return cached->second;
nlohmann::json info{ {"lot", lot}, {"name", ItemName(lot)} };
auto description = Phrase("Objects_" + std::to_string(lot) + "_description");
if (description.empty()) {
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT description FROM Objects WHERE id = ? LIMIT 1;");
stmt.bind(1, static_cast<int>(lot));
auto row = stmt.execQuery();
if (!row.eof()) description = row.getStringField("description", "");
}
info["description"] = description;
auto rarityStmt = CDClientDatabase::CreatePreppedStmt(
"SELECT ic.rarity FROM ComponentsRegistry cr JOIN ItemComponent ic ON ic.id = cr.component_id WHERE cr.component_type = 11 AND cr.id = ? LIMIT 1;");
rarityStmt.bind(1, static_cast<int>(lot));
auto rarity = rarityStmt.execQuery();
info["rarity"] = rarity.eof() ? 0 : rarity.getIntField("rarity", 0);
Stats stats;
auto skillStmt = CDClientDatabase::CreatePreppedStmt(
"SELECT sb.skillID, sb.skillIcon, sb.imBonusUI, sb.lifeBonusUI, sb.armorBonusUI FROM ObjectSkills os "
"JOIN SkillBehavior sb ON sb.skillID = os.skillID WHERE os.objectTemplate = ?;");
skillStmt.bind(1, static_cast<int>(lot));
auto skills = skillStmt.execQuery();
while (!skills.eof()) {
AddSkill(stats, skills);
skills.nextRow();
}
info["stats"] = StatsJson(stats);
info["set"] = ItemSet(lot);
g_ItemCache[lot] = info;
return info;
}
std::string IconPathForLot(LOT lot) {
auto stmt = CDClientDatabase::CreatePreppedStmt(
"SELECT rc.icon_asset, rc.IconID FROM ComponentsRegistry cr JOIN RenderComponent rc ON cr.component_id = rc.id "
"WHERE cr.component_type = 2 AND cr.id = ? LIMIT 1;");
stmt.bind(1, static_cast<int>(lot));
auto result = stmt.execQuery();
if (result.eof()) return "";
std::string iconPath = result.getStringField("icon_asset", "");
const int iconId = result.getIntField("IconID", 0);
if (iconPath.empty() && iconId > 0) iconPath = IconPathForId(iconId);
return iconPath;
}
std::string IconPathForId(int iconId) {
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT IconPath FROM Icons WHERE IconID = ? LIMIT 1;");
stmt.bind(1, iconId);
auto result = stmt.execQuery();
return result.eof() ? "" : result.getStringField("IconPath", "");
}
bool IsSafeAssetPath(const std::string& path) {
if (path.empty() || path.find("..") != std::string::npos || path.starts_with('/')) return false;
return std::ranges::all_of(path, [](char c) {
return std::isalnum(static_cast<unsigned char>(c)) || c == '/' || c == '_' || c == '-' || c == '.' || c == ' ';
});
}
std::optional<std::string> TextureAsPng(const std::string& assetPath, uint32_t maxSize, bool opaque) {
const auto res = ClientRes();
const auto normalized = NormalizeAssetPath(assetPath);
if (res.empty() || !IsSafeAssetPath(normalized)) return std::nullopt;
const auto source = ResolveRes(normalized);
std::error_code ec;
if (!std::filesystem::is_regular_file(source, ec)) return std::nullopt;
if (normalized.ends_with(".png")) return ReadFile(source);
if (!normalized.ends_with(".dds")) return std::nullopt;
const auto target = PngCachePath(normalized, maxSize, opaque);
const auto cacheDir = target.parent_path();
const auto cacheName = target.filename().string();
// ImageMagick runs without a shell (an argument vector); odd characters are still refused as a precaution
if (!std::filesystem::exists(target, ec) && !Process::HasShellMetacharacters(source.string())) {
// Once per file, however many threads ask at once; written under another name and renamed, so a reader
// never sees half a file
const auto converted = g_Conversions.Get(cacheName, [&] {
if (std::filesystem::exists(target, ec)) return true;
std::filesystem::create_directories(cacheDir, ec);
const auto partial = cacheDir / (cacheName + ".part.png");
const std::string size = std::to_string(maxSize) + "x" + std::to_string(maxSize) + ">";
std::vector<std::string> arguments{ "magick", source.string() };
if (opaque) { arguments.push_back("-alpha"); arguments.push_back("off"); }
arguments.insert(arguments.end(), { "-resize", size, partial.string() });
std::error_code renameError;
if (Process::Run(arguments) != 0 || (std::filesystem::rename(partial, target, renameError), renameError)) {
LOG_DEBUG("Texture conversion failed for %s (is ImageMagick installed?)", normalized.c_str());
return false;
}
return true;
});
if (!converted) return std::nullopt;
}
return ReadFile(target);
}
bool TextureAsPngReady(const std::string& assetPath, uint32_t maxSize, bool opaque) {
const auto normalized = NormalizeAssetPath(assetPath);
std::error_code ec;
return normalized.ends_with(".png") || std::filesystem::exists(PngCachePath(normalized, maxSize, opaque), ec);
}
std::optional<std::string> ReadResFile(const std::string& relativePath) {
const auto res = ClientRes();
if (res.empty() || !IsSafeAssetPath(NormalizeAssetPath(relativePath))) return std::nullopt;
return ReadFile(ResolveRes(NormalizeAssetPath(relativePath)));
}
std::optional<std::filesystem::path> ResolveResFile(const std::string& relativePath) {
const auto normalized = NormalizeAssetPath(relativePath);
if (ClientRes().empty() || !IsSafeAssetPath(normalized)) return std::nullopt;
auto path = ResolveRes(normalized);
std::error_code ec;
if (!std::filesystem::is_regular_file(path, ec)) return std::nullopt;
return path;
}
void Preload() {
ClientRes();
ObjectNameTable();
}
std::filesystem::path ResFolder() {
return ClientRes();
}
std::optional<std::filesystem::path> ResolveResFile(const std::string& relativePath, const std::filesystem::path& res) {
const auto normalized = NormalizeAssetPath(relativePath);
if (res.empty() || !IsSafeAssetPath(normalized)) return std::nullopt;
auto path = ResolveResIn(res, normalized);
std::error_code ec;
if (!std::filesystem::is_regular_file(path, ec)) return std::nullopt;
return path;
}
std::optional<std::string> FindResFile(const std::string& folder, const std::string& fileName) {
const auto normalized = NormalizeAssetPath(folder);
if (ClientRes().empty() || !IsSafeAssetPath(normalized)) return std::nullopt;
const auto root = ResolveRes(normalized);
auto wanted = fileName;
std::transform(wanted.begin(), wanted.end(), wanted.begin(), ::tolower);
std::error_code ec;
for (auto it = std::filesystem::recursive_directory_iterator(root, ec); !ec && it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) {
auto name = it->path().filename().string();
std::transform(name.begin(), name.end(), name.begin(), ::tolower);
if (name != wanted || !it->is_regular_file(ec)) continue;
auto relative = std::filesystem::relative(it->path(), ClientRes(), ec).generic_string();
std::transform(relative.begin(), relative.end(), relative.begin(), ::tolower);
return relative;
}
return std::nullopt;
}
std::optional<nlohmann::json> ListDirectory(const std::string& relativePath) {
const auto res = ClientRes();
const auto normalized = NormalizeAssetPath(relativePath);
if (res.empty() || (!normalized.empty() && !IsSafeAssetPath(normalized))) return std::nullopt;
std::error_code ec;
const auto dir = normalized.empty() ? res : ResolveRes(normalized);
if (!std::filesystem::is_directory(dir, ec)) return std::nullopt;
nlohmann::json entries = nlohmann::json::array();
for (const auto& entry : std::filesystem::directory_iterator(dir, ec)) {
if (entries.size() >= MAX_LISTING_ENTRIES) break;
const auto name = entry.path().filename().string();
const bool isDir = entry.is_directory(ec);
entries.push_back({
{"name", name},
{"path", normalized.empty() ? name : normalized + "/" + name},
{"dir", isDir},
{"size", isDir ? 0 : static_cast<int64_t>(entry.file_size(ec))}
});
}
std::sort(entries.begin(), entries.end(), [](const nlohmann::json& a, const nlohmann::json& b) {
if (a["dir"] != b["dir"]) return a["dir"].get<bool>();
return a["name"].get<std::string>() < b["name"].get<std::string>();
});
return nlohmann::json{ {"path", normalized}, {"entries", entries}, {"truncated", entries.size() >= MAX_LISTING_ENTRIES} };
}
}
namespace {
void ReplyPng(HTTPReply& reply, const std::optional<std::string>& png) {
if (!png) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "";
return;
}
reply.status = eHTTPStatusCode::OK;
reply.message = *png;
reply.contentType = eContentType::IMAGE_PNG;
reply.headers.push_back("Cache-Control: private, max-age=86400");
}
}
void RegisterClientAssetRoutes() {
Route(eHTTPMethod::GET, "/api/bricks/materials.js", 0,
"The brick colours (MatID -> [r, g, b, a]) from Materials.xml in the client's res/brickdb.zip, as a script setting "
"window.LDD_MATERIALS for the 3D viewers, and window.LDD_GLITTER: the colours the UGC server makes glitter with its settings "
"(shader_glitter on: glitter_material_types and glitter_colors) and the glitter's settings (flecks, and sparkles when shader_glitter_sparkle is on). "
"The colours are read once; an empty table when the client's brick database can't be read",
[](HTTPReply& reply, const HTTPContext&) {
// Read on first use (thread-safe static init); the client only changes with a restart
struct Colours {
std::string script;
std::map<uint32_t, std::string> types; // MatID -> MaterialType
};
static const Colours colours = [] {
Colours out;
nlohmann::json table = nlohmann::json::object();
const auto zip = ClientAssets::ReadResFile("brickdb.zip");
const auto xml = zip ? UgcBricks::ReadZipEntry(*zip, "Materials.xml") : std::nullopt;
if (xml) {
for (const auto& [id, m] : UgcBricks::ParseMaterials(*xml)) {
table[std::to_string(id)] = { m.r, m.g, m.b, m.a };
out.types[id] = m.type;
}
} else {
LOG("Couldn't read Materials.xml from the client's brickdb.zip; the 3D viewers' bricks will be grey");
}
out.script = "window.LDD_MATERIALS = " + table.dump() + ";\n";
return out;
}();
// The glitter colours as the UGC server picks them (UgcServer.cpp ReadSettings), from its current settings
const auto list = [](const std::string& name) {
std::set<std::string> items;
std::stringstream stream(UgcRoutes::Setting(name).value_or(""));
std::string item;
while (std::getline(stream, item, ',')) {
std::erase_if(item, [](unsigned char c) { return std::isspace(c); });
if (!item.empty() && item != "none") items.insert(item);
}
return items;
};
nlohmann::json glitter = nlohmann::json::array();
if (GeneralUtils::TryParse<uint32_t>(UgcRoutes::Setting("shader_glitter").value_or("")).value_or(0) != 0) {
const auto types = list("glitter_material_types");
std::set<uint32_t> ids;
for (const auto& [id, type] : colours.types) if (types.contains(type)) ids.insert(id);
for (const auto& id : list("glitter_colors")) if (const auto value = GeneralUtils::TryParse<uint32_t>(id)) ids.insert(*value);
for (const auto id : ids) glitter.push_back(id);
}
const nlohmann::json settings{ { "colors", glitter },
{ "tile", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_size").value_or("")).value_or(1.6f) },
{ "flecks", GeneralUtils::TryParse<uint32_t>(UgcRoutes::Setting("glitter_density").value_or("")).value_or(80) },
{ "fleckSize", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_fleck_size").value_or("")).value_or(0.05f) },
{ "fleckOpacity", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_fleck_opacity").value_or("")).value_or(80.0f) },
{ "speed", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_speed").value_or("")).value_or(1.0f) },
{ "sparkles", GeneralUtils::TryParse<uint32_t>(UgcRoutes::Setting("shader_glitter_sparkle").value_or("")).value_or(79) != 0 },
{ "sparkleSize", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_sparkle_size").value_or("")).value_or(0.1f) },
{ "sparkleAmount", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_sparkle_amount").value_or("")).value_or(5.0f) },
{ "sparkleTint", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_sparkle_tint").value_or("")).value_or(30.0f) },
{ "sparkleBrightness", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_sparkle_brightness").value_or("")).value_or(100.0f) } };
reply.status = eHTTPStatusCode::OK;
reply.message = colours.script + "window.LDD_GLITTER = " + settings.dump() + ";\n";
reply.contentType = eContentType::TEXT_JAVASCRIPT;
// Not kept: the glitter settings change from the settings page, and the preview should follow
reply.headers.push_back("Cache-Control: private, no-cache");
});
Route(eHTTPMethod::GET, "/api/icon/:lot", 0, "PNG icon for an item LOT (requires client_location and ImageMagick)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto lot = PathId<LOT>(context.path, 2);
std::string path = lot ? ClientAssets::IconPathForLot(*lot) : "";
auto png = path.empty() ? std::nullopt : ClientAssets::TextureAsPng(path, 64);
// Fall back to the game's "unknown item" icon
if (lot && !png) png = ClientAssets::TextureAsPng("textures/ui/inventory/unknown.dds", 64);
ReplyPng(reply, png);
});
Route(eHTTPMethod::GET, "/api/icon_id/:id", 0, "PNG icon by icon ID (skills, item sets)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto iconId = PathId<int>(context.path, 2);
const std::string path = iconId ? ClientAssets::IconPathForId(*iconId) : "";
ReplyPng(reply, path.empty() ? std::nullopt : ClientAssets::TextureAsPng(path, 64));
});
ReadRoute(eHTTPMethod::POST, "/api/items/info", 0, "Item details for tooltips. Body: {lots: [..]} (max 500)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body || !body->contains("lots") || !(*body)["lots"].is_array()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "lots must be an array");
nlohmann::json items = nlohmann::json::object();
uint32_t count = 0;
for (const auto& lot : (*body)["lots"]) {
if (!lot.is_number_integer() || ++count > MAX_ITEMS_PER_REQUEST) continue;
const auto value = lot.get<LOT>();
items[std::to_string(value)] = ClientAssets::ItemInfo(value);
}
JsonReply(reply, eHTTPStatusCode::OK, items);
});
Route(eHTTPMethod::GET, "/api/client/list", Perm("client_files"), "List a folder of the game client's res directory. Query: ?path=",
[](HTTPReply& reply, const HTTPContext& context) {
const auto listing = ClientAssets::ListDirectory(QueryValue(context.queryString, "path"));
if (!listing) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Folder not found (is client_location set?)");
JsonReply(reply, eHTTPStatusCode::OK, *listing);
});
Route(eHTTPMethod::GET, "/api/client/texture", Perm("client_files"), "A client DDS/PNG texture as PNG. Query: ?path=&size= (max 1024)",
[](HTTPReply& reply, const HTTPContext& context) {
const uint32_t size = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "size")).value_or(512), 16, 1024);
ReplyPng(reply, ClientAssets::TextureAsPng(QueryValue(context.queryString, "path"), size));
});
Route(eHTTPMethod::GET, "/api/client/file", Perm("client_files"), "Download a raw client file. Query: ?path=",
[](HTTPReply& reply, const HTTPContext& context) {
const auto normalized = NormalizeAssetPath(QueryValue(context.queryString, "path"));
const auto res = ClientRes();
std::error_code ec;
if (res.empty() || !ClientAssets::IsSafeAssetPath(normalized) || !std::filesystem::is_regular_file(ResolveRes(normalized), ec)) {
return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "File not found");
}
const auto data = ReadFile(ResolveRes(normalized));
if (!data) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "File not found");
reply.status = eHTTPStatusCode::OK;
reply.message = *data;
reply.contentType = eContentType::APPLICATION_OCTET_STREAM;
const auto name = std::filesystem::path(normalized).filename().string();
reply.headers.push_back("Content-Disposition: attachment; filename=\"" + name + "\"");
});
}