diff --git a/CMakeLists.txt b/CMakeLists.txt index 75a64616..c5c24666 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,7 @@ add_executable(newserv src/FileContentsCache.cc src/FunctionCompiler.cc src/GSLArchive.cc + src/GVMEncoder.cc src/IPFrameInfo.cc src/IPStackSimulator.cc src/ItemCreator.cc diff --git a/src/GVMEncoder.cc b/src/GVMEncoder.cc new file mode 100644 index 00000000..875c4cf8 --- /dev/null +++ b/src/GVMEncoder.cc @@ -0,0 +1,118 @@ +#include "GVMEncoder.hh" + +#include +#include +#include + +#include "Text.hh" + +using namespace std; + +static uint16_t encode_rgb565(uint8_t r, uint8_t g, uint8_t b) { + return ((r << 8) & 0xF800) | ((g << 3) & 0x07E0) | ((b >> 3) & 0x001F); +} + +static uint16_t encode_rgb5a3(uint8_t r, uint8_t g, uint8_t b, uint8_t a) { + if ((a & 0xE0) == 0xE0) { + return 0x8000 | ((r << 7) & 0x7C00) | ((g << 2) & 0x03E0) | ((b >> 3) & 0x001F); + } else { + return ((a << 7) & 0x7000) | ((r << 4) & 0x0F00) | (g & 0x00F0) | ((b >> 4) & 0x000F); + } +} + +static uint32_t encode_argb8888(uint8_t r, uint8_t g, uint8_t b, uint8_t a) { + return (a << 24) | (r << 16) | (g << 8) | b; +} + +struct GVMFileEntry { + be_uint16_t file_num; + parray name; + parray unknown_a1; +} __attribute__((packed)); + +struct GVMFileHeader { + be_uint32_t magic; // 'GVMH' + le_uint32_t header_size; + be_uint16_t flags; + be_uint16_t num_files; +} __attribute__((packed)); + +struct GVRHeader { + be_uint32_t magic; // 'GVRT' + le_uint32_t data_size; + be_uint16_t unknown; + uint8_t format_flags; // High 4 bits are pixel format, low 4 are data flags + GVRDataFormat data_format; + be_uint16_t width; + be_uint16_t height; +} __attribute__((packed)); + +string encode_gvm(const Image& img, GVRDataFormat data_format) { + if (img.get_width() > 0xFFFF) { + throw runtime_error("image is too wide to be encoded as a GVR texture"); + } + if (img.get_height() > 0xFFFF) { + throw runtime_error("image is too tall to be encoded as a GVR texture"); + } + if (img.get_width() & 3) { + throw runtime_error("image width is not a multiple of 4"); + } + if (img.get_height() & 3) { + throw runtime_error("image height is not a multiple of 4"); + } + size_t pixel_count = img.get_width() * img.get_height(); + size_t pixel_bytes = 0; + switch (data_format) { + case GVRDataFormat::RGB565: + case GVRDataFormat::RGB5A3: + pixel_bytes = pixel_count * 2; + break; + case GVRDataFormat::ARGB8888: + pixel_bytes = pixel_count * 2; + break; + default: + throw invalid_argument("cannot encode pixel format"); + } + + StringWriter w; + w.put({.magic = 0x47564D48, .header_size = 0x48, .flags = 0x010F, .num_files = 1}); + GVMFileEntry file_entry; + file_entry.file_num = 0; + file_entry.name = "img"; + file_entry.unknown_a1.clear(0); + w.put(file_entry); + w.extend_to(0x50, 0x00); + w.put({.magic = 0x47565254, + .data_size = pixel_bytes + 8, + .unknown = 0, + .format_flags = 0, + .data_format = data_format, + .width = img.get_width(), + .height = img.get_height()}); + + for (size_t y = 0; y < img.get_height(); y += 4) { + for (size_t x = 0; x < img.get_width(); x += 4) { + for (size_t yy = 0; yy < 4; yy++) { + for (size_t xx = 0; xx < 4; xx++) { + uint64_t a, r, g, b; + img.read_pixel(x + xx, y + yy, &r, &g, &b, &a); + switch (data_format) { + case GVRDataFormat::RGB565: + w.put_u16b(encode_rgb565(r, g, b)); + break; + case GVRDataFormat::RGB5A3: + w.put_u16b(encode_rgb5a3(r, g, b, a)); + break; + case GVRDataFormat::ARGB8888: + w.put_u32b(encode_argb8888(r, g, b, a)); + break; + default: + throw logic_error("cannot encode pixel format"); + } + } + } + } + } + + return std::move(w.str()); +} diff --git a/src/GVMEncoder.hh b/src/GVMEncoder.hh new file mode 100644 index 00000000..4159f2cf --- /dev/null +++ b/src/GVMEncoder.hh @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +#include "Text.hh" + +using namespace std; + +enum class GVRDataFormat : uint8_t { + INTENSITY_4 = 0x00, + INTENSITY_8 = 0x01, + INTENSITY_A4 = 0x02, + INTENSITY_A8 = 0x03, + RGB565 = 0x04, + RGB5A3 = 0x05, + ARGB8888 = 0x06, + INDEXED_4 = 0x08, + INDEXED_8 = 0x09, + DXT1 = 0x0E, +}; + +string encode_gvm(const Image& img, GVRDataFormat data_format); diff --git a/src/Main.cc b/src/Main.cc index 8cab0785..badda7ce 100644 --- a/src/Main.cc +++ b/src/Main.cc @@ -18,6 +18,7 @@ #include "Compression.hh" #include "DNSServer.hh" #include "GSLArchive.hh" +#include "GVMEncoder.hh" #include "IPStackSimulator.hh" #include "Loggers.hh" #include "NetworkAddresses.hh" @@ -274,6 +275,7 @@ enum class Behavior { ENCRYPT_GCI_SAVE, DECRYPT_GCI_SAVE, DECODE_GCI_SNAPSHOT, + ENCODE_GVM, FIND_DECRYPTION_SEED, SALVAGE_GCI, DECODE_QUEST_FILE, @@ -316,6 +318,7 @@ static bool behavior_takes_input_filename(Behavior b) { (b == Behavior::DECRYPT_CHALLENGE_DATA) || (b == Behavior::DECRYPT_GCI_SAVE) || (b == Behavior::DECODE_GCI_SNAPSHOT) || + (b == Behavior::ENCODE_GVM) || (b == Behavior::SALVAGE_GCI) || (b == Behavior::ENCRYPT_GCI_SAVE) || (b == Behavior::DECODE_QUEST_FILE) || @@ -350,6 +353,7 @@ static bool behavior_takes_output_filename(Behavior b) { (b == Behavior::DECRYPT_GCI_SAVE) || (b == Behavior::ENCRYPT_GCI_SAVE) || (b == Behavior::DECODE_GCI_SNAPSHOT) || + (b == Behavior::ENCODE_GVM) || (b == Behavior::ENCODE_QST) || (b == Behavior::DISASSEMBLE_QUEST_SCRIPT) || (b == Behavior::CONVERT_ITEMRT_REL_TO_JSON) || @@ -528,6 +532,8 @@ int main(int argc, char** argv) { behavior = Behavior::ENCRYPT_GCI_SAVE; } else if (!strcmp(argv[x], "decode-gci-snapshot")) { behavior = Behavior::DECODE_GCI_SNAPSHOT; + } else if (!strcmp(argv[x], "encode-gvm")) { + behavior = Behavior::ENCODE_GVM; } else if (!strcmp(argv[x], "find-decryption-seed")) { behavior = Behavior::FIND_DECRYPTION_SEED; } else if (!strcmp(argv[x], "salvage-gci")) { @@ -649,6 +655,8 @@ int main(int argc, char** argv) { } } else if (behavior == Behavior::DECODE_GCI_SNAPSHOT) { filename += ".bmp"; + } else if (behavior == Behavior::ENCODE_GVM) { + filename += ".gvm"; } else if (behavior == Behavior::DISASSEMBLE_QUEST_SCRIPT) { filename += ".txt"; } else if (behavior == Behavior::CONVERT_ITEMRT_REL_TO_JSON) { @@ -1011,6 +1019,18 @@ int main(int argc, char** argv) { break; } + case Behavior::ENCODE_GVM: { + Image img; + if (input_filename && strcmp(input_filename, "-")) { + img = Image(input_filename); + } else { + img = Image(stdin); + } + string encoded = encode_gvm(img, img.get_has_alpha() ? GVRDataFormat::RGB5A3 : GVRDataFormat::RGB565); + write_output_data(encoded.data(), encoded.size()); + break; + } + case Behavior::SALVAGE_GCI: { uint64_t likely_round1_seed = 0xFFFFFFFFFFFFFFFF; if (system_filename) { diff --git a/src/ReceiveCommands.cc b/src/ReceiveCommands.cc index 33b14946..caa29a0d 100644 --- a/src/ReceiveCommands.cc +++ b/src/ReceiveCommands.cc @@ -2481,6 +2481,9 @@ static void on_61_98(shared_ptr s, shared_ptr c, // login sequence. if ((c->flags & Client::Flag::IS_EPISODE_3) && !(c->flags & Client::Flag::HAS_EP3_CARD_DEFS)) { send_ep3_card_list_update(s, c); + for (const auto& banner : s->ep3_lobby_banners) { + send_ep3_media_update(c, banner.type, banner.which, banner.data); + } auto team = c->ep3_tournament_team.lock(); auto tourn = team ? team->tournament.lock() : nullptr; if (!(c->flags & Client::Flag::IS_EP3_TRIAL_EDITION)) { diff --git a/src/ServerState.cc b/src/ServerState.cc index d7c75ae5..6f585c99 100644 --- a/src/ServerState.cc +++ b/src/ServerState.cc @@ -3,10 +3,12 @@ #include #include +#include #include #include "Compression.hh" #include "FileContentsCache.hh" +#include "GVMEncoder.hh" #include "IPStackSimulator.hh" #include "Loggers.hh" #include "NetworkAddresses.hh" @@ -650,6 +652,23 @@ void ServerState::parse_config(const JSON& json) { .card_name = it.first}); } + for (const auto& it : json.get("Episode3LobbyBanners", JSON::list()).as_list()) { + Image img("system/ep3/banners/" + it->at(2).as_string()); + string gvm = encode_gvm(img, img.get_has_alpha() ? GVRDataFormat::RGB5A3 : GVRDataFormat::RGB565); + if (gvm.size() > 0x37000) { + throw runtime_error(string_printf("banner %s is too large (0x%zX bytes; maximum size is 0x37000 bytes)", it->at(2).as_string().c_str(), gvm.size())); + } + string compressed = prs_compress_optimal(gvm.data(), gvm.size()); + if (compressed.size() > 0x3800) { + throw runtime_error(string_printf("banner %s cannot be compressed small enough (0x%zX bytes; maximum size is 0x3800 bytes compressed)", it->at(2).as_string().c_str(), compressed.size())); + } + config_log.info("Loaded Episode 3 lobby banner %s (0x%zX -> 0x%zX bytes)", it->at(2).as_string().c_str(), gvm.size(), compressed.size()); + this->ep3_lobby_banners.emplace_back( + Ep3LobbyBannerEntry{.type = static_cast(it->at(0).as_int()), + .which = static_cast(it->at(1).as_int()), + .data = std::move(compressed)}); + } + { auto parse_ep3_ex_result_cmd = [&](const JSON& src) -> shared_ptr { shared_ptr ret(new G_SetEXResultValues_GC_Ep3_6xB4x4B()); diff --git a/src/ServerState.hh b/src/ServerState.hh index 35009bb6..1558c1b5 100644 --- a/src/ServerState.hh +++ b/src/ServerState.hh @@ -103,6 +103,12 @@ struct ServerState { std::string card_name; }; std::vector ep3_card_auction_pool; + struct Ep3LobbyBannerEntry { + uint32_t type = 1; + uint32_t which; // See B9 documentation in CommandFormats.hh + std::string data; + }; + std::vector ep3_lobby_banners; std::shared_ptr license_manager; diff --git a/system/config.example.json b/system/config.example.json index d3b2f414..33307c8b 100644 --- a/system/config.example.json +++ b/system/config.example.json @@ -382,6 +382,51 @@ "Thread +": [800, 8], }, + // Episode 3 lobby banners. Currently only images are supported. + // Banners are specified as lists of [type, which, filename]. Files are + // expected to be BMP or PPM files in the system/ep3/banners directory. type + // should be 1 for image files. which is a bitmask specifying where in the + // lobby the banner should appear; the bits are: + // 00000001: South above-counter banner (facing away from teleporters) + // 00000002: West above-counter banner + // 00000004: North above-counter banner (facing toward jukebox) + // 00000008: East above-counter banner + // 00000010: Banner above west (left) teleporter + // 00000020: Banner above east (right) teleporter + // 00000040: Banner at south end of lobby (opposite the jukebox) + // 00000080: Immediately left of 00000040 + // 00000100: Immediately right of 00000040 + // 00000200: Same as 00000080, but further left and at a slight inward angle + // 00000400: Same as 00000100, but further right and at a slight inward angle + // 00000800: Banner at north end of lobby, above the jukebox + // 00001000: Immediately right of 00000800 + // 00002000: Immediately left of 00000800 + // 00004000: Same as 00001000, but further right and at a slight inward angle + // 00008000: Same as 00002000, but further left and at a slight inward angle + // 00010000: Banners at west AND east ends of lobby, next to battle tables + // 00020000: Immediately left of 00001000 (2 banners) + // 00040000: Immediately right of 00001000 (2 banners) + // 00080000: Banners on southwest AND southeast ends of the lobby + // 00100000: Banners on south-southwest AND south-southeast ends of the lobby + // 00200000: Floor banners in front of the counter (4 banners) + // 00400000: Banners on both small walls in front of the battle tables + // 00800000: On southern platform + // 01000000: In front of jukebox + // 02000000: In western battle table corner (next to 4-player tables) + // 04000000: In eastern battle table corner (next to 2-player tables) + // 08000000: In southeastern battle table corner (next to 2-player tables) + // 10000000: In southwestern battle table corner (next to 4-player tables) + // 20000000: Just north-northwest of the counter + // 40000000: In front of the small wall in front of the 2-player battle tables + // 80000000: Inside the lobby counter, facing southeast + // So, to make the image system/ep3/banners/test-image.bmp appear in the lobby + // above both the left and right teleporters, you would set + // Episode3LobbyBanners like so: + // "Episode3LobbyBanners": [ + // [1, 0x00000030, "test-image.bmp"], + // ], + "Episode3LobbyBanners": [], + // Quest category configuration. See README.md for information on how quest // files should be named. This list specifies the quest category names and // descriptions. (We don't use a map here because the category order