From 917cb1b0fcddb069c52fdfe23e7b6b8df495d1a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Mon, 7 Sep 2026 11:06:17 -0600 Subject: [PATCH] Implement KL4E/KL3E decompression, so firmware modules that use it can load KL4E is Sony's second compression scheme for ~PSP modules, alongside gzip: LZ77 tokens where every bit is arithmetic-coded, structurally close to LZMA. PPSSPP could detect it but not decode it, so any module packed with it failed to load at all - the loader reached the gzip path and bailed there. Which scheme a compressed module uses is now decided by the payload's own magic rather than assuming gzip. On a 6.61 flash0 dump this takes the kd/ modules that load from 126 to 129 of 129; libmp3.prx, libaac.prx and libmp4.prx were the ones affected, and libmp3.prx decompresses to exactly the elf_size its PRX header declares. Two bounds problems in the format are fixed rather than reproduced: the match copy is unchecked against the output buffer on real hardware, so a crafted stream can write up to 255 bytes past it, and a long enough distance code indexes copyDistProbs out of range. Input reads are bounded too - the format carries no length and trusts the stream to terminate itself. Co-Authored-By: Claude Opus 5 (1M context) --- Core/CMakeLists.txt | 2 + Core/Core.vcxproj | 2 + Core/Core.vcxproj.filters | 3 + Core/HLE/sceKernelModule.cpp | 41 ++- Core/Util/KL4E.cpp | 419 ++++++++++++++++++++++++++++ Core/Util/KL4E.h | 41 +++ UWP/CoreUWP/CoreUWP.vcxproj | 2 + UWP/CoreUWP/CoreUWP.vcxproj.filters | 1 + android/jni/Android.mk | 1 + libretro/Makefile.common | 1 + 10 files changed, 500 insertions(+), 13 deletions(-) create mode 100644 Core/Util/KL4E.cpp create mode 100644 Core/Util/KL4E.h diff --git a/Core/CMakeLists.txt b/Core/CMakeLists.txt index 52e585e0ac..6bccb0845a 100644 --- a/Core/CMakeLists.txt +++ b/Core/CMakeLists.txt @@ -693,6 +693,8 @@ add_library(Core STATIC Util/BlockAllocator.h Util/PPGeDraw.cpp Util/PPGeDraw.h + Util/KL4E.cpp + Util/KL4E.h Util/PSARUnpack.cpp Util/PSARUnpack.h Util/RecentFiles.cpp diff --git a/Core/Core.vcxproj b/Core/Core.vcxproj index f493c40f25..ebe55178a3 100644 --- a/Core/Core.vcxproj +++ b/Core/Core.vcxproj @@ -921,6 +921,7 @@ + MaxSpeed @@ -1289,6 +1290,7 @@ + diff --git a/Core/Core.vcxproj.filters b/Core/Core.vcxproj.filters index c26c14f463..5829960173 100644 --- a/Core/Core.vcxproj.filters +++ b/Core/Core.vcxproj.filters @@ -363,6 +363,9 @@ Util + + Util + Util diff --git a/Core/HLE/sceKernelModule.cpp b/Core/HLE/sceKernelModule.cpp index 5ed7883ca1..e6c0603d54 100644 --- a/Core/HLE/sceKernelModule.cpp +++ b/Core/HLE/sceKernelModule.cpp @@ -48,6 +48,7 @@ #include "Core/ELF/ElfReader.h" #include "Core/ELF/PBPReader.h" #include "Core/ELF/PrxDecrypter.h" +#include "Core/Util/KL4E.h" #include "Core/FileSystems/FileSystem.h" #include "Core/FileSystems/MetaFileSystem.h" #include "Core/Util/BlockAllocator.h" @@ -1273,7 +1274,7 @@ static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 load g_OSD.Show(OSDType::MESSAGE_WARNING, StringFromFormat("HLE for '%s' has been manually disabled", head->modname)); } const u8 *in = ptr; - const auto isGzip = head->comp_attribute & 1; + const bool isCompressed = (head->comp_attribute & 1) != 0; // Kind of odd. u32 size = head->psp_size; if (size > elfSize) { @@ -1311,22 +1312,36 @@ static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 load return nullptr; } - // decompress if required. - if (isGzip) { - _dbg_assert_(Read32(ptr + 0x150) != ELF_MAGIC); - + // decompress if required. comp_attribute bit 0 says "compressed"; which scheme is then + // decided by the payload's own magic - gzip, or Sony's KL4E/KL3E. (JPCSP instead reads + // bits 8-11 of comp_attribute, but the magic is right there and can't disagree.) + if (isCompressed) { // Can't decompress in place so we need a temporary buffer. u8 *temp = (u8 *)malloc(decryptedSize); - _assert_msg_(temp != nullptr, "Failed to allocate gzip decompression buffer (decryptedSize: %d)", decryptedSize); + _assert_msg_(temp != nullptr, "Failed to allocate decompression buffer (decryptedSize: %d)", decryptedSize); memcpy(temp, ptr, decryptedSize); - int outBytes = gzipDecompress((u8 *)ptr, maxElfSize, temp); + + bool isKL3E = false; + int outBytes; + const char *scheme; + if (IsKL4EMagic(temp, decryptedSize, &isKL3E)) { + scheme = isKL3E ? "KL3E" : "KL4E"; + // The decompressor is handed the stream header, i.e. past the four-byte magic. + outBytes = DecompressKL4E((u8 *)ptr, maxElfSize, temp + 4, (size_t)decryptedSize - 4, nullptr, isKL3E); + if (outBytes < 0) { + // A PSP error code, not a byte count. + outBytes = -1; + } + } else { + scheme = "gzip"; + _dbg_assert_(Read32(ptr + 0x150) != ELF_MAGIC); + outBytes = gzipDecompress((u8 *)ptr, maxElfSize, temp); + } free(temp); if (outBytes < 0) { - // Not necessarily actually gzip - some kd/ system modules (and possibly VSH - // modules) use KL4E compression instead, which we don't support decompressing. - // Bail out cleanly here rather than falling through to parse whatever's left - // in the buffer (still compressed, not a valid ELF) as if it were real code. - *error_string = StringFromFormat("Module '%s' decompression failed", head->modname); + // Bail out cleanly rather than falling through to parse whatever's left in the + // buffer (still compressed, not a valid ELF) as if it were real code. + *error_string = StringFromFormat("Module '%s' %s decompression failed", head->modname, scheme); delete [] newptr; module->Cleanup(); kernelObjects.Destroy(module->GetUID()); @@ -1334,7 +1349,7 @@ static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 load error = SCE_KERNEL_ERROR_FILEERR; return nullptr; } - INFO_LOG(Log::sceModule, "gzip is enabled in '%s', decompressing (%d -> %d bytes, bufmax=%d).", head->modname, decryptedSize, outBytes, maxElfSize); + INFO_LOG(Log::sceModule, "'%s' is %s-compressed, decompressing (%d -> %d bytes, bufmax=%d).", head->modname, scheme, decryptedSize, outBytes, maxElfSize); } if (fakeLoadedModule) { diff --git a/Core/Util/KL4E.cpp b/Core/Util/KL4E.cpp new file mode 100644 index 0000000000..eff3551d82 --- /dev/null +++ b/Core/Util/KL4E.cpp @@ -0,0 +1,419 @@ +// Copyright (c) 2026- PPSSPP Project. + +// 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, version 2.0 or later versions. + +// 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 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +// KL4E / KL3E decompression. See docs/KL4E.md. +// +// LZ77 tokens - "emit this literal" or "repeat N bytes from M back" - where every bit is coded +// with an adaptive binary arithmetic coder rather than written directly. Structurally close to +// LZMA. KL3E differs from KL4E in exactly one constant (kPowLimitLong). +// +// Written from the format description, not ported from existing code. + +#include + +#include "Common/Log.h" +#include "Core/HLE/ErrorCodes.h" +#include "Core/Util/KL4E.h" +namespace { + +// Adaptation profiles. A probability is the chance the next bit is a 1, out of 256: on a 0 it +// just decays, on a 1 it decays and then gains the bonus back, so it climbs toward a ceiling of +// roughly (bonus << decay). +const int kDecayNormal = 3, kBonusNormal = 31; +const int kDecayFlag = 4, kBonusFlag = 15; + +// Table sizes. Each is exactly large enough for the highest index its indexing scheme can +// produce - except copyDistProbs, see the bounds check in the distance decode. +const int kLitProbsSize = 2040; // 8 literal contexts * 255 +const int kCopyCountBitsProbsSize = 64; // 8 states * 8 unary steps +const int kCopyCountProbsSize = 256; +const int kCopyDistBitsProbsSize = 304; +const int kCopyDistProbsSize = 144; + +// The doubling walk that sizes a distance code stops once it reaches this. The single constant +// that separates the two formats. +const int kPowLimitKL4E = 256; +const int kPowLimitKL3E = 128; +// Short matches use a tighter limit in both formats. +const int kPowLimitShort = 64; + +struct ArithDecoder { + const u8 *in; + const u8 *inEnd; + u32 range; + u32 code; + bool overrun; + + u8 NextByte() { + if (in >= inEnd) { + // The format carries no length and relies on the stream terminating itself, so a + // truncated file would otherwise read forever. Feed zeroes and remember. + overrun = true; + return 0; + } + return *in++; + } + + // One bit against an adaptive probability. + int ReadBit(u8 *prob, int decay, int bonus) { + u32 bound; + const u32 p = *prob; + if ((range >> 24) == 0) { + // Renormalize. The bound uses the range from *before* the shift. + code = (code << 8) + NextByte(); + bound = range * p; + range <<= 8; + } else { + bound = (range >> 8) * p; + } + const u32 decayed = p - (p >> decay); + if (code >= bound) { + code -= bound; + range -= bound; + *prob = (u8)decayed; + return 0; + } + range = bound; + *prob = (u8)(decayed + bonus); + return 1; + } + + // A bit the encoder judged incompressible: no probability, no adaptation. + int ReadBitUniform() { + if ((range >> 24) == 0) { + code = (code << 8) + NextByte(); + range <<= 7; + } else { + range >>= 1; + } + if (code >= range) { + code -= range; + return 0; + } + return 1; + } + + // Same, skipping the refill check. Only valid straight after a read that did refill, which + // is how runs of uniform bits are emitted. + int ReadBitUniformNoNorm() { + range >>= 1; + if (code >= range) { + code -= range; + return 0; + } + return 1; + } + + // Eight bits into an accumulator that starts at 1, so the count is implicit. The context is + // a mix of the previous byte and the output position's alignment; `shift` slides the window + // between the two, the same choice LZMA's lc makes. + u32 ReadLiteral(u8 *litProbs, int outPos, u32 prevByte, int shift) { + const u32 ctx = ((u32)(outPos & 7) << 8) | (prevByte & 0xFF); + u8 *probs = litProbs + ((ctx >> shift) & 7) * 255 - 1; + u32 acc = 1; + while (acc < 0x100) { + acc = (acc << 1) | ReadBit(&probs[acc], kDecayNormal, kBonusNormal); + } + return acc & 0xFF; + } +}; + +} // namespace + +bool IsKL4EMagic(const u8 *data, size_t size, bool *isKL3E) { + if (size < 4) { + return false; + } + if (!memcmp(data, "KL4E", 4)) { + if (isKL3E) { + *isKL3E = false; + } + return true; + } + if (!memcmp(data, "KL3E", 4)) { + if (isKL3E) { + *isKL3E = true; + } + return true; + } + return false; +} + +int DecompressKL4E(u8 *out, int outSize, const u8 *in, size_t inSize, const u8 **end, bool isKL3E) { + if (!out || !in || outSize <= 0 || inSize < 5) { + return SCE_KERNEL_ERROR_INVALID_FORMAT; + } + + // --- five-byte stream header --- + const u8 flags = in[0]; + // Big-endian, which almost nothing else on the PSP is. + const u32 headerWord = ((u32)in[1] << 24) | ((u32)in[2] << 16) | ((u32)in[3] << 8) | in[4]; + + if (flags & 0x80) { + // Stored, not compressed: the header word is a length and the bytes follow verbatim. + // A payload that exactly fills the output buffer is rejected, not accepted. + if (headerWord >= (u32)outSize) { + return SCE_KERNEL_ERROR_INVALID_SIZE; + } + if (5 + (size_t)headerWord > inSize) { + return SCE_KERNEL_ERROR_INVALID_FORMAT; + } + memcpy(out, in + 5, headerWord); + if (end) { + *end = in + 5 + headerWord; + } + return (int)headerWord; + } + + // bits 4-3 pick the starting probability, bits 2-0 the literal context selector. + const u8 seed = (u8)(0x80 - (((flags >> 3) & 3) << 4)); + const int shift = flags & 7; + + u8 litProbs[kLitProbsSize]; + u8 copyCountBitsProbs[kCopyCountBitsProbsSize]; + u8 copyCountProbs[kCopyCountProbsSize]; + u8 copyDistBitsProbs[kCopyDistBitsProbsSize]; + u8 copyDistProbs[kCopyDistProbsSize]; + memset(litProbs, seed, sizeof(litProbs)); + memset(copyCountBitsProbs, seed, sizeof(copyCountBitsProbs)); + memset(copyCountProbs, seed, sizeof(copyCountProbs)); + memset(copyDistBitsProbs, seed, sizeof(copyDistBitsProbs)); + memset(copyDistProbs, seed, sizeof(copyDistProbs)); + + ArithDecoder dec; + dec.in = in + 5; + dec.inEnd = in + inSize; + dec.range = 0xFFFFFFFF; + dec.code = headerWord; + dec.overrun = false; + + const int powLimitLong = isKL3E ? kPowLimitKL3E : kPowLimitKL4E; + + // outPos indexes the byte being produced. The loop head advances it, which is why a match + // only advances it by copyCount after writing copyCount + 1 bytes. + int outPos = 0; + u32 prevByte = 0; + + // An index into copyCountBitsProbs that persists across tokens - the "state". The unary walk + // below moves it in steps of 8 and does not put it back, so its low three bits are what the + // length code's context uses. + int countBitsIdx = 0; + + // The first literal has no flag bit in front of it. + prevByte = dec.ReadLiteral(litProbs, outPos, prevByte, shift); + out[0] = (u8)prevByte; + + while (true) { + outPos++; + + if (dec.overrun) { + return SCE_KERNEL_ERROR_INVALID_FORMAT; + } + + if (dec.ReadBit(©CountBitsProbs[countBitsIdx], kDecayFlag, kBonusFlag) == 0) { + // Literal. + countBitsIdx = countBitsIdx > 0 ? countBitsIdx - 1 : 0; + if (outPos >= outSize) { + return SCE_KERNEL_ERROR_INVALID_SIZE; + } + prevByte = dec.ReadLiteral(litProbs, outPos, prevByte, shift); + out[outPos] = (u8)prevByte; + continue; + } + + // A match. First, how many bits the length code uses, as a unary run stepping the index + // by 8 so the low three bits stay put. + u32 copyCount = 1; + int copyCountBits = -1; + while (copyCountBits < 6) { + countBitsIdx += 8; + if (countBitsIdx >= kCopyCountBitsProbsSize) { + return SCE_KERNEL_ERROR_INVALID_FORMAT; + } + if (!dec.ReadBit(©CountBitsProbs[countBitsIdx], kDecayFlag, kBonusFlag)) { + break; + } + copyCountBits++; + } + + // The length, and with it the distance code's parameters: the bit that lands in the + // length's low position also selects them, for short matches. + int powLimit = kPowLimitShort; + int distBase = copyCountBits; + if (copyCountBits >= 0) { + const int offset = (copyCountBits << 5) + | (((outPos & 3) << (copyCountBits + 3)) & 0x18) + | (countBitsIdx & 7); + u8 *probs = copyCountProbs + offset; + + // High bits: two adaptive, then uniform for anything longer. + if (copyCountBits >= 3) { + copyCount = 2 + dec.ReadBit(probs + 24, kDecayNormal, kBonusNormal); + if (copyCountBits > 3) { + copyCount = (copyCount << 1) | dec.ReadBit(probs + 24, kDecayNormal, kBonusNormal); + if (copyCountBits > 4) { + copyCount = (copyCount << 1) | dec.ReadBitUniform(); + } + for (int i = 5; i < copyCountBits; i++) { + copyCount = (copyCount << 1) | dec.ReadBitUniformNoNorm(); + } + } + } + + copyCount <<= 1; + if (dec.ReadBit(probs, kDecayNormal, kBonusNormal)) { + copyCount |= 1; + if (copyCountBits <= 0) { + powLimit = powLimitLong; + distBase = 56 + copyCountBits; + } + } else if (copyCountBits <= 0) { + powLimit = kPowLimitShort; + distBase = copyCountBits; + } + + if (copyCountBits > 0) { + copyCount = (copyCount << 1) | dec.ReadBit(probs + 8, kDecayNormal, kBonusNormal); + if (copyCountBits != 1) { + copyCount <<= 1; + if (dec.ReadBit(probs + 16, kDecayNormal, kBonusNormal)) { + copyCount++; + // 0xFF ends the stream. There is no length in the header and no + // terminator byte; this is the only normal way out of the loop. + if (copyCount == 0xFF) { + if (end) { + *end = dec.in; + } + return outPos; + } + } + } + powLimit = powLimitLong; + distBase = 56 + copyCountBits; + } + } + + // How many bits the distance code uses, by a doubling walk. curPow only picks up the + // extra 8 when the walk continues, which is what makes the deepest reachable index fit + // copyDistBitsProbs exactly. + u32 copyDist = 0; + bool distanceIsZero = false; + int curPow = 8; + int copyDistBits = 0; + while (true) { + const int probIndex = distBase + curPow - 7; + if (probIndex < 0 || probIndex >= kCopyDistBitsProbsSize) { + return SCE_KERNEL_ERROR_INVALID_FORMAT; + } + u8 *prob = ©DistBitsProbs[probIndex]; + curPow <<= 1; + copyDistBits = curPow - powLimit; + if (!dec.ReadBit(prob, kDecayNormal, kBonusNormal)) { + if (copyDistBits >= 0) { + if (copyDistBits != 0) { + copyDistBits -= 8; + break; + } + // No distance bits at all: a match against the byte just emitted, i.e. a run. + distanceIsZero = true; + break; + } + } else { + curPow += 8; + if (copyDistBits >= 0) { + break; + } + } + } + + if (!distanceIsZero) { + // The distance value. Same shape as the length: two adaptive high bits, uniform in + // the middle, then three adaptive low bits whose +1/-1 adjustments keep the code + // ranges contiguous across bit counts. + if (copyDistBits < 0 || copyDistBits + 3 >= kCopyDistProbsSize) { + // Only reachable from a stream encoding a distance far larger than any real + // module needs - the hardware routine reads past its own table here. + return SCE_KERNEL_ERROR_INVALID_FORMAT; + } + u8 *probs = copyDistProbs + copyDistBits; + int readBits = copyDistBits / 8; + + if (readBits < 3) { + copyDist = 1; + } else { + copyDist = 2 + dec.ReadBit(probs + 3, kDecayNormal, kBonusNormal); + if (readBits > 3) { + copyDist = (copyDist << 1) | dec.ReadBit(probs + 3, kDecayNormal, kBonusNormal); + if (readBits > 4) { + copyDist = (copyDist << 1) | dec.ReadBitUniform(); + readBits--; + } + while (readBits > 4) { + copyDist = (copyDist << 1) + dec.ReadBitUniformNoNorm(); + readBits--; + } + } + } + + copyDist <<= 1; + if (dec.ReadBit(probs, kDecayNormal, kBonusNormal)) { + if (readBits > 0) { + copyDist++; + } + } else if (readBits <= 0) { + copyDist--; + } + + if (readBits > 0) { + copyDist <<= 1; + if (dec.ReadBit(probs + 1, kDecayNormal, kBonusNormal)) { + if (readBits != 1) { + copyDist++; + } + } else if (readBits == 1) { + copyDist--; + } + if (readBits != 1) { + copyDist <<= 1; + if (!dec.ReadBit(probs + 2, kDecayNormal, kBonusNormal)) { + copyDist--; + } + } + } + + if (copyDist >= (u32)outPos) { + return SCE_KERNEL_ERROR_INVALID_FORMAT; + } + } + + const int copyLen = (int)copyCount + 1; + // The hardware routine bounds-checks only the literal path, so a crafted stream can + // write up to 255 bytes past the end of the output buffer here. Check it. + if (copyLen > outSize - outPos) { + return SCE_KERNEL_ERROR_INVALID_SIZE; + } + + // Forward, byte at a time - overlap is legal and is how runs are encoded. + const u8 *src = out + outPos - copyDist - 1; + for (int i = 0; i < copyLen; i++) { + out[outPos + i] = src[i]; + } + prevByte = out[outPos + (int)copyCount]; + outPos += (int)copyCount; + countBitsIdx = 6 + (outPos & 1); + } +} diff --git a/Core/Util/KL4E.h b/Core/Util/KL4E.h new file mode 100644 index 0000000000..e2fbb7345a --- /dev/null +++ b/Core/Util/KL4E.h @@ -0,0 +1,41 @@ +// Copyright (c) 2026- PPSSPP Project. + +// 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, version 2.0 or later versions. + +// 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 2.0 for more details. + +// A copy of the GPL 2.0 should have been included with the program. +// If not, see http://www.gnu.org/licenses/ + +// Official git repository and contact information can be found at +// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/. + +#pragma once + +#include +#include "Common/CommonTypes.h" + +// KL4E / KL3E - Sony's LZ77-with-arithmetic-coding scheme for compressed PSP executables, the +// alternative to gzip inside a ~PSP module. KL3E is the same bitstream with one constant changed. +// +// See docs/KL4E.md for the format. + +// Decompresses one KL4E/KL3E stream. `in` points at the five-byte stream header, i.e. *past* the +// four-byte "KL4E"/"KL3E" magic. `inSize` bounds reads so a truncated file can't run off the end. +// +// Returns the number of bytes written, or a negative PSP error code: +// SCE_KERNEL_ERROR_INVALID_SIZE (0x80000104) output didn't fit +// SCE_KERNEL_ERROR_INVALID_FORMAT (0x80000108) stream is malformed +// +// If `end` is non-null it receives a pointer just past the last input byte consumed. (The PSP's +// own routine reports the last byte consumed for stored streams and one past for compressed ones; +// this is deliberately consistent instead, since nothing in PPSSPP depends on the quirk.) +int DecompressKL4E(u8 *out, int outSize, const u8 *in, size_t inSize, const u8 **end, bool isKL3E); + +// True if the buffer starts with a "KL4E" or "KL3E" magic. Sets *isKL3E when it does. +bool IsKL4EMagic(const u8 *data, size_t size, bool *isKL3E); diff --git a/UWP/CoreUWP/CoreUWP.vcxproj b/UWP/CoreUWP/CoreUWP.vcxproj index e3a4523a23..d043148711 100644 --- a/UWP/CoreUWP/CoreUWP.vcxproj +++ b/UWP/CoreUWP/CoreUWP.vcxproj @@ -336,6 +336,7 @@ + @@ -655,6 +656,7 @@ + diff --git a/UWP/CoreUWP/CoreUWP.vcxproj.filters b/UWP/CoreUWP/CoreUWP.vcxproj.filters index bb2f5192e8..20fee321d7 100644 --- a/UWP/CoreUWP/CoreUWP.vcxproj.filters +++ b/UWP/CoreUWP/CoreUWP.vcxproj.filters @@ -279,6 +279,7 @@ + diff --git a/android/jni/Android.mk b/android/jni/Android.mk index eae8b2235a..d62a3e1e35 100644 --- a/android/jni/Android.mk +++ b/android/jni/Android.mk @@ -811,6 +811,7 @@ EXEC_AND_LIB_FILES := \ $(SRC)/Core/Util/GameManager.cpp \ $(SRC)/Core/Util/BlockAllocator.cpp \ $(SRC)/Core/Util/PPGeDraw.cpp \ + $(SRC)/Core/Util/KL4E.cpp \ $(SRC)/Core/Util/PSARUnpack.cpp \ $(SRC)/Core/Util/RecentFiles.cpp \ $(SRC)/Core/Util/VideoPlayer.cpp \ diff --git a/libretro/Makefile.common b/libretro/Makefile.common index a0f8f7fd65..b197b43ffe 100644 --- a/libretro/Makefile.common +++ b/libretro/Makefile.common @@ -900,6 +900,7 @@ SOURCES_CXX += \ $(COREDIR)/Util/BlockAllocator.cpp \ $(COREDIR)/Util/MemStick.cpp \ $(COREDIR)/Util/PPGeDraw.cpp \ + $(COREDIR)/Util/KL4E.cpp \ $(COREDIR)/Util/PSARUnpack.cpp \ $(COREDIR)/Util/RecentFiles.cpp \ $(COREDIR)/Util/AudioFormat.cpp \