diff --git a/.gitignore b/.gitignore index bd4a0ed5..92b03ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ bins/ /exe2elf /exe2exe /exe2iso +/mdec /midi2psm /midi2spd /modconv diff --git a/Makefile b/Makefile index 925a8403..b76a4ddb 100644 --- a/Makefile +++ b/Makefile @@ -206,7 +206,7 @@ SUPPORT_SRCS += third_party/ucl/src/n2e_99.c third_party/ucl/src/alloc.c third_p SUPPORT_SRCS += $(wildcard third_party/iec-60908b/*.c) LIBS := third_party/luajit/src/libluajit.a -TOOLS = authoring exe2elf exe2exe exe2iso midi2psm midi2spd modconv ps1-packer psyq-obj-parser +TOOLS = authoring exe2elf exe2exe exe2iso mdec midi2psm midi2spd modconv ps1-packer psyq-obj-parser ############################################################################## diff --git a/azure-pipelines-cli.yml b/azure-pipelines-cli.yml index 7d650915..d074e125 100644 --- a/azure-pipelines-cli.yml +++ b/azure-pipelines-cli.yml @@ -91,6 +91,7 @@ steps: !**\exe2elf.exe !**\exe2exe.exe !**\exe2iso.exe + !**\mdec.exe !**\modconv.exe !**\midi2psm.exe !**\midi2spd.exe diff --git a/azure-pipelines.yml b/azure-pipelines.yml index fddd1bb9..524be250 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -98,6 +98,7 @@ steps: !**\exe2elf.exe !**\exe2exe.exe !**\exe2iso.exe + !**\mdec.exe !**\modconv.exe !**\midi2psm.exe !**\midi2spd.exe diff --git a/tools/mdec/mdec.cc b/tools/mdec/mdec.cc new file mode 100644 index 00000000..f8b52b4f --- /dev/null +++ b/tools/mdec/mdec.cc @@ -0,0 +1,699 @@ +/*************************************************************************** + * Copyright (C) 2026 PCSX-Redux authors * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program; if not, write to the * + * Free Software Foundation, Inc., * + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. * + ***************************************************************************/ + +// mdec - tooling for the PlayStation's Macroblock Decoder. +// +// --------------------------------------------------------------------------- +// CLI CONTRACT. Read this before adding a command. +// +// Usage is always: mdec [options] +// +// 1. The command is the FIRST POSITIONAL argument, always, and never a flag. +// There is no default command: `mdec` with no command prints usage and +// exits non-zero. This is what lets commands be added without any existing +// invocation changing meaning. +// 2. `-i` is the input and `-o` is the output in EVERY command that has them. +// An option name never means two different things in two commands. +// 3. Options are scoped to their command. A command ignores nothing silently: +// an option it does not know is an error, not a no-op, because a silently +// ignored option is how a user ends up believing a table was applied. +// 4. `mdec -h` lists commands. `mdec -h` describes one command. +// 5. An unknown command lists what exists and exits non-zero. It never falls +// back to a default. +// +// Commands so far: rawencode, bsencode, rawdecode. "raw" means the MDEC's own +// run-level halfword stream, the bytes DMA0 consumes, with no BS/VLC entropy +// layer and no STR framing; `bsencode` is the same stream VLC coded into Sony's +// legacy BS. STR framing gets its own command when it lands, rather than a flag +// on either of these, so that each one keeps meaning what it means today. +// +// ⛔ THERE IS NO COMPRESSING COMMAND AND THAT IS A MEASURED DECISION, not a gap. +// A ucl-nrv2e path existed here briefly and was removed 2026-09-18: measured over +// 245 real 320x240 video frames, per frame, BS is smaller on EVERY ONE of them at +// every q_scale, by 30-54%. It still wins by 4.6% against ucl given the whole +// sequence as one stream, which is not even a deployable shape. The decode-cost +// argument that might have rescued it does not survive either - Sony shipped games +// that decode BS at full rate on real hardware, so the VLC pass is affordable by +// existence proof. +// +// If compression ever comes back, it belongs on THIS side of the line and not in +// supportpsx: this tool is GPLv2, the library is MIT, and ucl's compressor is +// GPLv2. The library emits Container::Raw and stops for that reason. +// --------------------------------------------------------------------------- + +#include +#include + +#include +#include +#include +#include +#include + +#include "flags.h" +#include "fmt/format.h" +#include "json.hpp" +#include "supportpsx/dct.h" + +#define STB_IMAGE_IMPLEMENTATION +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include "stb/stb_image.h" +#include "stb/stb_image_write.h" + +namespace { + +// Sony's own zscan[], FileFormat47 p.1-12, verified identical entry for entry. +// c_zscan[k] is the natural-order index of zigzag position k. +constexpr int c_zscan[64] = { + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, + 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, + 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +}; + +// The scale matrix every game uploads via MDEC(3), Q14. This is the INVERSE +// transform; the forward one used for encoding is DCT::standardBasis(). +constexpr int16_t c_standardScale[64] = { + 0x5A82, 0x5A82, 0x5A82, 0x5A82, 0x5A82, 0x5A82, 0x5A82, 0x5A82, + 0x7D8A, 0x6A6D, 0x471C, 0x18F8, (int16_t)0xE707, (int16_t)0xB8E3, (int16_t)0x9592, (int16_t)0x8275, + 0x7641, 0x30FB, (int16_t)0xCF04, (int16_t)0x89BE, (int16_t)0x89BE, (int16_t)0xCF04, 0x30FB, 0x7641, + 0x6A6D, (int16_t)0xE707, (int16_t)0x8275, (int16_t)0xB8E3, 0x471C, 0x7D8A, 0x18F8, (int16_t)0x9592, + 0x5A82, (int16_t)0xA57D, (int16_t)0xA57D, 0x5A82, 0x5A82, (int16_t)0xA57D, (int16_t)0xA57D, 0x5A82, + 0x471C, (int16_t)0x8275, 0x18F8, 0x6A6D, (int16_t)0x9592, (int16_t)0xE707, 0x7D8A, (int16_t)0xB8E3, + 0x30FB, (int16_t)0x89BE, 0x7641, (int16_t)0xCF04, (int16_t)0xCF04, 0x7641, (int16_t)0x89BE, 0x30FB, + 0x18F8, (int16_t)0xB8E3, 0x6A6D, (int16_t)0x8275, 0x7D8A, (int16_t)0x9592, 0x471C, (int16_t)0xE707, +}; + +// Sony's Qtab, FileFormat47 p.1-11, verified identical entry for entry. Their +// matrix is drawn "x 1/16", so the printed integers are what goes here and the +// 16 in their quantization formula is what cancels it. +// +// It is the DEFAULT here rather than an all-ones table: an +// unquantized DC for a bright 8x8 block lands around 1150, and the run-level +// field is signed TEN BITS. An identity quant table therefore clips the DC of +// any block brighter than about mid-grey, which is not a tuning problem, it is +// the reason the format carries a quant table at all. +constexpr uint8_t c_standardQuant[64] = { + 2, 16, 19, 22, 26, 27, 29, 34, 16, 16, 22, 24, 27, 29, 34, 37, 19, 22, 26, 27, 29, 34, + 34, 38, 22, 22, 26, 27, 29, 34, 37, 40, 22, 26, 27, 29, 32, 35, 40, 48, 26, 27, 29, 32, + 35, 40, 48, 58, 26, 27, 29, 34, 38, 46, 56, 69, 27, 29, 35, 38, 46, 56, 69, 83, +}; + +struct Tables { + uint8_t quantY[64]; + uint8_t quantUV[64]; + int16_t scale[64]; // Q14 inverse matrix, what MDEC(3) receives + PCSX::DCT::Basis forward; // Q14 forward matrix, used by rawencode + bool scaleFromFile = false; + bool forwardFromFile = false; + + Tables() { + memcpy(quantY, c_standardQuant, sizeof(quantY)); + memcpy(quantUV, c_standardQuant, sizeof(quantUV)); + memcpy(scale, c_standardScale, sizeof(scale)); + forward = PCSX::DCT::standardBasis(); + } +}; + +bool loadArray(const nlohmann::json &j, const char *key, int *out, unsigned n, std::string &err) { + if (!j.contains(key)) return false; + const auto &a = j[key]; + if (!a.is_array() || a.size() != n) { + err = fmt::format("'{}' must be an array of {} numbers", key, n); + return false; + } + for (unsigned i = 0; i < n; i++) { + if (!a[i].is_number_integer()) { + err = fmt::format("'{}'[{}] is not an integer", key, i); + return false; + } + out[i] = a[i].get(); + } + return true; +} + +// Table file layout: +// { "quant": { "y": [64], "uv": [64] }, "scale": [64], "forward": [64] } +// Every member is optional; whatever is absent keeps its default. +bool loadTables(const std::string &path, Tables &t, std::string &err) { + FILE *f = fopen(path.c_str(), "rb"); + if (!f) { + err = fmt::format("cannot open {}", path); + return false; + } + std::string text; + char buf[4096]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) text.append(buf, n); + fclose(f); + + auto j = nlohmann::json::parse(text, nullptr, false, true); + if (j.is_discarded() || !j.is_object()) { + err = fmt::format("{} is not a JSON object", path); + return false; + } + int tmp[64]; + if (j.contains("quant") && j["quant"].is_object()) { + if (loadArray(j["quant"], "y", tmp, 64, err)) { + for (int i = 0; i < 64; i++) t.quantY[i] = static_cast(std::clamp(tmp[i], 0, 255)); + } else if (!err.empty()) { + return false; + } + if (loadArray(j["quant"], "uv", tmp, 64, err)) { + for (int i = 0; i < 64; i++) t.quantUV[i] = static_cast(std::clamp(tmp[i], 0, 255)); + } else if (!err.empty()) { + return false; + } + } + if (loadArray(j, "scale", tmp, 64, err)) { + for (int i = 0; i < 64; i++) t.scale[i] = static_cast(tmp[i]); + t.scaleFromFile = true; + } else if (!err.empty()) { + return false; + } + if (loadArray(j, "forward", tmp, 64, err)) { + for (int i = 0; i < 64; i++) t.forward[i] = static_cast(tmp[i]); + t.forwardFromFile = true; + } else if (!err.empty()) { + return false; + } + return true; +} + +} // namespace + + +namespace { + +void printUsage(const char *argv0) { + fmt::print(R"( +Usage: {} [options] + + The command is always the first argument. Run `{} -h` for its + options. Adding a command never changes what an existing one does. + +commands: + rawencode a PNG into a raw MDEC run-level stream (what DMA0 consumes) + bsencode a PNG into Sony's legacy BS: the same stream, VLC/Huffman coded + bsdecode a BS stream back into a raw MDEC run-level stream + rawdecode a raw MDEC run-level stream back into a PNG + + -h this help, or a command's help when given after a command +)", + argv0, argv0); +} + +void usageRawEncode() { + fmt::print(R"( +Usage: mdec rawencode -i input.png -o output.bin [options] + + -i file mandatory: input PNG. Dimensions are rounded UP to a multiple of + 16 by edge replication; the padding is encoded and reported. + -o file mandatory: raw MDEC run-level stream, little endian halfwords. + -t file optional: JSON tables, see below. + -quality n optional: 1..100, higher is better. Default 50, which is exactly + the q_scale of 8 this defaulted to before there was a dial. The + spread is geometric, because the format's rate curve is: 100 is + q_scale 1, 50 is 8, 1 is 63. + -qscale n optional: the MDEC's own q_scale field, 1..63, for when you want to + set the format value directly. Higher is coarser, so it reads the + opposite way round to -quality. Mutually exclusive with it. + + Neither one touches the quant table, and that is deliberate. qt[0] + is 2 and the DC divisor is qt[0]*2, so a finer table pins the DC + divisor at 2 and DC coefficients overrun the signed 10 bit field - + measured, on a smooth frame, mean error going 1.31 to 19.18 while + the size barely moved. The standard table is the finest DC the + format tolerates rather than a default to improve on. Supply a + genuinely different table with -t if you want one. + + -order optional: raster | column. Default raster. + raster walks the image row by row. column walks 16-pixel columns + top to bottom, which is what retail STR does; it makes each column + of the decoded output one contiguous run, so a player can upload a + whole column as a single VRAM rect instead of one transfer per + macroblock. The pixels are identical either way, only the order + the macroblocks arrive in changes, and a decoder that disagrees + with the stream about it renders bands of vertically-striped + blocks. + + -transform optional: exact | fast | symmetric. Default exact. + exact general basis, int32 accumulation, reference accurate + fast general basis, Q15 narrowing, quicker and coarser + symmetric general basis with the even/odd butterfly; needs a basis + with the usual symmetry and refuses one without it + +JSON tables, every member optional, absent members keep their defaults: + {{ "quant": {{ "y": [64 ints], "uv": [64 ints] }}, + "scale": [64 ints], "forward": [64 ints] }} + + "forward" is the Q14 matrix this command transforms with. "scale" is the Q14 + matrix MDEC(3) receives, used by rawdecode. They are the two halves of a pair + and THIS TOOL DOES NOT DERIVE ONE FROM THE OTHER: supply "scale" alone and + encoding still uses the default forward basis, which is almost certainly not + what you want. It says so when you do. + + The default quant table is the standard one every shipping encoder uses. An + all-ones table looks appealing because it makes dequantization the identity, + but the run-level DC field is signed TEN BITS and an unquantized DC runs to + about 1150, so identity quant clips every block brighter than mid-grey. +)"); +} + +void usageRawDecode() { + fmt::print(R"( +Usage: mdec rawdecode -i input.bin -o output.png -width W -height H [options] + + -i file mandatory: raw MDEC run-level stream. + -o file mandatory: output PNG. + -width n mandatory: width in pixels, multiple of 16. + -height n mandatory: height in pixels, multiple of 16. + -t file optional: JSON tables. Only "quant" and "scale" are used here. + + The stream carries no dimensions, which is why they are mandatory rather than + guessed. This decodes with psx-spx's real_idct_core against "scale", so a + custom matrix is honoured exactly as the hardware honours it. +)"); +} + +int clamp10(int v, unsigned &clipped) { + if (v > 511) { + clipped++; + return 511; + } + if (v < -512) { + clipped++; + return -512; + } + return v; +} + +int divRound(int num, int den) { + if (den == 0) return 0; + return (num < 0) ? -((-num + den / 2) / den) : ((num + den / 2) / den); +} + +// psx-spx real_idct_core, verified against real consoles 2026-09-14 to within +// 3 LSB on a custom matrix. +void realIdct(int *block, const int16_t *scale) { + int temp[64]; + int *src = block; + int *dst = temp; + for (int pass = 0; pass < 2; pass++) { + for (int x = 0; x < 8; x++) { + for (int y = 0; y < 8; y++) { + int64_t sum = 0; + for (int z = 0; z < 8; z++) sum += static_cast(src[y + z * 8]) * (scale[x + z * 8] / 8); + dst[x + y * 8] = static_cast((sum + 0xfff) >> 13); + } + } + std::swap(src, dst); + } + if (src != block) memcpy(block, src, sizeof(temp)); +} + +int cmdEncode(CommandLine::args &args, bool asksForHelp, PCSX::DCT::Container container) { + if (asksForHelp) { + usageRawEncode(); + return 0; + } + const auto in = args.get("i"); + const auto out = args.get("o"); + if (!in.has_value() || !out.has_value()) { + usageRawEncode(); + return -1; + } + // -q is RETIRED rather than redefined. It used to mean the raw q_scale field, + // and making it mean a quality percentage would silently invert its sense - + // higher was worse, higher is now better - while every old invocation kept + // working and produced a very different image. An unknown option is already an + // error here, so the hard break is the one that cannot be misread. + if (args.get("q").has_value()) { + fmt::print(stderr, + "-q is retired because it meant the raw q_scale field, which reads backwards as a quality " + "dial. Use -quality 1..100 (higher is better, 50 is the old default), or -qscale 1..63 for " + "the format field itself.\n"); + return -1; + } + const auto qualityOpt = args.get("quality"); + const auto qscaleOpt = args.get("qscale"); + if (qualityOpt.has_value() && qscaleOpt.has_value()) { + fmt::print(stderr, "-quality and -qscale set the same thing. Pass one.\n"); + return -1; + } + const int quality = std::clamp(qualityOpt.value_or(50), 1, 100); + const int qscale = + qscaleOpt.has_value() ? std::clamp(qscaleOpt.value(), 1, 63) : PCSX::DCT::qualityToQScale(quality); + const std::string which = args.get("transform").value_or("exact"); + + PCSX::DCT::Transform transform; + if (which == "exact") { + transform = PCSX::DCT::Transform::ExactMatrix; + } else if (which == "fast") { + transform = PCSX::DCT::Transform::FastMatrix; + } else if (which == "symmetric") { + transform = PCSX::DCT::Transform::FastSymmetric; + } else { + fmt::print(stderr, "Unknown -transform '{}'; expected exact, fast or symmetric.\n", which); + return -1; + } + + Tables tables; + if (auto t = args.get("t"); t.has_value()) { + std::string err; + if (!loadTables(t.value(), tables, err)) { + fmt::print(stderr, "{}\n", err.empty() ? "cannot read tables" : err); + return -1; + } + if (tables.scaleFromFile && !tables.forwardFromFile) { + fmt::print(stderr, + "warning: {} supplies \"scale\" but no \"forward\". Encoding with the DEFAULT forward\n" + " basis, which does not match that scale matrix. Supply \"forward\" too.\n", + t.value()); + } + } + + int w = 0, h = 0, comp = 0; + uint8_t *pixels = stbi_load(in.value().c_str(), &w, &h, &comp, 3); + if (!pixels) { + fmt::print(stderr, "cannot read {}: {}\n", in.value(), stbi_failure_reason()); + return -1; + } + const int pw = (w + 15) & ~15; + const int ph = (h + 15) & ~15; + + std::vector y(static_cast(pw) * ph); + std::vector cb(static_cast(pw / 2) * (ph / 2)); + std::vector cr(cb.size()); + // Full range JPEG YCbCr, which is what the MDEC's colour conversion inverts. + auto at = [&](int x, int yy, int c) { + const int sx = std::min(x, w - 1), sy = std::min(yy, h - 1); + return static_cast(pixels[(static_cast(sy) * w + sx) * 3 + c]); + }; + for (int j = 0; j < ph; j++) { + for (int i = 0; i < pw; i++) { + const int r = at(i, j, 0), g = at(i, j, 1), b = at(i, j, 2); + y[static_cast(j) * pw + i] = + static_cast(std::clamp((299 * r + 587 * g + 114 * b) / 1000, 0, 255)); + } + } + for (int j = 0; j < ph / 2; j++) { + for (int i = 0; i < pw / 2; i++) { + int rs = 0, gs = 0, bs = 0; + for (int dy = 0; dy < 2; dy++) { + for (int dx = 0; dx < 2; dx++) { + rs += at(i * 2 + dx, j * 2 + dy, 0); + gs += at(i * 2 + dx, j * 2 + dy, 1); + bs += at(i * 2 + dx, j * 2 + dy, 2); + } + } + const int r = rs / 4, g = gs / 4, b = bs / 4; + const int luma = (299 * r + 587 * g + 114 * b) / 1000; + const size_t o = static_cast(j) * (pw / 2) + i; + cb[o] = static_cast(std::clamp(128 + (b - luma) * 564 / 1000, 0, 255)); + cr[o] = static_cast(std::clamp(128 + (r - luma) * 713 / 1000, 0, 255)); + } + } + stbi_image_free(pixels); + + PCSX::DCT::Frame frame; + frame.y = y.data(); + frame.cb = cb.data(); + frame.cr = cr.data(); + frame.width = pw; + frame.height = ph; + frame.yStride = pw; + frame.cStride = pw / 2; + + // -order column walks 16-pixel columns top to bottom, the way retail STR + // does, so each decoded column is one contiguous run and a player can upload + // it as a single VRAM rect. + const std::string order = args.get("order").value_or("raster"); + if (order == "column") { + frame.order = PCSX::DCT::MacroblockOrder::Column; + } else if (order != "raster") { + fmt::print(stderr, "-order takes raster or column, got '{}'.\n", order); + return -1; + } + + std::vector coeffs(PCSX::DCT::requiredCoefficientCount(pw, ph)); + PCSX::DCT::Encoder encoder(0, transform, tables.forward); + auto promise = encoder.submit(frame, coeffs); + const auto result = promise.get(); + if (result.failed) { + fmt::print(stderr, "the DCT stage refused the frame\n"); + return -1; + } + + // Quantization and run-level packing live in supportpsx/dct now: they are + // format knowledge, not CLI knowledge, and putting them there is what lets a + // caller drive per-macroblock rate control through a functor. + std::vector stream; + stream.reserve(result.blockCount * 12); + PCSX::DCT::QuantTables qtabs; + qtabs.y = tables.quantY; + qtabs.uv = tables.quantUV; + const auto packed = PCSX::DCT::pack(coeffs, result, qtabs, qscale, stream); + if (packed.failed) { + fmt::print(stderr, "the packing stage refused the frame\n"); + return -1; + } + // Two counters, two remedies. Merging them is what made the old message + // advise "raise -q" for a DC clip, which q_scale structurally cannot fix. + const unsigned clipped = packed.clippedAc + packed.clippedDc; + + FILE *f = fopen(out.value().c_str(), "wb"); + if (!f) { + fmt::print(stderr, "cannot write {}\n", out.value()); + return -1; + } + std::vector wrapped; + auto wrap = PCSX::DCT::toContainer(stream, container, wrapped); + if (wrap.failed) { + fclose(f); + fmt::print(stderr, "cannot wrap the stream: {}\n", wrap.error); + return -1; + } + fwrite(wrapped.data(), 1, wrapped.size(), f); + fclose(f); + + fmt::print("{}x{}", w, h); + if (pw != w || ph != h) fmt::print(" padded to {}x{}", pw, ph); + if (!qscaleOpt.has_value()) fmt::print(", quality {}", quality); + fmt::print(", {} blocks, {} halfwords, q_scale {}, transform {}", result.blockCount, stream.size(), qscale, + which); + const double raw = static_cast(stream.size() * 2); + if (container == PCSX::DCT::Container::Bs) { + fmt::print(", BS {} bytes ({:.1f}% of raw)", wrap.bytes, 100.0 * wrap.bytes / raw); + } + fmt::print("\n"); + if (clipped) { + // Name the lever that can actually reach each one. q_scale is in the AC + // divisor only, so it cannot fix a clipped DC however far it is raised. + if (packed.clippedAc) { + fmt::print(stderr, "warning: {} AC coefficients clipped to the 10 bit run-level range. Raise -q.\n", + packed.clippedAc); + } + if (packed.clippedDc) { + fmt::print(stderr, + "warning: {} DC coefficients clipped to the 10 bit run-level range. Raising -q will NOT " + "help - q_scale is not in the DC divisor. Use a coarser quant table: lower -quality, or " + "raise quant[0] with -t.\n", + packed.clippedDc); + } + } + return 0; +} + +int cmdBsDecode(CommandLine::args &args, bool asksForHelp) { + if (asksForHelp) { + fmt::print(R"( +Usage: mdec bsdecode -i input.bs -o output.bin + + -i file mandatory: a BS stream, as bsencode writes. + -o file mandatory: the raw MDEC run-level stream it carries. + + This undoes the VLC layer and nothing else, so it composes: `bsdecode` then + `rawdecode` gets you back to a PNG, and `bsencode | bsdecode` must reproduce + `rawencode` byte for byte on the same input and settings. + + Dimensions are not needed and not accepted - a BS header carries the decoded + length, which is the whole reason its first word is the MDEC decode command. +)"); + return 0; + } + auto in = args.get("i"); + auto out = args.get("o"); + if (!in.has_value() || !out.has_value()) { + fmt::print(stderr, "bsdecode needs -i and -o\n"); + return -1; + } + FILE *f = fopen(in.value().c_str(), "rb"); + if (!f) { + fmt::print(stderr, "cannot read {}\n", in.value()); + return -1; + } + std::vector bytes; + uint8_t buf[65536]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) bytes.insert(bytes.end(), buf, buf + n); + fclose(f); + + std::vector rl; + auto r = PCSX::DCT::fromContainer(bytes, PCSX::DCT::Container::Bs, rl); + if (r.failed) { + fmt::print(stderr, "cannot decode {}: {}\n", in.value(), r.error); + return -1; + } + FILE *o = fopen(out.value().c_str(), "wb"); + if (!o) { + fmt::print(stderr, "cannot write {}\n", out.value()); + return -1; + } + for (uint16_t v : rl) { + const uint8_t b2[2] = {static_cast(v & 0xff), static_cast(v >> 8)}; + fwrite(b2, 1, 2, o); + } + fclose(o); + fmt::print("{} blocks, q_scale {}, {} halfwords ({} words as the header declares)\n", r.blocks, r.qScale, + rl.size(), r.rlWords); + return 0; +} + +int cmdRawDecode(CommandLine::args &args, bool asksForHelp) { + if (asksForHelp) { + usageRawDecode(); + return 0; + } + const auto in = args.get("i"); + const auto out = args.get("o"); + const int w = args.get("width").value_or(0); + const int h = args.get("height").value_or(0); + if (!in.has_value() || !out.has_value() || w <= 0 || h <= 0 || (w % 16) || (h % 16)) { + usageRawDecode(); + return -1; + } + + Tables tables; + if (auto t = args.get("t"); t.has_value()) { + std::string err; + if (!loadTables(t.value(), tables, err)) { + fmt::print(stderr, "{}\n", err.empty() ? "cannot read tables" : err); + return -1; + } + } + + FILE *f = fopen(in.value().c_str(), "rb"); + if (!f) { + fmt::print(stderr, "cannot open {}\n", in.value()); + return -1; + } + std::vector stream; + uint8_t bytes[2]; + while (fread(bytes, 1, 2, f) == 2) stream.push_back(static_cast(bytes[0] | (bytes[1] << 8))); + fclose(f); + + const int mbx = w / 16, mby = h / 16; + std::vector image(static_cast(w) * h * 3); + size_t pos = 0; + for (int my = 0; my < mby; my++) { + for (int mx = 0; mx < mbx; mx++) { + int blocks[6][64]; + for (int b = 0; b < 6; b++) { + memset(blocks[b], 0, sizeof(blocks[b])); + if (pos >= stream.size()) { + fmt::print(stderr, "stream ran out at macroblock {},{} block {}\n", mx, my, b); + return -1; + } + const uint8_t *qt = b < 2 ? tables.quantUV : tables.quantY; + uint16_t n = stream[pos++]; + const int qscale = (n >> 10) & 0x3f; + auto sext10 = [](uint16_t v) { return static_cast(static_cast(v << 6)) >> 6; }; + blocks[b][0] = std::clamp(sext10(n & 0x3ff) * qt[0], -0x400, 0x3ff); + for (int k = 0;;) { + if (pos >= stream.size()) break; + n = stream[pos++]; + if (n == 0xfe00) break; + k += ((n >> 10) & 0x3f) + 1; + if (k > 63) break; + blocks[b][c_zscan[k]] = + std::clamp((sext10(n & 0x3ff) * qt[k] * qscale + 4) / 8, -0x400, 0x3ff); + } + realIdct(blocks[b], tables.scale); + } + for (int py = 0; py < 16; py++) { + for (int px = 0; px < 16; px++) { + const int cidx = (py / 2) * 8 + (px / 2); + const int cr = blocks[0][cidx], cb = blocks[1][cidx]; + const int yblk = (py / 8) * 2 + (px / 8); + const int yv = blocks[2 + yblk][(py % 8) * 8 + (px % 8)]; + const int r = yv + (1402 * cr) / 1000; + const int g = yv - (344 * cb + 714 * cr) / 1000; + const int bl = yv + (1772 * cb) / 1000; + const size_t o = (static_cast(my * 16 + py) * w + (mx * 16 + px)) * 3; + image[o + 0] = static_cast(std::clamp(r + 128, 0, 255)); + image[o + 1] = static_cast(std::clamp(g + 128, 0, 255)); + image[o + 2] = static_cast(std::clamp(bl + 128, 0, 255)); + } + } + } + } + if (!stbi_write_png(out.value().c_str(), w, h, 3, image.data(), w * 3)) { + fmt::print(stderr, "cannot write {}\n", out.value()); + return -1; + } + fmt::print("{}x{}, {} macroblocks, {} halfwords consumed of {}\n", w, h, mbx * mby, pos, stream.size()); + return 0; +} + +} // namespace + + +int main(int argc, char **argv) { + CommandLine::args args(argc, argv); + const auto positional = args.positional(); + const bool asksForHelp = args.get("h").value_or(false); + + if (positional.empty()) { + printUsage(argv[0]); + return asksForHelp ? 0 : -1; + } + const std::string command(positional[0]); + + // DCT::Transform::FastSymmetric throws std::invalid_argument on a basis + // without the even/odd symmetry, which is a documented and reachable input + // here - `-transform symmetric` with a `-t` table that lacks it. Without this + // the exception escapes main and the tool SIGABRTs on an input its own -h text + // says it refuses, while every other bad input prints a line and returns + // non-zero. Measured with `-transform exact` on the same table as the control: + // exit 0, encodes fine, so the abort was the symmetry check and not the table + // loader. + try { + if (command == "rawencode") return cmdEncode(args, asksForHelp, PCSX::DCT::Container::Raw); + if (command == "bsencode") return cmdEncode(args, asksForHelp, PCSX::DCT::Container::Bs); + if (command == "bsdecode") return cmdBsDecode(args, asksForHelp); + if (command == "rawdecode") return cmdRawDecode(args, asksForHelp); + } catch (const std::exception &e) { + fmt::print(stderr, "{}: {}\n", command, e.what()); + return -1; + } + + fmt::print(stderr, "Unknown command: {}\n\n", command); + printUsage(argv[0]); + return -1; +} diff --git a/tools/mdec/xmake.lua b/tools/mdec/xmake.lua new file mode 100644 index 00000000..9d143988 --- /dev/null +++ b/tools/mdec/xmake.lua @@ -0,0 +1,7 @@ +local support_root = path.join(os.scriptdir(), "..", "..") + +includes(path.join(support_root, "tools", "xmake")) + +common_rules() + +target("pcsx.mdec", common_tool) diff --git a/vsprojects/mdec/mdec.vcxproj b/vsprojects/mdec/mdec.vcxproj new file mode 100644 index 00000000..0f402c7b --- /dev/null +++ b/vsprojects/mdec/mdec.vcxproj @@ -0,0 +1,132 @@ + + + + + Debug + x64 + + + ReleaseWithClangCL + x64 + + + Release + x64 + + + + 18.0 + Win32Proj + {69f506ed-2c5b-4571-b023-786edcfc0e1e} + mdec + 10.0 + + + + Application + true + v145 + Unicode + + + Application + false + v145 + true + Unicode + + + Application + false + v145 + true + Unicode + + + + + + + + + + + + + + + + + + + + + + Level3 + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdcpp20 + + + Console + true + + + true + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdcpp20 + + + Console + true + + + true + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + stdcpp20 + + + Console + true + + + true + + + + + + + + {71772007-5110-418d-be9c-fb102b6eaabf} + + + {b2e2ad84-9d7f-4976-9572-e415819ffd7f} + + + {0e621321-093c-4d60-bd8b-027fdc2b0f63} + + + + + + \ No newline at end of file diff --git a/vsprojects/mdec/mdec.vcxproj.filters b/vsprojects/mdec/mdec.vcxproj.filters new file mode 100644 index 00000000..a74b0a1a --- /dev/null +++ b/vsprojects/mdec/mdec.vcxproj.filters @@ -0,0 +1,22 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Source Files + + + \ No newline at end of file diff --git a/vsprojects/pcsx-redux.sln b/vsprojects/pcsx-redux.sln index 7fffcbd5..13184042 100644 --- a/vsprojects/pcsx-redux.sln +++ b/vsprojects/pcsx-redux.sln @@ -335,6 +335,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "midi2psm", "midi2psm\midi2p EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "exe2exe", "exe2exe\exe2exe.vcxproj", "{6A476694-C1F8-4BA6-AA33-07D70FB197F4}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "mdec", "mdec\mdec.vcxproj", "{69F506ED-2C5B-4571-B023-786EDCFC0E1E}" +EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "implot", "implot\implot.vcxproj", "{C068ED1F-1341-42B1-A429-5D5F377FD63D}" EndProject Global @@ -796,6 +798,16 @@ Global {6A476694-C1F8-4BA6-AA33-07D70FB197F4}.ReleaseWithClangCL|x64.Build.0 = ReleaseWithClangCL|x64 {6A476694-C1F8-4BA6-AA33-07D70FB197F4}.ReleaseWithTracy|x64.ActiveCfg = Release|x64 {6A476694-C1F8-4BA6-AA33-07D70FB197F4}.ReleaseWithTracy|x64.Build.0 = Release|x64 + {69F506ED-2C5B-4571-B023-786EDCFC0E1E}.Debug|x64.ActiveCfg = Debug|x64 + {69F506ED-2C5B-4571-B023-786EDCFC0E1E}.Debug|x64.Build.0 = Debug|x64 + {69F506ED-2C5B-4571-B023-786EDCFC0E1E}.Release|x64.ActiveCfg = Release|x64 + {69F506ED-2C5B-4571-B023-786EDCFC0E1E}.Release|x64.Build.0 = Release|x64 + {69F506ED-2C5B-4571-B023-786EDCFC0E1E}.ReleaseCLI|x64.ActiveCfg = ReleaseWithClangCL|x64 + {69F506ED-2C5B-4571-B023-786EDCFC0E1E}.ReleaseCLI|x64.Build.0 = ReleaseWithClangCL|x64 + {69F506ED-2C5B-4571-B023-786EDCFC0E1E}.ReleaseWithClangCL|x64.ActiveCfg = ReleaseWithClangCL|x64 + {69F506ED-2C5B-4571-B023-786EDCFC0E1E}.ReleaseWithClangCL|x64.Build.0 = ReleaseWithClangCL|x64 + {69F506ED-2C5B-4571-B023-786EDCFC0E1E}.ReleaseWithTracy|x64.ActiveCfg = Release|x64 + {69F506ED-2C5B-4571-B023-786EDCFC0E1E}.ReleaseWithTracy|x64.Build.0 = Release|x64 {C068ED1F-1341-42B1-A429-5D5F377FD63D}.Debug|x64.ActiveCfg = Debug|x64 {C068ED1F-1341-42B1-A429-5D5F377FD63D}.Debug|x64.Build.0 = Debug|x64 {C068ED1F-1341-42B1-A429-5D5F377FD63D}.Release|x64.ActiveCfg = Release|x64 @@ -864,6 +876,7 @@ Global {FC0BCB98-8E1A-4304-BFF0-B22970853019} = {C6DD47BC-0C38-4AE6-B517-9675F3AC8A50} {E1D23582-C66F-40D3-A163-B1B4470B3F15} = {C6DD47BC-0C38-4AE6-B517-9675F3AC8A50} {6A476694-C1F8-4BA6-AA33-07D70FB197F4} = {C6DD47BC-0C38-4AE6-B517-9675F3AC8A50} + {69F506ED-2C5B-4571-B023-786EDCFC0E1E} = {C6DD47BC-0C38-4AE6-B517-9675F3AC8A50} {C068ED1F-1341-42B1-A429-5D5F377FD63D} = {64A05F50-3203-42CC-B632-09D6EE6EA856} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution