diff --git a/app/src/main/cpp/LinkuraLocalify/UnityAssetHelper.hpp b/app/src/main/cpp/LinkuraLocalify/UnityAssetHelper.hpp new file mode 100644 index 0000000..fcd2fc8 --- /dev/null +++ b/app/src/main/cpp/LinkuraLocalify/UnityAssetHelper.hpp @@ -0,0 +1,1320 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace LinkuraLocal::UnityAssetHelper { + namespace Detail { + enum class Endian { + Big, + Little + }; + + struct Reader { + std::span bytes; + size_t pos = 0; + Endian endian = Endian::Big; + + [[nodiscard]] bool canRead(size_t n) const { + return pos <= bytes.size() && n <= (bytes.size() - pos); + } + + bool seek(size_t newPos) { + if (newPos > bytes.size()) return false; + pos = newPos; + return true; + } + + bool skip(size_t n) { + if (!canRead(n)) return false; + pos += n; + return true; + } + + bool align(size_t alignment) { + if (alignment == 0) return false; + const size_t mod = pos % alignment; + if (mod == 0) return true; + return skip(alignment - mod); + } + + bool readU8(uint8_t& out) { + if (!canRead(1)) return false; + out = bytes[pos++]; + return true; + } + + bool readU16(uint16_t& out) { + if (!canRead(2)) return false; + if (endian == Endian::Big) { + out = (static_cast(bytes[pos]) << 8) | + static_cast(bytes[pos + 1]); + } else { + out = static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8); + } + pos += 2; + return true; + } + + bool readU32(uint32_t& out) { + if (!canRead(4)) return false; + if (endian == Endian::Big) { + out = (static_cast(bytes[pos]) << 24) | + (static_cast(bytes[pos + 1]) << 16) | + (static_cast(bytes[pos + 2]) << 8) | + static_cast(bytes[pos + 3]); + } else { + out = static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24); + } + pos += 4; + return true; + } + + bool readU64(uint64_t& out) { + if (!canRead(8)) return false; + if (endian == Endian::Big) { + out = (static_cast(bytes[pos]) << 56) | + (static_cast(bytes[pos + 1]) << 48) | + (static_cast(bytes[pos + 2]) << 40) | + (static_cast(bytes[pos + 3]) << 32) | + (static_cast(bytes[pos + 4]) << 24) | + (static_cast(bytes[pos + 5]) << 16) | + (static_cast(bytes[pos + 6]) << 8) | + static_cast(bytes[pos + 7]); + } else { + out = static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24) | + (static_cast(bytes[pos + 4]) << 32) | + (static_cast(bytes[pos + 5]) << 40) | + (static_cast(bytes[pos + 6]) << 48) | + (static_cast(bytes[pos + 7]) << 56); + } + pos += 8; + return true; + } + + bool readCString(std::string& out) { + out.clear(); + const size_t start = pos; + while (pos < bytes.size() && bytes[pos] != 0) { + ++pos; + } + if (pos >= bytes.size()) return false; + out.assign(reinterpret_cast(bytes.data() + start), pos - start); + ++pos; + return true; + } + }; + + inline size_t alignUp(size_t value, size_t alignment) { + if (alignment == 0) return value; + const size_t mod = value % alignment; + return mod == 0 ? value : (value + alignment - mod); + } + + inline void writeU16(std::vector& out, uint16_t value, Endian endian) { + if (endian == Endian::Big) { + out.push_back(static_cast((value >> 8) & 0xFFu)); + out.push_back(static_cast(value & 0xFFu)); + } else { + out.push_back(static_cast(value & 0xFFu)); + out.push_back(static_cast((value >> 8) & 0xFFu)); + } + } + + inline void writeU32(std::vector& out, uint32_t value, Endian endian) { + if (endian == Endian::Big) { + out.push_back(static_cast((value >> 24) & 0xFFu)); + out.push_back(static_cast((value >> 16) & 0xFFu)); + out.push_back(static_cast((value >> 8) & 0xFFu)); + out.push_back(static_cast(value & 0xFFu)); + } else { + out.push_back(static_cast(value & 0xFFu)); + out.push_back(static_cast((value >> 8) & 0xFFu)); + out.push_back(static_cast((value >> 16) & 0xFFu)); + out.push_back(static_cast((value >> 24) & 0xFFu)); + } + } + + inline void writeU64(std::vector& out, uint64_t value, Endian endian) { + if (endian == Endian::Big) { + out.push_back(static_cast((value >> 56) & 0xFFu)); + out.push_back(static_cast((value >> 48) & 0xFFu)); + out.push_back(static_cast((value >> 40) & 0xFFu)); + out.push_back(static_cast((value >> 32) & 0xFFu)); + out.push_back(static_cast((value >> 24) & 0xFFu)); + out.push_back(static_cast((value >> 16) & 0xFFu)); + out.push_back(static_cast((value >> 8) & 0xFFu)); + out.push_back(static_cast(value & 0xFFu)); + } else { + out.push_back(static_cast(value & 0xFFu)); + out.push_back(static_cast((value >> 8) & 0xFFu)); + out.push_back(static_cast((value >> 16) & 0xFFu)); + out.push_back(static_cast((value >> 24) & 0xFFu)); + out.push_back(static_cast((value >> 32) & 0xFFu)); + out.push_back(static_cast((value >> 40) & 0xFFu)); + out.push_back(static_cast((value >> 48) & 0xFFu)); + out.push_back(static_cast((value >> 56) & 0xFFu)); + } + } + + inline bool patchU32(std::span bytes, size_t offset, uint32_t value, Endian endian) { + if (offset + 4 > bytes.size()) return false; + if (endian == Endian::Big) { + bytes[offset] = static_cast((value >> 24) & 0xFFu); + bytes[offset + 1] = static_cast((value >> 16) & 0xFFu); + bytes[offset + 2] = static_cast((value >> 8) & 0xFFu); + bytes[offset + 3] = static_cast(value & 0xFFu); + } else { + bytes[offset] = static_cast(value & 0xFFu); + bytes[offset + 1] = static_cast((value >> 8) & 0xFFu); + bytes[offset + 2] = static_cast((value >> 16) & 0xFFu); + bytes[offset + 3] = static_cast((value >> 24) & 0xFFu); + } + return true; + } + + inline uint64_t fnv1a64(std::string_view s) { + uint64_t hash = 1469598103934665603ull; + for (const unsigned char c : s) { + hash ^= static_cast(c); + hash *= 1099511628211ull; + } + return hash; + } + + inline std::string hexU64(uint64_t value) { + static constexpr char kHex[] = "0123456789abcdef"; + std::string out(16, '0'); + for (int i = 15; i >= 0; --i) { + out[i] = kHex[value & 0xFu]; + value >>= 4; + } + return out; + } + + inline bool hasUnityFsSignature(std::span bytes, size_t offset) { + static constexpr std::array kUnityFsSignature{ + 'U', 'n', 'i', 't', 'y', 'F', 'S', 0 + }; + return offset <= bytes.size() && + kUnityFsSignature.size() <= (bytes.size() - offset) && + std::memcmp(bytes.data() + offset, + kUnityFsSignature.data(), + kUnityFsSignature.size()) == 0; + } + + inline bool resolveUnityFsOffset(std::span bytes, uint64_t requestedOffset, uint64_t& actualOffset) { + if (requestedOffset <= bytes.size() && hasUnityFsSignature(bytes, static_cast(requestedOffset))) { + actualOffset = requestedOffset; + return true; + } + if (requestedOffset == 0 && + bytes.size() >= 10 && + bytes[0] == 0xAB && + bytes[1] == 0x00 && + hasUnityFsSignature(bytes, 2)) { + actualOffset = 2; + return true; + } + return false; + } + + inline bool readFileBytes(const std::filesystem::path& path, std::vector& out, std::string& error) { + error.clear(); + std::ifstream ifs(path, std::ios::binary); + if (!ifs) { + error = "open failed"; + return false; + } + ifs.seekg(0, std::ios::end); + const auto size = ifs.tellg(); + if (size < 0) { + error = "tellg failed"; + return false; + } + out.resize(static_cast(size)); + ifs.seekg(0, std::ios::beg); + if (!out.empty()) { + ifs.read(reinterpret_cast(out.data()), static_cast(out.size())); + if (!ifs) { + error = "read failed"; + return false; + } + } + return true; + } + + inline bool writeFileBytesAtomically(const std::filesystem::path& path, const std::vector& bytes, std::string& error) { + error.clear(); + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) { + error = "create_directories failed"; + return false; + } + + const auto tmpPath = path.string() + ".tmp"; + { + std::ofstream ofs(tmpPath, std::ios::binary | std::ios::trunc); + if (!ofs) { + error = "open tmp output failed"; + return false; + } + if (!bytes.empty()) { + ofs.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + } + if (!ofs) { + error = "write tmp output failed"; + return false; + } + } + + std::filesystem::rename(tmpPath, path, ec); + if (ec) { + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(tmpPath, path, ec); + } + if (ec) { + std::filesystem::remove(tmpPath, ec); + error = "rename failed"; + return false; + } + return true; + } + + inline bool decompressLz4Block(std::span src, size_t expectedSize, std::vector& dst, std::string& error) { + error.clear(); + dst.clear(); + dst.reserve(expectedSize); + + size_t i = 0; + while (i < src.size()) { + const uint8_t token = src[i++]; + size_t literalLength = static_cast(token >> 4); + if (literalLength == 15) { + while (i < src.size()) { + const uint8_t extra = src[i++]; + literalLength += extra; + if (extra != 255) break; + } + } + if (i + literalLength > src.size()) { + error = "lz4 literal overflow"; + return false; + } + dst.insert(dst.end(), src.begin() + static_cast(i), + src.begin() + static_cast(i + literalLength)); + i += literalLength; + if (i >= src.size()) break; + + if (i + 2 > src.size()) { + error = "lz4 missing match offset"; + return false; + } + const size_t matchOffset = static_cast(src[i]) | + (static_cast(src[i + 1]) << 8); + i += 2; + if (matchOffset == 0 || matchOffset > dst.size()) { + error = "lz4 invalid match offset"; + return false; + } + + size_t matchLength = static_cast(token & 0x0F) + 4; + if ((token & 0x0F) == 15) { + while (i < src.size()) { + const uint8_t extra = src[i++]; + matchLength += extra; + if (extra != 255) break; + } + } + + const size_t matchStart = dst.size() - matchOffset; + dst.resize(dst.size() + matchLength); + for (size_t j = 0; j < matchLength; ++j) { + dst[dst.size() - matchLength + j] = dst[matchStart + j]; + } + } + + if (dst.size() != expectedSize) { + error = "lz4 size mismatch"; + return false; + } + return true; + } + + struct SerializedTargetField { + size_t offset = 0; + uint32_t currentValue = 0; + Endian endian = Endian::Little; + }; + + inline std::optional findSerializedFileTargetField(std::span bytes) { + if (bytes.size() < 20) return std::nullopt; + + Reader reader{bytes}; + uint32_t metadataSize = 0; + uint32_t fileSize32 = 0; + uint32_t version = 0; + uint32_t dataOffset32 = 0; + if (!reader.readU32(metadataSize) || + !reader.readU32(fileSize32) || + !reader.readU32(version) || + !reader.readU32(dataOffset32)) { + return std::nullopt; + } + + uint64_t fileSize = fileSize32; + uint8_t fileEndian = 0; + if (version >= 9) { + if (!reader.readU8(fileEndian) || !reader.skip(3)) { + return std::nullopt; + } + } else { + if (fileSize < metadataSize || fileSize > bytes.size()) { + return std::nullopt; + } + fileEndian = bytes[static_cast(fileSize - metadataSize)]; + } + + if (version >= 22) { + uint64_t dataOffset64 = 0; + if (!reader.readU32(metadataSize) || + !reader.readU64(fileSize) || + !reader.readU64(dataOffset64) || + !reader.skip(8)) { + return std::nullopt; + } + } + + if (fileSize == 0 || fileSize > bytes.size()) { + return std::nullopt; + } + + reader.endian = fileEndian == 0 ? Endian::Little : Endian::Big; + + if (version >= 7) { + std::string unityVersion; + if (!reader.readCString(unityVersion)) { + return std::nullopt; + } + } + + if (version < 8 || !reader.canRead(4)) { + return std::nullopt; + } + + const size_t targetOffset = reader.pos; + uint32_t targetValue = 0; + if (!reader.readU32(targetValue)) { + return std::nullopt; + } + + return SerializedTargetField{ + .offset = targetOffset, + .currentValue = targetValue, + .endian = reader.endian, + }; + } + + struct StorageBlock { + uint32_t uncompressedSize = 0; + uint32_t compressedSize = 0; + uint16_t flags = 0; + }; + + struct Node { + uint64_t offset = 0; + uint64_t size = 0; + uint32_t flags = 0; + std::string path; + }; + + struct BundleHeader { + std::string signature; + uint32_t version = 0; + std::string unityVersion; + std::string unityRevision; + uint64_t size = 0; + uint32_t compressedBlocksInfoSize = 0; + uint32_t uncompressedBlocksInfoSize = 0; + uint32_t flags = 0; + size_t alignedHeaderSize = 0; + size_t blockDataStart = 0; + }; + + struct ParsedBundle { + BundleHeader header; + std::vector blocks; + std::vector nodes; + std::vector rawData; + }; + + inline bool parseUnityFsBundle(std::span bytes, ParsedBundle& out, std::string& error) { + error.clear(); + out = {}; + + Reader reader{bytes}; + if (!reader.readCString(out.header.signature) || out.header.signature != "UnityFS") { + error = "not a UnityFS stream"; + return false; + } + if (!reader.readU32(out.header.version) || + !reader.readCString(out.header.unityVersion) || + !reader.readCString(out.header.unityRevision) || + !reader.readU64(out.header.size) || + !reader.readU32(out.header.compressedBlocksInfoSize) || + !reader.readU32(out.header.uncompressedBlocksInfoSize) || + !reader.readU32(out.header.flags)) { + error = "failed to read UnityFS header"; + return false; + } + + if (out.header.version >= 7 && !reader.align(16)) { + error = "failed to align UnityFS header"; + return false; + } + out.header.alignedHeaderSize = reader.pos; + + const bool blocksInfoAtEnd = (out.header.flags & 0x80u) != 0; + size_t blocksInfoPos = reader.pos; + if (blocksInfoAtEnd) { + if (out.header.compressedBlocksInfoSize > bytes.size()) { + error = "compressed blocks info is larger than stream"; + return false; + } + blocksInfoPos = bytes.size() - out.header.compressedBlocksInfoSize; + } + + if (blocksInfoPos + out.header.compressedBlocksInfoSize > bytes.size()) { + error = "blocks info range is out of bounds"; + return false; + } + + const auto blocksInfoCompressed = bytes.subspan(blocksInfoPos, out.header.compressedBlocksInfoSize); + std::vector blocksInfoStorage; + const uint32_t blocksInfoCompression = out.header.flags & 0x3Fu; + if (blocksInfoCompression == 0) { + blocksInfoStorage.assign(blocksInfoCompressed.begin(), blocksInfoCompressed.end()); + } else if (blocksInfoCompression == 2 || blocksInfoCompression == 3) { + if (!decompressLz4Block(blocksInfoCompressed, out.header.uncompressedBlocksInfoSize, blocksInfoStorage, error)) { + error = "blocks info " + error; + return false; + } + } else { + error = "unsupported UnityFS blocks info compression"; + return false; + } + + Reader blocksReader{std::span(blocksInfoStorage.data(), blocksInfoStorage.size())}; + if (!blocksReader.skip(16)) { + error = "blocks info hash is truncated"; + return false; + } + + uint32_t blockCount = 0; + if (!blocksReader.readU32(blockCount)) { + error = "failed to read UnityFS block count"; + return false; + } + + uint64_t totalRawSize = 0; + out.blocks.reserve(blockCount); + for (uint32_t i = 0; i < blockCount; ++i) { + StorageBlock block; + if (!blocksReader.readU32(block.uncompressedSize) || + !blocksReader.readU32(block.compressedSize) || + !blocksReader.readU16(block.flags)) { + error = "failed to read UnityFS block table"; + return false; + } + totalRawSize += block.uncompressedSize; + if (totalRawSize > static_cast(std::numeric_limits::max())) { + error = "bundle raw size is too large"; + return false; + } + out.blocks.push_back(block); + } + + uint32_t nodeCount = 0; + if (!blocksReader.readU32(nodeCount)) { + error = "failed to read UnityFS node count"; + return false; + } + + out.nodes.reserve(nodeCount); + for (uint32_t i = 0; i < nodeCount; ++i) { + Node node; + if (!blocksReader.readU64(node.offset) || + !blocksReader.readU64(node.size) || + !blocksReader.readU32(node.flags) || + !blocksReader.readCString(node.path)) { + error = "failed to read UnityFS node table"; + return false; + } + out.nodes.push_back(std::move(node)); + } + + out.header.blockDataStart = reader.pos; + if (!blocksInfoAtEnd) { + out.header.blockDataStart += out.header.compressedBlocksInfoSize; + } + if ((out.header.flags & 0x200u) != 0) { + out.header.blockDataStart = alignUp(out.header.blockDataStart, 16); + } + + if (out.header.blockDataStart > bytes.size()) { + error = "UnityFS block data start is out of bounds"; + return false; + } + + out.rawData.clear(); + out.rawData.reserve(static_cast(totalRawSize)); + + size_t blockCursor = out.header.blockDataStart; + for (const auto& block : out.blocks) { + if (blockCursor + block.compressedSize > bytes.size()) { + error = "UnityFS block range is out of bounds"; + return false; + } + const auto compressedBlock = bytes.subspan(blockCursor, block.compressedSize); + blockCursor += block.compressedSize; + + const uint16_t blockCompression = block.flags & 0x3Fu; + if (blockCompression == 0) { + if (block.uncompressedSize != block.compressedSize) { + error = "uncompressed block size mismatch"; + return false; + } + out.rawData.insert(out.rawData.end(), compressedBlock.begin(), compressedBlock.end()); + } else if (blockCompression == 2 || blockCompression == 3) { + std::vector rawBlock; + if (!decompressLz4Block(compressedBlock, block.uncompressedSize, rawBlock, error)) { + error = "bundle block " + error; + return false; + } + out.rawData.insert(out.rawData.end(), rawBlock.begin(), rawBlock.end()); + } else { + error = "unsupported UnityFS data block compression"; + return false; + } + } + + return true; + } + + inline bool rebuildUnityFsBundle(std::span prefix, const ParsedBundle& bundle, std::vector& out, std::string& error) { + error.clear(); + out.clear(); + + std::vector blocksInfo; + blocksInfo.resize(16, 0); + writeU32(blocksInfo, static_cast(bundle.blocks.size()), Endian::Big); + + size_t rawOffset = 0; + for (const auto& block : bundle.blocks) { + const uint64_t blockEnd = rawOffset + static_cast(block.uncompressedSize); + if (blockEnd > bundle.rawData.size()) { + error = "raw data is shorter than block table"; + return false; + } + const uint16_t rebuiltBlockFlags = static_cast(block.flags & ~0x3Fu); + writeU32(blocksInfo, block.uncompressedSize, Endian::Big); + writeU32(blocksInfo, block.uncompressedSize, Endian::Big); + writeU16(blocksInfo, rebuiltBlockFlags, Endian::Big); + rawOffset += block.uncompressedSize; + } + if (rawOffset != bundle.rawData.size()) { + error = "raw data size does not match block table"; + return false; + } + + writeU32(blocksInfo, static_cast(bundle.nodes.size()), Endian::Big); + for (const auto& node : bundle.nodes) { + if (node.offset + node.size > bundle.rawData.size()) { + error = "node range is out of bounds"; + return false; + } + writeU64(blocksInfo, node.offset, Endian::Big); + writeU64(blocksInfo, node.size, Endian::Big); + writeU32(blocksInfo, node.flags, Endian::Big); + blocksInfo.insert(blocksInfo.end(), node.path.begin(), node.path.end()); + blocksInfo.push_back(0); + } + + uint32_t outFlags = bundle.header.flags & ~0x3Fu; + if ((outFlags & 0xC0u) == 0u) { + outFlags |= 0x40u; + } + const bool blocksInfoAtEnd = (outFlags & 0x80u) != 0u; + + out.insert(out.end(), prefix.begin(), prefix.end()); + const size_t streamStart = out.size(); + + out.insert(out.end(), bundle.header.signature.begin(), bundle.header.signature.end()); + out.push_back(0); + writeU32(out, bundle.header.version, Endian::Big); + out.insert(out.end(), bundle.header.unityVersion.begin(), bundle.header.unityVersion.end()); + out.push_back(0); + out.insert(out.end(), bundle.header.unityRevision.begin(), bundle.header.unityRevision.end()); + out.push_back(0); + + const size_t sizeFieldPos = out.size(); + writeU64(out, 0, Endian::Big); + writeU32(out, static_cast(blocksInfo.size()), Endian::Big); + writeU32(out, static_cast(blocksInfo.size()), Endian::Big); + writeU32(out, outFlags, Endian::Big); + + if (bundle.header.version >= 7) { + while (((out.size() - streamStart) % 16u) != 0u) { + out.push_back(0); + } + } + + if (!blocksInfoAtEnd) { + out.insert(out.end(), blocksInfo.begin(), blocksInfo.end()); + } + + if ((outFlags & 0x200u) != 0u) { + while (((out.size() - streamStart) % 16u) != 0u) { + out.push_back(0); + } + } + + rawOffset = 0; + for (const auto& block : bundle.blocks) { + const size_t len = block.uncompressedSize; + out.insert(out.end(), + bundle.rawData.begin() + static_cast(rawOffset), + bundle.rawData.begin() + static_cast(rawOffset + len)); + rawOffset += len; + } + + if (blocksInfoAtEnd) { + out.insert(out.end(), blocksInfo.begin(), blocksInfo.end()); + } + + const uint64_t bundleSize = out.size() - streamStart; + if (sizeFieldPos + 8 > out.size()) { + error = "invalid size field position"; + return false; + } + out[sizeFieldPos + 0] = static_cast((bundleSize >> 56) & 0xFFu); + out[sizeFieldPos + 1] = static_cast((bundleSize >> 48) & 0xFFu); + out[sizeFieldPos + 2] = static_cast((bundleSize >> 40) & 0xFFu); + out[sizeFieldPos + 3] = static_cast((bundleSize >> 32) & 0xFFu); + out[sizeFieldPos + 4] = static_cast((bundleSize >> 24) & 0xFFu); + out[sizeFieldPos + 5] = static_cast((bundleSize >> 16) & 0xFFu); + out[sizeFieldPos + 6] = static_cast((bundleSize >> 8) & 0xFFu); + out[sizeFieldPos + 7] = static_cast(bundleSize & 0xFFu); + return true; + } + + inline std::filesystem::path makePatchedOutputPath(const std::filesystem::path& sourcePath, + uint64_t loadOffset, + uint32_t desiredBuildTarget, + uint64_t fileSize, + long long lastWriteTicks) { + std::string key = sourcePath.string(); + key.push_back('|'); + key += std::to_string(loadOffset); + key.push_back('|'); + key += std::to_string(desiredBuildTarget); + key.push_back('|'); + key += std::to_string(fileSize); + key.push_back('|'); + key += std::to_string(lastWriteTicks); + + const auto outputDir = sourcePath.parent_path() / ".linkura_localify_patched"; + const auto stem = sourcePath.filename().string(); + const auto hash = hexU64(fnv1a64(key)); + return outputDir / (stem + "." + hash); + } + } // namespace Detail + + enum : uint32_t { + kBuildTargetIos = 0x09, + kBuildTargetAndroid = 0x0D, + }; + + struct PatchLoadResult { + std::string loadPath; + uint64_t loadOffset = 0; + bool usedPatchedCopy = false; + bool targetAlreadyMatched = false; + bool foundSerializedFile = false; + size_t patchedSerializedFileCount = 0; + uint32_t firstObservedBuildTarget = 0; + uint64_t actualOffset = 0; + uint64_t firstNodeOffset = 0; + uint64_t firstNodeSize = 0; + uint64_t firstTargetOffsetInNode = 0; + uint64_t firstTargetAbsoluteRawOffset = 0; + int64_t firstBlockIndex = -1; + uint32_t firstBlockCompression = 0; + bool firstDirectFileOffsetValid = false; + uint64_t firstDirectFileOffset = 0; + std::string error; + }; + + struct PatchFileResult { + bool patched = false; + bool targetAlreadyMatched = false; + bool foundSerializedFile = false; + size_t patchedSerializedFileCount = 0; + uint32_t firstObservedBuildTarget = 0; + uint64_t actualOffset = 0; + uint64_t firstNodeOffset = 0; + uint64_t firstNodeSize = 0; + uint64_t firstTargetOffsetInNode = 0; + uint64_t firstTargetAbsoluteRawOffset = 0; + int64_t firstBlockIndex = -1; + uint32_t firstBlockCompression = 0; + bool firstDirectFileOffsetValid = false; + uint64_t firstDirectFileOffset = 0; + std::string error; + }; + + struct MemoryPatchField { + size_t offsetInBlock = 0; + uint32_t originalValue = 0; + uint32_t patchedValue = 0; + Detail::Endian endian = Detail::Endian::Little; + }; + + struct MemoryPatchBlock { + size_t blockIndex = 0; + uint32_t compressedSize = 0; + uint32_t uncompressedSize = 0; + std::array compressedPrefix{}; + size_t compressedPrefixSize = 0; + std::vector fields; + }; + + struct MemoryPatchPlan { + bool foundSerializedFile = false; + bool targetAlreadyMatched = false; + size_t patchedSerializedFileCount = 0; + uint32_t firstObservedBuildTarget = 0; + uint64_t actualOffset = 0; + std::vector blocks; + std::string error; + }; + + inline MemoryPatchPlan BuildMemoryPatchPlanForLz4Blocks(const std::string& sourcePath, + uint64_t requestedOffset, + uint32_t desiredBuildTarget) { + MemoryPatchPlan result; + if (sourcePath.empty()) { + result.error = "source path is empty"; + return result; + } + + const std::filesystem::path path(sourcePath); + std::vector fileBytes; + if (!Detail::readFileBytes(path, fileBytes, result.error)) { + return result; + } + + const auto tryBuildAtOffset = [&](uint64_t actualOffset, MemoryPatchPlan& state) -> bool { + if (actualOffset > fileBytes.size()) { + state.error = "load offset is out of bounds"; + return false; + } + + uint64_t bundleOffset = 0; + if (!Detail::resolveUnityFsOffset(std::span(fileBytes.data(), fileBytes.size()), + actualOffset, + bundleOffset)) { + state.error = "not a UnityFS/LZ4 bundle"; + return false; + } + + const auto stream = std::span(fileBytes.data() + static_cast(bundleOffset), + fileBytes.size() - static_cast(bundleOffset)); + Detail::ParsedBundle bundle; + std::string parseError; + if (!Detail::parseUnityFsBundle(stream, bundle, parseError)) { + state.error = parseError.empty() ? "not a UnityFS/LZ4 bundle" : parseError; + return false; + } + + size_t compressedCursor = bundle.header.blockDataStart; + std::vector rawBlockStarts; + rawBlockStarts.reserve(bundle.blocks.size()); + size_t rawCursor = 0; + for (size_t i = 0; i < bundle.blocks.size(); ++i) { + const auto& block = bundle.blocks[i]; + rawBlockStarts.push_back(rawCursor); + rawCursor += block.uncompressedSize; + + if (compressedCursor + block.compressedSize > stream.size()) { + state.error = "compressed block range is out of bounds"; + return false; + } + compressedCursor += block.compressedSize; + } + + bool firstObservedSet = false; + for (const auto& node : bundle.nodes) { + if (node.offset + node.size > bundle.rawData.size()) continue; + auto nodeSpan = std::span(bundle.rawData.data() + static_cast(node.offset), + static_cast(node.size)); + const auto field = Detail::findSerializedFileTargetField(nodeSpan); + if (!field) continue; + + state.foundSerializedFile = true; + if (!firstObservedSet) { + firstObservedSet = true; + state.firstObservedBuildTarget = field->currentValue; + } + if (field->currentValue == desiredBuildTarget) continue; + + const size_t absoluteRawOffset = static_cast(node.offset) + field->offset; + size_t blockIndex = 0; + bool foundBlock = false; + for (; blockIndex < bundle.blocks.size(); ++blockIndex) { + const size_t blockStart = rawBlockStarts[blockIndex]; + const size_t blockEnd = blockStart + bundle.blocks[blockIndex].uncompressedSize; + if (absoluteRawOffset + 4 <= blockEnd) { + foundBlock = true; + break; + } + } + if (!foundBlock) { + state.error = "target field is not inside any raw block"; + return false; + } + + const auto& block = bundle.blocks[blockIndex]; + const uint16_t compression = block.flags & 0x3Fu; + if (compression != 2 && compression != 3) { + state.error = "target field is in a non-LZ4 block"; + return false; + } + + auto& patchBlock = [&]() -> MemoryPatchBlock& { + for (auto& existing : state.blocks) { + if (existing.blockIndex == blockIndex) return existing; + } + MemoryPatchBlock created; + created.blockIndex = blockIndex; + created.compressedSize = block.compressedSize; + created.uncompressedSize = block.uncompressedSize; + + size_t tmpCompressedCursor = bundle.header.blockDataStart; + for (size_t i = 0; i < blockIndex; ++i) { + tmpCompressedCursor += bundle.blocks[i].compressedSize; + } + created.compressedPrefixSize = std::min(created.compressedPrefix.size(), block.compressedSize); + if (created.compressedPrefixSize > 0) { + std::memcpy(created.compressedPrefix.data(), + stream.data() + static_cast(tmpCompressedCursor), + created.compressedPrefixSize); + } + + state.blocks.push_back(created); + return state.blocks.back(); + }(); + + patchBlock.fields.push_back(MemoryPatchField{ + .offsetInBlock = absoluteRawOffset - rawBlockStarts[blockIndex], + .originalValue = field->currentValue, + .patchedValue = desiredBuildTarget, + .endian = field->endian, + }); + ++state.patchedSerializedFileCount; + } + + state.actualOffset = bundleOffset; + state.targetAlreadyMatched = state.foundSerializedFile && state.patchedSerializedFileCount == 0; + if (!state.foundSerializedFile) { + state.error = "no serialized file found inside UnityFS bundle"; + return false; + } + return true; + }; + + if (tryBuildAtOffset(requestedOffset, result)) { + return result; + } + + if (result.error.empty()) { + result.error = "failed to build memory patch plan"; + } + return result; + } + + inline PatchFileResult PatchFileTargetBuildIdInPlace(const std::string& sourcePath, + uint64_t requestedOffset, + uint32_t desiredBuildTarget) { + PatchFileResult result; + if (sourcePath.empty()) { + result.error = "source path is empty"; + return result; + } + + const std::filesystem::path path(sourcePath); + std::vector fileBytes; + if (!Detail::readFileBytes(path, fileBytes, result.error)) { + return result; + } + + const auto tryPatchAtOffset = [&](uint64_t actualOffset, PatchFileResult& state) -> bool { + if (actualOffset > fileBytes.size()) { + state.error = "load offset is out of bounds"; + return false; + } + + auto stream = std::span(fileBytes.data() + static_cast(actualOffset), + fileBytes.size() - static_cast(actualOffset)); + if (stream.empty()) { + state.error = "load stream is empty"; + return false; + } + + std::vector patchedBytes; + std::string parseError; + + Detail::ParsedBundle bundle; + uint64_t bundleOffset = 0; + const bool isUnityFs = Detail::resolveUnityFsOffset(std::span(fileBytes.data(), fileBytes.size()), + actualOffset, + bundleOffset); + const auto bundleStream = isUnityFs + ? std::span(fileBytes.data() + static_cast(bundleOffset), + fileBytes.size() - static_cast(bundleOffset)) + : std::span{}; + if (isUnityFs && Detail::parseUnityFsBundle(bundleStream, bundle, parseError)) { + state.actualOffset = bundleOffset; + bool firstObservedSet = false; + std::vector rawBlockStarts; + rawBlockStarts.reserve(bundle.blocks.size()); + size_t rawCursor = 0; + for (const auto& block : bundle.blocks) { + rawBlockStarts.push_back(rawCursor); + rawCursor += block.uncompressedSize; + } + for (const auto& node : bundle.nodes) { + if (node.offset + node.size > bundle.rawData.size()) continue; + auto nodeSpan = std::span(bundle.rawData.data() + static_cast(node.offset), + static_cast(node.size)); + const auto field = Detail::findSerializedFileTargetField(nodeSpan); + if (!field) continue; + + state.foundSerializedFile = true; + if (!firstObservedSet) { + firstObservedSet = true; + state.firstObservedBuildTarget = field->currentValue; + state.firstNodeOffset = node.offset; + state.firstNodeSize = node.size; + state.firstTargetOffsetInNode = field->offset; + state.firstTargetAbsoluteRawOffset = node.offset + field->offset; + for (size_t i = 0; i < bundle.blocks.size(); ++i) { + const size_t blockStart = rawBlockStarts[i]; + const size_t blockEnd = blockStart + bundle.blocks[i].uncompressedSize; + if (state.firstTargetAbsoluteRawOffset + 4 <= blockEnd) { + state.firstBlockIndex = static_cast(i); + state.firstBlockCompression = bundle.blocks[i].flags & 0x3Fu; + if (state.firstBlockCompression == 0) { + size_t compressedBlockStart = bundle.header.blockDataStart; + for (size_t j = 0; j < i; ++j) { + compressedBlockStart += bundle.blocks[j].compressedSize; + } + state.firstDirectFileOffsetValid = true; + state.firstDirectFileOffset = bundleOffset + + compressedBlockStart + + (state.firstTargetAbsoluteRawOffset - blockStart); + } + break; + } + } + } + if (field->currentValue == desiredBuildTarget) continue; + + auto mutableSpan = std::span(bundle.rawData.data(), bundle.rawData.size()); + if (!Detail::patchU32(mutableSpan, + static_cast(node.offset) + field->offset, + desiredBuildTarget, + field->endian)) { + state.error = "failed to patch serialized file target"; + return false; + } + ++state.patchedSerializedFileCount; + } + + state.targetAlreadyMatched = state.foundSerializedFile && state.patchedSerializedFileCount == 0; + if (state.patchedSerializedFileCount == 0) { + if (!state.foundSerializedFile) { + state.error = "no serialized file found inside UnityFS bundle"; + } + return state.foundSerializedFile; + } + + if (!Detail::rebuildUnityFsBundle(std::span(fileBytes.data(), static_cast(bundleOffset)), + bundle, + patchedBytes, + state.error)) { + return false; + } + } else { + const auto field = Detail::findSerializedFileTargetField(stream); + if (!field) { + state.error = parseError.empty() ? "unsupported asset format" : parseError; + return false; + } + + state.actualOffset = actualOffset; + state.foundSerializedFile = true; + state.firstObservedBuildTarget = field->currentValue; + state.firstTargetOffsetInNode = field->offset; + state.firstDirectFileOffsetValid = true; + state.firstDirectFileOffset = actualOffset + field->offset; + state.targetAlreadyMatched = field->currentValue == desiredBuildTarget; + if (state.targetAlreadyMatched) { + return true; + } + + patchedBytes = fileBytes; + if (!Detail::patchU32(std::span(patchedBytes.data() + static_cast(actualOffset), + patchedBytes.size() - static_cast(actualOffset)), + field->offset, + desiredBuildTarget, + field->endian)) { + state.error = "failed to patch direct serialized file target"; + return false; + } + state.patchedSerializedFileCount = 1; + } + + if (state.patchedSerializedFileCount == 0) { + return true; + } + + if (!Detail::writeFileBytesAtomically(path, patchedBytes, state.error)) { + return false; + } + state.patched = true; + return true; + }; + + if (tryPatchAtOffset(requestedOffset, result)) { + return result; + } + + if (result.error.empty()) { + result.error = "failed to patch source file"; + } + return result; + } + + inline PatchLoadResult PreparePatchedLoadTarget(const std::string& sourcePath, + uint64_t requestedOffset, + uint32_t desiredBuildTarget) { + PatchLoadResult result{ + .loadPath = sourcePath, + .loadOffset = requestedOffset, + }; + if (sourcePath.empty()) { + result.error = "source path is empty"; + return result; + } + + const std::filesystem::path path(sourcePath); + std::error_code ec; + const auto fileSize = std::filesystem::file_size(path, ec); + if (ec) { + result.error = "file_size failed"; + return result; + } + const auto lastWrite = std::filesystem::last_write_time(path, ec); + if (ec) { + result.error = "last_write_time failed"; + return result; + } + + std::vector fileBytes; + if (!Detail::readFileBytes(path, fileBytes, result.error)) { + return result; + } + + const auto tryPatchAtOffset = [&](uint64_t actualOffset, PatchLoadResult& state) -> bool { + if (actualOffset > fileBytes.size()) { + state.error = "load offset is out of bounds"; + return false; + } + + const auto stream = std::span(fileBytes.data() + static_cast(actualOffset), + fileBytes.size() - static_cast(actualOffset)); + if (stream.empty()) { + state.error = "load stream is empty"; + return false; + } + + std::vector patchedBytes; + std::string parseError; + + Detail::ParsedBundle bundle; + uint64_t bundleOffset = 0; + const bool isUnityFs = Detail::resolveUnityFsOffset(std::span(fileBytes.data(), fileBytes.size()), + actualOffset, + bundleOffset); + const auto bundleStream = isUnityFs + ? std::span(fileBytes.data() + static_cast(bundleOffset), + fileBytes.size() - static_cast(bundleOffset)) + : std::span{}; + if (isUnityFs && Detail::parseUnityFsBundle(bundleStream, bundle, parseError)) { + state.loadOffset = bundleOffset; + state.actualOffset = bundleOffset; + bool firstObservedSet = false; + std::vector rawBlockStarts; + rawBlockStarts.reserve(bundle.blocks.size()); + size_t rawCursor = 0; + for (const auto& block : bundle.blocks) { + rawBlockStarts.push_back(rawCursor); + rawCursor += block.uncompressedSize; + } + for (const auto& node : bundle.nodes) { + if (node.offset + node.size > bundle.rawData.size()) continue; + auto nodeSpan = std::span(bundle.rawData.data() + static_cast(node.offset), + static_cast(node.size)); + const auto field = Detail::findSerializedFileTargetField(nodeSpan); + if (!field) continue; + + state.foundSerializedFile = true; + if (!firstObservedSet) { + firstObservedSet = true; + state.firstObservedBuildTarget = field->currentValue; + state.firstNodeOffset = node.offset; + state.firstNodeSize = node.size; + state.firstTargetOffsetInNode = field->offset; + state.firstTargetAbsoluteRawOffset = node.offset + field->offset; + for (size_t i = 0; i < bundle.blocks.size(); ++i) { + const size_t blockStart = rawBlockStarts[i]; + const size_t blockEnd = blockStart + bundle.blocks[i].uncompressedSize; + if (state.firstTargetAbsoluteRawOffset + 4 <= blockEnd) { + state.firstBlockIndex = static_cast(i); + state.firstBlockCompression = bundle.blocks[i].flags & 0x3Fu; + if (state.firstBlockCompression == 0) { + size_t compressedBlockStart = bundle.header.blockDataStart; + for (size_t j = 0; j < i; ++j) { + compressedBlockStart += bundle.blocks[j].compressedSize; + } + state.firstDirectFileOffsetValid = true; + state.firstDirectFileOffset = bundleOffset + + compressedBlockStart + + (state.firstTargetAbsoluteRawOffset - blockStart); + } + break; + } + } + } + if (field->currentValue == desiredBuildTarget) continue; + + auto mutableSpan = std::span(bundle.rawData.data(), bundle.rawData.size()); + if (!Detail::patchU32(mutableSpan, + static_cast(node.offset) + field->offset, + desiredBuildTarget, + field->endian)) { + state.error = "failed to patch serialized file target"; + return false; + } + ++state.patchedSerializedFileCount; + } + + state.targetAlreadyMatched = state.foundSerializedFile && state.patchedSerializedFileCount == 0; + if (state.patchedSerializedFileCount == 0) { + if (!state.foundSerializedFile) { + state.error = "no serialized file found inside UnityFS bundle"; + } + return state.foundSerializedFile; + } + + if (!Detail::rebuildUnityFsBundle(std::span(fileBytes.data(), static_cast(bundleOffset)), + bundle, + patchedBytes, + state.error)) { + return false; + } + } else { + const auto field = Detail::findSerializedFileTargetField(stream); + if (!field) { + state.error = parseError.empty() ? "unsupported asset format" : parseError; + return false; + } + + state.actualOffset = actualOffset; + state.foundSerializedFile = true; + state.firstObservedBuildTarget = field->currentValue; + state.firstTargetOffsetInNode = field->offset; + state.firstDirectFileOffsetValid = true; + state.firstDirectFileOffset = actualOffset + field->offset; + state.targetAlreadyMatched = field->currentValue == desiredBuildTarget; + if (state.targetAlreadyMatched) { + return true; + } + + patchedBytes = fileBytes; + if (!Detail::patchU32(std::span(patchedBytes.data() + static_cast(actualOffset), + patchedBytes.size() - static_cast(actualOffset)), + field->offset, + desiredBuildTarget, + field->endian)) { + state.error = "failed to patch direct serialized file target"; + return false; + } + state.patchedSerializedFileCount = 1; + } + + if (state.patchedSerializedFileCount == 0) { + return true; + } + + const auto outputPath = Detail::makePatchedOutputPath(path, + state.loadOffset, + desiredBuildTarget, + fileSize, + lastWrite.time_since_epoch().count()); + state.loadPath = outputPath.string(); + state.usedPatchedCopy = true; + + if (std::filesystem::exists(outputPath, ec) && !ec) { + return true; + } + + if (!Detail::writeFileBytesAtomically(outputPath, patchedBytes, state.error)) { + state.usedPatchedCopy = false; + state.loadPath = sourcePath; + state.loadOffset = requestedOffset; + return false; + } + return true; + }; + + if (tryPatchAtOffset(requestedOffset, result)) { + return result; + } + + if (result.error.empty()) { + result.error = "failed to prepare patched load target"; + } + return result; + } +} // namespace LinkuraLocal::UnityAssetHelper diff --git a/app/src/main/cpp/LinkuraLocalify/config/Config.cpp b/app/src/main/cpp/LinkuraLocalify/config/Config.cpp index 003b520..88b326d 100644 --- a/app/src/main/cpp/LinkuraLocalify/config/Config.cpp +++ b/app/src/main/cpp/LinkuraLocalify/config/Config.cpp @@ -16,6 +16,7 @@ namespace LinkuraLocal::Config { bool renderHighResolution = true; bool fesArchiveUnlockTicket = false; bool lazyInit = true; + bool loginAsIOS = false; bool replaceFont = true; bool textTest = false; bool dumpText = false; @@ -86,6 +87,7 @@ namespace LinkuraLocal::Config { GetConfigItem(renderHighResolution); GetConfigItem(fesArchiveUnlockTicket); GetConfigItem(lazyInit); + GetConfigItem(loginAsIOS); GetConfigItem(replaceFont); GetConfigItem(textTest); GetConfigItem(dumpText); @@ -176,6 +178,7 @@ namespace LinkuraLocal::Config { if (configUpdate.has_render_high_resolution()) renderHighResolution = configUpdate.render_high_resolution(); if (configUpdate.has_fes_archive_unlock_ticket()) fesArchiveUnlockTicket = configUpdate.fes_archive_unlock_ticket(); if (configUpdate.has_lazy_init()) lazyInit = configUpdate.lazy_init(); + if (configUpdate.has_login_as_ios()) loginAsIOS = configUpdate.login_as_ios(); if (configUpdate.has_replace_font()) replaceFont = configUpdate.replace_font(); if (configUpdate.has_text_test()) textTest = configUpdate.text_test(); if (configUpdate.has_dump_text()) dumpText = configUpdate.dump_text(); diff --git a/app/src/main/cpp/LinkuraLocalify/config/Config.hpp b/app/src/main/cpp/LinkuraLocalify/config/Config.hpp index ff4972b..1079346 100644 --- a/app/src/main/cpp/LinkuraLocalify/config/Config.hpp +++ b/app/src/main/cpp/LinkuraLocalify/config/Config.hpp @@ -14,6 +14,7 @@ namespace LinkuraLocal::Config { extern bool renderHighResolution; extern bool fesArchiveUnlockTicket; extern bool lazyInit; + extern bool loginAsIOS; extern bool replaceFont; extern bool textTest; extern bool dumpText; diff --git a/app/src/main/cpp/LinkuraLocalify/hooks/HookDebug.cpp b/app/src/main/cpp/LinkuraLocalify/hooks/HookDebug.cpp index 4537e04..095f0ae 100644 --- a/app/src/main/cpp/LinkuraLocalify/hooks/HookDebug.cpp +++ b/app/src/main/cpp/LinkuraLocalify/hooks/HookDebug.cpp @@ -1,4 +1,5 @@ #include "../HookMain.h" +#include "../UnityAssetHelper.hpp" #include #include #include @@ -6,15 +7,712 @@ #include #include #include +#include + +#include +#include +#include +#include namespace LinkuraLocal::HookDebug { using Il2cppString = UnityResolve::UnityType::String; - DEFINE_HOOK(void, Internal_LogException, (void* ex, void* obj)) { - Internal_LogException_Orig(ex, obj); - static auto Exception_ToString = Il2cppUtils::GetMethod("mscorlib.dll", "System", "Exception", "ToString"); - Log::LogUnityLog(ANDROID_LOG_ERROR, "UnityLog - Internal_LogException:\n%s", Exception_ToString->Invoke(ex)->ToString().c_str()); - } + static thread_local bool tls_insideAssetBundleLoadFromFile = false; + + // Unity AssetBundle build target enum (BuildTarget): Android=13(0x0D), iOS=9(0x09). + // When loginAsIOS is enabled we still download iOS resources, but the Android Unity player expects + // AssetBundle metadata to say Android. The reliable path is to rewrite the source file's + // serialized m_TargetPlatform from iOS(9) to Android(13) before LoadFromFile_Internal reads it. + namespace { + constexpr uint16_t kBuildTargetAndroid = 0x0D; + constexpr uint16_t kBuildTargetIos = 0x09; + + struct CmpImmPatchSite { + uintptr_t addr = 0; // instruction address + uint32_t origInsn = 0; // original 32-bit instruction word + }; + + static std::mutex g_buildTargetPatchMutex; + static std::vector g_buildTargetPatchSites; + static std::atomic g_buildTargetPatchUsers{0}; + static uintptr_t g_unityLoadFromFileInternalAddr = 0; // resolved icall pointer + + struct NopPatchSite { + uintptr_t addr = 0; + uint32_t origInsn = 0; + uint32_t patchedInsn = 0; + }; + static std::vector g_buildTargetBypassBranchSites; + static std::atomic g_buildTargetBypassUsers{0}; + static std::chrono::steady_clock::time_point g_buildTargetBypassLastScan{}; + static int g_buildTargetBypassScanAttempts = 0; + + static bool is_cond_branch_insn(uint32_t insn) { + // B.cond encoding: 01010100 (0x54) at [31:24], bit[4]==0, cond in [3:0] + return (insn & 0xFF000010u) == 0x54000000u; + } + + static uintptr_t sign_extend(uintptr_t x, int bits) { + const uintptr_t m = static_cast(1) << (bits - 1); + return (x ^ m) - m; + } + + static bool is_adrp(uint32_t insn) { + // ADRP: op=1, fixed bits [28:24]=10000, mask from ARM64 encoding patterns. + return (insn & 0x9F000000u) == 0x90000000u; + } + + static bool is_adr(uint32_t insn) { + // ADR: op=0, fixed bits [28:24]=10000 + return (insn & 0x9F000000u) == 0x10000000u; + } + + static uintptr_t decode_adr_target(uint32_t insn, uintptr_t pc) { + // imm = sign_extend(immhi:immlo, 21); target = pc + imm + const uintptr_t immlo = (insn >> 29) & 0x3; + const uintptr_t immhi = (insn >> 5) & 0x7FFFF; + uintptr_t imm21 = (immhi << 2) | immlo; + imm21 = sign_extend(imm21, 21); + return pc + imm21; + } + + static bool is_add_imm64(uint32_t insn) { + // ADD (immediate, 64-bit): 0x91000000 with varying fields. + return (insn & 0xFF000000u) == 0x91000000u; + } + + static uintptr_t decode_adrp_target(uint32_t insn, uintptr_t pc) { + // imm = sign_extend(immhi:immlo, 21) << 12; target = (pc & ~0xFFF) + imm + const uintptr_t immlo = (insn >> 29) & 0x3; + const uintptr_t immhi = (insn >> 5) & 0x7FFFF; + uintptr_t imm21 = (immhi << 2) | immlo; + imm21 = sign_extend(imm21, 21); + const uintptr_t imm = imm21 << 12; + return (pc & ~static_cast(0xFFF)) + imm; + } + + static bool decode_add_imm64(uint32_t insn, uint32_t& rd, uint32_t& rn, uint32_t& imm12, uint32_t& shift) { + if (!is_add_imm64(insn)) return false; + rd = insn & 0x1F; + rn = (insn >> 5) & 0x1F; + imm12 = (insn >> 10) & 0xFFF; + shift = (insn >> 22) & 0x3; + return true; + } + + static bool is_ldr_x_uimm(uint32_t insn) { + // LDR Xt, [Xn, #imm12*8] + return (insn & 0xFFC00000u) == 0xF9400000u; + } + + static bool decode_ldr_x_uimm(uint32_t insn, uint32_t& rt, uint32_t& rn, uint32_t& imm12) { + if (!is_ldr_x_uimm(insn)) return false; + rt = insn & 0x1F; + rn = (insn >> 5) & 0x1F; + imm12 = (insn >> 10) & 0xFFF; + return true; + } + + static bool is_ldr_lit64(uint32_t insn) { + // LDR Xt, #imm19 (literal, 64-bit) + return (insn & 0xFF000000u) == 0x58000000u; + } + + static uintptr_t decode_ldr_lit_target(uint32_t insn, uintptr_t pc) { + // imm19 in bits [23:5], signed, <<2. Address is PC-relative. + uintptr_t imm19 = (insn >> 5) & 0x7FFFF; + imm19 = sign_extend(imm19, 19); + return pc + (imm19 << 2); + } + + static bool is_cmp_imm_32(uint32_t insn, uint16_t imm12) { + // cmp wN, #imm == subs wzr, wN, #imm (shift=0) + // SUBS (immediate, 32-bit) base: 0x71000000, Rd=WZR => low5=0x1F + if ((insn & 0x7F00001Fu) != 0x7100001Fu) return false; + if (((insn >> 22) & 0x3u) != 0) return false; // shift must be 0 + return ((insn >> 10) & 0xFFFu) == imm12; + } + + static bool is_cmp_imm_64(uint32_t insn, uint16_t imm12) { + // cmp xN, #imm == subs xzr, xN, #imm (shift=0) + if ((insn & 0xFF00001Fu) != 0xF100001Fu) return false; + if (((insn >> 22) & 0x3u) != 0) return false; // shift must be 0 + return ((insn >> 10) & 0xFFFu) == imm12; + } + + static bool write_code_u32(uintptr_t addr, uint32_t value) { + const long pageSize = sysconf(_SC_PAGESIZE); + if (pageSize <= 0) return false; + const uintptr_t pageStart = addr & ~(static_cast(pageSize) - 1); + if (mprotect(reinterpret_cast(pageStart), static_cast(pageSize), + PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { + return false; + } + *reinterpret_cast(addr) = value; + __builtin___clear_cache(reinterpret_cast(pageStart), + reinterpret_cast(pageStart + pageSize)); + // Best-effort restore to RX. + (void)mprotect(reinterpret_cast(pageStart), static_cast(pageSize), + PROT_READ | PROT_EXEC); + return true; + } + + static uintptr_t find_mapping_start(uintptr_t addr) { + FILE* fp = std::fopen("/proc/self/maps", "r"); + if (!fp) return 0; + char line[512]; + uintptr_t start = 0, end = 0; + while (std::fgets(line, sizeof(line), fp)) { + start = 0; end = 0; + if (std::sscanf(line, "%lx-%lx", &start, &end) == 2) { + if (addr >= start && addr < end) { + std::fclose(fp); + return start; + } + } + } + std::fclose(fp); + return 0; + } + + static uintptr_t find_mapping_end(uintptr_t addr) { + // Parse /proc/self/maps to find the mapping containing addr. + // Format: start-end perms offset dev inode pathname? + FILE* fp = std::fopen("/proc/self/maps", "r"); + if (!fp) return 0; + char line[512]; + uintptr_t start = 0, end = 0; + while (std::fgets(line, sizeof(line), fp)) { + start = 0; end = 0; + if (std::sscanf(line, "%lx-%lx", &start, &end) == 2) { + if (addr >= start && addr < end) { + std::fclose(fp); + return end; + } + } + } + std::fclose(fp); + return 0; + } + + struct MapsSeg { + uintptr_t start = 0; + uintptr_t end = 0; + char perms[5] = {0}; + unsigned long inode = 0; + char path[256] = {0}; + }; + + static std::vector list_all_segments() { + std::vector segs; + FILE* fp = std::fopen("/proc/self/maps", "r"); + if (!fp) return segs; + char line[1024]; + while (std::fgets(line, sizeof(line), fp)) { + MapsSeg s; + unsigned long st = 0, ed = 0; + char perms[5] = {0}; + unsigned long inode = 0; + char path[256] = {0}; + // pathname is optional + // start-end perms offset dev inode pathname? + // Conversions: st, ed, perms, inode, path => max n = 5 + const int n = std::sscanf(line, "%lx-%lx %4s %*s %*s %lu %255s", &st, &ed, perms, &inode, path); + if (n < 3) continue; + s.start = static_cast(st); + s.end = static_cast(ed); + s.inode = (n >= 4) ? inode : 0; + std::snprintf(s.perms, sizeof(s.perms), "%s", perms); + if (n >= 5) { + std::snprintf(s.path, sizeof(s.path), "%s", path); + } else { + s.path[0] = '\0'; + } + segs.push_back(s); + } + std::fclose(fp); + return segs; + } + + static bool find_segment_for_addr(const std::vector& segs, uintptr_t addr, MapsSeg& out) { + for (const auto& s : segs) { + if (addr >= s.start && addr < s.end) { + out = s; + return true; + } + } + return false; + } + + static std::vector filter_segments_by_inode(const std::vector& segs, unsigned long inode) { + std::vector out; + if (!inode) return out; + out.reserve(16); + for (const auto& s : segs) { + if (s.inode == inode) out.push_back(s); + } + return out; + } + + static std::vector filter_segments_by_path_substr(const std::vector& segs, const char* substr) { + std::vector out; + if (!substr || !*substr) return out; + out.reserve(16); + for (const auto& s : segs) { + if (std::strstr(s.path, substr)) out.push_back(s); + } + return out; + } + + static const uint8_t* memmem_u8(const uint8_t* hay, size_t hayLen, const char* needle) { + if (!hay || !needle) return nullptr; + const size_t nLen = std::strlen(needle); + if (nLen == 0 || hayLen < nLen) return nullptr; + for (size_t i = 0; i + nLen <= hayLen; i++) { + if (std::memcmp(hay + i, needle, nLen) == 0) return hay + i; + } + return nullptr; + } + + static uintptr_t find_cstr_start(const uint8_t* segStart, const uint8_t* p, size_t maxBack = 0x200) { + // `p` points inside a C-string. Walk backwards to find its start (after previous '\0'). + if (!segStart || !p) return reinterpret_cast(p); + const uint8_t* cur = p; + size_t walked = 0; + while (cur > segStart && walked < maxBack) { + if (*(cur - 1) == 0) break; + cur--; + walked++; + } + return reinterpret_cast(cur); + } + + static bool is_readable_range(uintptr_t addr, size_t size) { + if (size == 0) return true; + const uintptr_t end = addr + size; + FILE* fp = std::fopen("/proc/self/maps", "r"); + if (!fp) return false; + char line[512]; + uintptr_t start = 0, stop = 0; + char perms[5] = {0}; + while (std::fgets(line, sizeof(line), fp)) { + start = 0; stop = 0; perms[0] = 0; + if (std::sscanf(line, "%lx-%lx %4s", &start, &stop, perms) == 3) { + if (addr >= start && end <= stop) { + std::fclose(fp); + return perms[0] == 'r'; + } + } + } + std::fclose(fp); + return false; + } + + static bool is_cbz_cbnz(uint32_t insn) { + // CBZ/CBNZ: opcodes 0x34/0x35 in top bits. Mask: 0x7F000000 == 0x34000000. + return (insn & 0x7F000000u) == 0x34000000u; + } + + static bool is_tbz_tbnz(uint32_t insn) { + // TBZ/TBNZ: 0x36000000 with mask 0x7F000000 == 0x36000000. + return (insn & 0x7F000000u) == 0x36000000u; + } + + static uintptr_t decode_bcond_target(uint32_t insn, uintptr_t pc) { + // imm19 in bits [23:5], signed, <<2. + uintptr_t imm19 = (insn >> 5) & 0x7FFFF; + imm19 = sign_extend(imm19, 19); + return pc + (imm19 << 2); + } + + static uintptr_t decode_cb_target(uint32_t insn, uintptr_t pc) { + // imm19 in bits [23:5], signed, <<2. + uintptr_t imm19 = (insn >> 5) & 0x7FFFF; + imm19 = sign_extend(imm19, 19); + return pc + (imm19 << 2); + } + + static uintptr_t decode_tb_target(uint32_t insn, uintptr_t pc) { + // imm14 in bits [18:5], signed, <<2. + uintptr_t imm14 = (insn >> 5) & 0x3FFF; + imm14 = sign_extend(imm14, 14); + return pc + (imm14 << 2); + } + + static bool encode_uncond_b(uintptr_t pc, uintptr_t target, uint32_t& outInsn) { + // Unconditional B: 0x14000000 | imm26, where imm26 = (target - pc) >> 2 (signed). + const intptr_t delta = static_cast(target) - static_cast(pc); + if ((delta & 0x3) != 0) return false; + const intptr_t imm26 = delta >> 2; + if (imm26 < -(1 << 25) || imm26 >= (1 << 25)) return false; // signed 26-bit + outInsn = 0x14000000u | (static_cast(imm26) & 0x03FFFFFFu); + return true; + } + + static void ensure_build_target_bypass_sites_locked() { + if (!g_buildTargetBypassBranchSites.empty()) return; + + const auto allSegs = list_all_segments(); + if (allSegs.empty()) return; + + // Retry scanning periodically until we find patch sites. Unity may map modules/strings lazily. + const auto now = std::chrono::steady_clock::now(); + if (g_buildTargetBypassScanAttempts > 0) { + const auto dt = now - g_buildTargetBypassLastScan; + if (dt < std::chrono::seconds(3)) return; + } + g_buildTargetBypassLastScan = now; + g_buildTargetBypassScanAttempts++; + Log::WarnFmt("BuildTarget bypass: scan attempt %d (icall=%p)", g_buildTargetBypassScanAttempts, + (void*)g_unityLoadFromFileInternalAddr); + + // Prefer scanning within the same mapped module that contains the resolved icall pointer. + std::vector moduleSegs; + MapsSeg icallSeg{}; + if (g_unityLoadFromFileInternalAddr && find_segment_for_addr(allSegs, g_unityLoadFromFileInternalAddr, icallSeg)) { + if (icallSeg.inode) { + moduleSegs = filter_segments_by_inode(allSegs, icallSeg.inode); + } else if (icallSeg.path[0]) { + moduleSegs = filter_segments_by_path_substr(allSegs, icallSeg.path); + } + } + if (moduleSegs.empty()) { + // Fallback: try classic name; if even that fails, scan globally for the needle. + moduleSegs = filter_segments_by_path_substr(allSegs, "libunity.so"); + } + + if (moduleSegs.empty()) { + Log::WarnFmt("BuildTarget bypass: module identification failed (icall=%p segPath=%s inode=%lu).", + (void*)g_unityLoadFromFileInternalAddr, icallSeg.path, icallSeg.inode); + return; + } + Log::WarnFmt("BuildTarget bypass: icall seg %p-%p perms=%s inode=%lu path=%s", + (void*)icallSeg.start, (void*)icallSeg.end, icallSeg.perms, icallSeg.inode, icallSeg.path); + Log::WarnFmt("BuildTarget bypass: scanning module segments=%zu", moduleSegs.size()); + + constexpr const char* kNeedle = "File's Build target is:"; + uintptr_t hitAddr = 0; + uintptr_t strAddr = 0; // actual C-string start + MapsSeg needleSeg{}; + for (const auto& s : moduleSegs) { + if (s.perms[0] != 'r') continue; + const auto* mem = reinterpret_cast(s.start); + const size_t len = static_cast(s.end - s.start); + if (const uint8_t* hit = memmem_u8(mem, len, kNeedle)) { + hitAddr = reinterpret_cast(hit); + strAddr = find_cstr_start(mem, hit); + needleSeg = s; + break; + } + } + if (!strAddr) { + // As a fallback, scan all segments globally for the needle (string may live in a different module). + for (const auto& s : allSegs) { + if (s.perms[0] != 'r') continue; + const auto* mem = reinterpret_cast(s.start); + const size_t len = static_cast(s.end - s.start); + if (const uint8_t* hit = memmem_u8(mem, len, kNeedle)) { + hitAddr = reinterpret_cast(hit); + strAddr = find_cstr_start(mem, hit); + needleSeg = s; + break; + } + } + } + if (!strAddr) { + Log::WarnFmt("BuildTarget bypass: string needle not found."); + return; + } + Log::WarnFmt("BuildTarget bypass: needle hit=%p cstr=%p seg %p-%p perms=%s inode=%lu path=%s", + (void*)hitAddr, (void*)strAddr, + (void*)needleSeg.start, (void*)needleSeg.end, needleSeg.perms, needleSeg.inode, needleSeg.path); + { + char preview[96] = {0}; + std::strncpy(preview, reinterpret_cast(strAddr), sizeof(preview) - 1); + Log::WarnFmt("BuildTarget bypass: preview=\"%s\"", preview); + } + + // If the string is in a different module, re-scope scans to that module. + if (needleSeg.inode && (!icallSeg.inode || needleSeg.inode != icallSeg.inode)) { + moduleSegs = filter_segments_by_inode(allSegs, needleSeg.inode); + Log::WarnFmt("BuildTarget bypass: rescoping to needle inode=%lu segments=%zu", needleSeg.inode, moduleSegs.size()); + } + + // Find a code site that materializes the string address (in the same module as the string). + uintptr_t codeRefPc = 0; + const char* xrefKind = nullptr; + for (const auto& s : moduleSegs) { + if (s.perms[0] != 'r') continue; + if (std::strchr(s.perms, 'x') == nullptr) continue; + const uintptr_t start = s.start; + const uintptr_t end = s.end; + for (uintptr_t pc = start; pc + 8 <= end; pc += 4) { + const uint32_t i0 = *reinterpret_cast(pc); + if (is_adr(i0)) { + const uintptr_t full = decode_adr_target(i0, pc); + if (full == strAddr || (hitAddr && full == hitAddr)) { + codeRefPc = pc; + xrefKind = "ADR"; + break; + } + continue; + } + if (is_ldr_lit64(i0)) { + const uintptr_t lit = decode_ldr_lit_target(i0, pc); + if (is_readable_range(lit, sizeof(uintptr_t))) { + const uintptr_t ptr = *reinterpret_cast(lit); + if (ptr == strAddr || (hitAddr && ptr == hitAddr)) { + codeRefPc = pc; + xrefKind = "LDR-literal"; + Log::WarnFmt("BuildTarget bypass: xref via LDR literal at %p (lit=%p ptr=%p)", + (void*)pc, (void*)lit, (void*)ptr); + break; + } + } + } + + if (!is_adrp(i0)) continue; + const uint32_t baseReg = i0 & 0x1F; + const uintptr_t page = decode_adrp_target(i0, pc); + + // Unity often does ADRP, then a few instructions, then ADD/LDR to materialize the pointer. + constexpr int kLookaheadInsns = 8; + for (int k = 1; k <= kLookaheadInsns && pc + 4u * k < end; k++) { + const uint32_t insn = *reinterpret_cast(pc + 4u * k); + + uint32_t rd1 = 0, rn1 = 0, imm12 = 0, shift = 0; + if (decode_add_imm64(insn, rd1, rn1, imm12, shift)) { + if (rn1 == baseReg && (shift == 0 || shift == 1)) { + const uintptr_t full = page + (shift == 1 ? (static_cast(imm12) << 12) : static_cast(imm12)); + if (full == strAddr || (hitAddr && full == hitAddr)) { + codeRefPc = pc; + xrefKind = "ADRP..ADD"; + Log::WarnFmt("BuildTarget bypass: xref via ADRP..ADD at %p (+%d)", (void*)pc, k); + break; + } + } + } + + uint32_t rt = 0, rn = 0, ldrImm12 = 0; + if (decode_ldr_x_uimm(insn, rt, rn, ldrImm12)) { + if (rn == baseReg) { + const uintptr_t slot = page + (static_cast(ldrImm12) << 3); + if (!is_readable_range(slot, sizeof(uintptr_t))) continue; + const uintptr_t ptr = *reinterpret_cast(slot); + if (ptr == strAddr || (hitAddr && ptr == hitAddr)) { + codeRefPc = pc; + xrefKind = "ADRP..LDR"; + Log::WarnFmt("BuildTarget bypass: xref via ADRP..LDR at %p (+%d) (slot=%p ptr=%p rt=%u)", + (void*)pc, k, (void*)slot, (void*)ptr, rt); + break; + } + } + } + } + if (codeRefPc) break; + } + if (codeRefPc) break; + } + + if (!codeRefPc) { + Log::WarnFmt("BuildTarget bypass: no code xref found (cstr=%p hit=%p).", (void*)strAddr, (void*)hitAddr); + return; + } + Log::WarnFmt("BuildTarget bypass: picked xref=%p kind=%s", (void*)codeRefPc, xrefKind ? xrefKind : "?"); + + // Find a nearby conditional branch controlling the error block containing codeRefPc. + // Two possible layouts: + // - branch enters error: target near codeRefPc -> NOP branch + // - branch skips error: target just after codeRefPc -> force always-taken by rewriting to unconditional B + constexpr intptr_t kBackScan = 0x300; + constexpr intptr_t kEnterNearLo = -0x80; + constexpr intptr_t kEnterNearHi = 0x120; + constexpr intptr_t kSkipMin = 0x10; + constexpr intptr_t kSkipMax = 0x240; + + const uintptr_t scanStart = codeRefPc > static_cast(kBackScan) ? (codeRefPc - kBackScan) : codeRefPc; + uintptr_t bestPc = 0; + uint32_t bestOrig = 0; + uint32_t bestPatched = 0; + const char* bestMode = nullptr; + for (uintptr_t pc = scanStart; pc < codeRefPc; pc += 4) { + const uint32_t insn = *reinterpret_cast(pc); + uintptr_t target = 0; + if (is_cond_branch_insn(insn)) { + target = decode_bcond_target(insn, pc); + } else if (is_cbz_cbnz(insn)) { + target = decode_cb_target(insn, pc); + } else if (is_tbz_tbnz(insn)) { + target = decode_tb_target(insn, pc); + } else { + continue; + } + + // Enter-error branch (target into error block) + const intptr_t enterDelta = static_cast(target) - static_cast(codeRefPc); + if (enterDelta >= kEnterNearLo && enterDelta <= kEnterNearHi) { + if (pc >= bestPc) { + bestPc = pc; + bestOrig = insn; + bestPatched = 0xD503201F; // NOP + bestMode = "NopBranch"; + } + continue; + } + + // Skip-error branch (target after error block) + const intptr_t skipDelta = static_cast(target) - static_cast(codeRefPc); + if (skipDelta >= kSkipMin && skipDelta <= kSkipMax) { + uint32_t bInsn = 0; + if (encode_uncond_b(pc, target, bInsn)) { + if (pc >= bestPc) { + bestPc = pc; + bestOrig = insn; + bestPatched = bInsn; + bestMode = "ForceBranch"; + } + } + } + } + + if (!bestPc) { + Log::WarnFmt("BuildTarget bypass: no nearby conditional branch found for error block near %p.", (void*)codeRefPc); + return; + } + + g_buildTargetBypassBranchSites.push_back({bestPc, bestOrig, bestPatched}); + Log::WarnFmt("BuildTarget bypass: will patch 1 branch at %p (mode=%s, xref=%p, needle=%p).", + (void*)bestPc, bestMode ? bestMode : "?", (void*)codeRefPc, (void*)strAddr); + } + + static void apply_build_target_patch_locked(uint16_t desiredImm12) { + for (auto& site : g_buildTargetPatchSites) { + // Rewrite only imm12 bits (21:10) based on the original instruction word. + const uint32_t patched = (site.origInsn & ~(0xFFFu << 10)) | (static_cast(desiredImm12) << 10); + (void)write_code_u32(site.addr, patched); + } + } + + static void apply_build_target_bypass_locked(bool enable) { + for (auto& s : g_buildTargetBypassBranchSites) { + (void)write_code_u32(s.addr, enable ? s.patchedInsn : s.origInsn); + } + } + + static void ensure_build_target_patch_sites_locked() { + if (g_unityLoadFromFileInternalAddr == 0) return; + if (!g_buildTargetPatchSites.empty()) return; + + // Scan a small window in the native function for "cmp #0x0D; b.cond" patterns. + // We patch those compares to iOS (0x09) while inside iOS AssetBundle loads. + constexpr size_t kMaxScanBytes = 0x3000; + constexpr size_t kMinOffset = 0x40; // skip prologue/trampoline region + + const auto base = g_unityLoadFromFileInternalAddr; + size_t scanBytes = kMaxScanBytes; + if (const uintptr_t mappingEnd = find_mapping_end(base)) { + const uintptr_t avail = mappingEnd - base; + if (avail < scanBytes) scanBytes = static_cast(avail); + } + if (scanBytes <= kMinOffset + 8) { + Log::WarnFmt("BuildTarget patch: mapping too small to scan (%p).", (void*)base); + return; + } + + size_t found = 0; + for (size_t off = kMinOffset; off + 8 <= scanBytes; off += 4) { + const uint32_t insn = *reinterpret_cast(base + off); + const uint32_t next = *reinterpret_cast(base + off + 4); + if (!is_cond_branch_insn(next)) continue; + + if (is_cmp_imm_32(insn, kBuildTargetAndroid) || is_cmp_imm_64(insn, kBuildTargetAndroid)) { + g_buildTargetPatchSites.push_back({base + off, insn}); + found++; + // Avoid patching too many sites; if there are lots, our heuristic is probably wrong. + if (found >= 8) break; + } + } + + if (g_buildTargetPatchSites.empty()) { + Log::WarnFmt("BuildTarget patch: no cmp-immediate sites found near LoadFromFile_Internal (%p).", (void*)base); + } else { + Log::InfoFmt("BuildTarget patch: found %zu candidate cmp sites near LoadFromFile_Internal (%p).", g_buildTargetPatchSites.size(), (void*)base); + } + } + + struct ScopedBuildTargetPatch { + bool active = false; + ScopedBuildTargetPatch() = default; + explicit ScopedBuildTargetPatch(bool enable) { + if (!enable) return; + std::lock_guard lock(g_buildTargetPatchMutex); + ensure_build_target_patch_sites_locked(); + if (g_buildTargetPatchSites.empty()) return; + + const int prev = g_buildTargetPatchUsers.fetch_add(1, std::memory_order_acq_rel); + if (prev == 0) { + apply_build_target_patch_locked(kBuildTargetIos); + } + active = true; + } + ~ScopedBuildTargetPatch() { + if (!active) return; + std::lock_guard lock(g_buildTargetPatchMutex); + const int now = g_buildTargetPatchUsers.fetch_sub(1, std::memory_order_acq_rel) - 1; + if (now == 0 && !g_buildTargetPatchSites.empty()) { + apply_build_target_patch_locked(kBuildTargetAndroid); + } + } + }; + + struct ScopedBuildTargetBypass { + bool active = false; + ScopedBuildTargetBypass() = default; + explicit ScopedBuildTargetBypass(bool enable) { + if (!enable) return; + std::lock_guard lock(g_buildTargetPatchMutex); + ensure_build_target_bypass_sites_locked(); + if (g_buildTargetBypassBranchSites.empty()) return; + + const int prev = g_buildTargetBypassUsers.fetch_add(1, std::memory_order_acq_rel); + if (prev == 0) { + apply_build_target_bypass_locked(true); + } + active = true; + } + ~ScopedBuildTargetBypass() { + if (!active) return; + std::lock_guard lock(g_buildTargetPatchMutex); + const int now = g_buildTargetBypassUsers.fetch_sub(1, std::memory_order_acq_rel) - 1; + if (now == 0 && !g_buildTargetBypassBranchSites.empty()) { + apply_build_target_bypass_locked(false); + } + } + }; + } // namespace + + static std::string replaceAndroidDirWithIos(std::string s) { + // Only swap the platform path component. This is intentionally narrow to avoid accidental replacements. + constexpr std::string_view fromMid = "/android"; + constexpr std::string_view toMid = "/ios"; + + size_t pos = 0; + while ((pos = s.find(fromMid, pos)) != std::string::npos) { + s.replace(pos, fromMid.size(), toMid); + pos += toMid.size(); + } + // Handle trailing "/android" (no trailing slash) + if (s.size() >= 8 && s.rfind("/android") == s.size() - 8) { + s.replace(s.size() - 8, 8, "/ios"); + } + return s; + } + + DEFINE_HOOK(void, Internal_LogException, (void* ex, void* obj)) { + Internal_LogException_Orig(ex, obj); + static auto Exception_ToString = Il2cppUtils::GetMethod("mscorlib.dll", "System", "Exception", "ToString"); + Log::LogUnityLog(ANDROID_LOG_ERROR, "UnityLog - Internal_LogException:\n%s", Exception_ToString->Invoke(ex)->ToString().c_str()); + } DEFINE_HOOK(void, Internal_Log, (int logType, int logOption, UnityResolve::UnityType::String* content, void* context)) { Internal_Log_Orig(logType, logOption, content, context); @@ -49,12 +747,153 @@ namespace LinkuraLocal::HookDebug { DEFINE_HOOK(Il2cppString*, Hailstorm_AssetDownloadJob_get_UrlBase, (Il2cppUtils::Il2CppObject* self, void* method)) { auto base = Hailstorm_AssetDownloadJob_get_UrlBase_Orig(self, method); - if (!Config::assetsUrlPrefix.empty()) { - base = Il2cppString::New(HookShare::replaceUriHost(base->ToString(), Config::assetsUrlPrefix)); + if (base) { + auto s = base->ToString(); + if (!Config::assetsUrlPrefix.empty()) { + s = HookShare::replaceUriHost(s, Config::assetsUrlPrefix); + } + if (Config::loginAsIOS) { + // Force platform dir to ios so we request "/ios/..." instead of "/android/...". + s = replaceAndroidDirWithIos(std::move(s)); + } + base = Il2cppString::New(s); + } + return base; + } + + DEFINE_HOOK(Il2cppString*, Hailstorm_AssetDownloadJob_get_LocalBase, (Il2cppUtils::Il2CppObject* self, void* method)) { + auto base = Hailstorm_AssetDownloadJob_get_LocalBase_Orig(self, method); + if (base && Config::loginAsIOS) { + // Force local cache dir to ios so files land under ".../ios/..." + base = Il2cppString::New(replaceAndroidDirWithIos(base->ToString())); } return base; } + DEFINE_HOOK(int, UnityEngine_Application_get_platform, ()) { + return UnityEngine_Application_get_platform_Orig(); + } + + DEFINE_HOOK(void*, UnityEngine_AssetBundle_LoadFromFile_Internal, (Il2cppString* path, uint32_t crc, uint64_t offset)) { + if (Config::loginAsIOS) { + static bool s_loggedOnce = false; + static std::mutex s_pathLogMutex; + static std::set s_loggedPaths; + static std::mutex s_patchLogMutex; + static std::set s_patchLoggedPaths; + if (!s_loggedOnce) { + s_loggedOnce = true; + Log::WarnFmt("BuildTarget bypass: hook entered (icall=%p).", (void*)g_unityLoadFromFileInternalAddr); + } + if (path) { + const auto p0 = path->ToString(); + if (!p0.empty()) { + bool doLog = false; + { + std::lock_guard lock(s_pathLogMutex); + if (s_loggedPaths.insert(p0).second) doLog = true; + } + if (doLog) { + Log::WarnFmt("AssetBundle.LoadFromFile_Internal: path=%s crc=%u offset=%llu", + p0.c_str(), crc, static_cast(offset)); + } + } + } + + tls_insideAssetBundleLoadFromFile = true; + // The primary strategy is now to rewrite the on-disk source bundle target to Android(13) + // before Unity reads it. Keep the native bypass disabled unless we prove it is needed again. + const ScopedBuildTargetBypass bypassGuard(false); + const ScopedBuildTargetPatch patchGuard(false); + + if (path) { + const auto originalPath = path->ToString(); + if (!originalPath.empty()) { + const auto patchResult = UnityAssetHelper::PatchFileTargetBuildIdInPlace(originalPath, offset, kBuildTargetAndroid); + bool shouldLogPatchResult = false; + { + std::lock_guard lock(s_patchLogMutex); + shouldLogPatchResult = s_patchLoggedPaths.insert(originalPath).second; + } + if (patchResult.patched) { + crc = 0; + if (shouldLogPatchResult) { + const auto directFileOffsetStr = patchResult.firstDirectFileOffsetValid + ? Log::StringFormat("0x%llX", static_cast(patchResult.firstDirectFileOffset)) + : std::string("n/a"); + Log::WarnFmt("AssetBundle target patch: path=%s target=0x%02X patched=%zu observed=0x%02X requestOffset=%llu actualOffset=%llu nodeOffset=0x%llX nodeSize=0x%llX targetOffsetInNode=0x%llX targetRawOffset=0x%llX blockIndex=%lld blockCompression=%u directFileOffset=%s", + originalPath.c_str(), + kBuildTargetAndroid, + patchResult.patchedSerializedFileCount, + patchResult.firstObservedBuildTarget, + static_cast(offset), + static_cast(patchResult.actualOffset), + static_cast(patchResult.firstNodeOffset), + static_cast(patchResult.firstNodeSize), + static_cast(patchResult.firstTargetOffsetInNode), + static_cast(patchResult.firstTargetAbsoluteRawOffset), + static_cast(patchResult.firstBlockIndex), + patchResult.firstBlockCompression, + directFileOffsetStr.c_str()); + Log::WarnFmt("AssetBundle target patch: source file modified in place: %s", originalPath.c_str()); + } + } else if (patchResult.targetAlreadyMatched) { + if (shouldLogPatchResult) { + const auto directFileOffsetStr = patchResult.firstDirectFileOffsetValid + ? Log::StringFormat("0x%llX", static_cast(patchResult.firstDirectFileOffset)) + : std::string("n/a"); + Log::WarnFmt("AssetBundle target patch: path=%s already target=0x%02X requestOffset=%llu actualOffset=%llu nodeOffset=0x%llX nodeSize=0x%llX targetOffsetInNode=0x%llX targetRawOffset=0x%llX blockIndex=%lld blockCompression=%u directFileOffset=%s", + originalPath.c_str(), + kBuildTargetAndroid, + static_cast(offset), + static_cast(patchResult.actualOffset), + static_cast(patchResult.firstNodeOffset), + static_cast(patchResult.firstNodeSize), + static_cast(patchResult.firstTargetOffsetInNode), + static_cast(patchResult.firstTargetAbsoluteRawOffset), + static_cast(patchResult.firstBlockIndex), + patchResult.firstBlockCompression, + directFileOffsetStr.c_str()); + } + } else if (!patchResult.error.empty() && shouldLogPatchResult) { + const auto directFileOffsetStr = patchResult.firstDirectFileOffsetValid + ? Log::StringFormat("0x%llX", static_cast(patchResult.firstDirectFileOffset)) + : std::string("n/a"); + Log::WarnFmt("AssetBundle target patch skipped: path=%s requestOffset=%llu actualOffset=%llu found=%s observed=0x%02X targetOffsetInNode=0x%llX targetRawOffset=0x%llX blockIndex=%lld blockCompression=%u directFileOffset=%s error=%s", + originalPath.c_str(), + static_cast(offset), + static_cast(patchResult.actualOffset), + patchResult.foundSerializedFile ? "true" : "false", + patchResult.firstObservedBuildTarget, + static_cast(patchResult.firstTargetOffsetInNode), + static_cast(patchResult.firstTargetAbsoluteRawOffset), + static_cast(patchResult.firstBlockIndex), + patchResult.firstBlockCompression, + directFileOffsetStr.c_str(), + patchResult.error.c_str()); + } + } + } + + auto ret = UnityEngine_AssetBundle_LoadFromFile_Internal_Orig(path, crc, offset); + tls_insideAssetBundleLoadFromFile = false; + if (path) { + const auto finalPath = path->ToString(); + if (!ret) { + Log::WarnFmt("AssetBundle.LoadFromFile_Internal returned NULL (path=%s crc=%u offset=%llu).", + finalPath.c_str(), crc, static_cast(offset)); + } else { + Log::WarnFmt("AssetBundle.LoadFromFile_Internal succeeded (path=%s ret=%p crc=%u offset=%llu).", + finalPath.c_str(), ret, crc, static_cast(offset)); + } + } + return ret; + } + + tls_insideAssetBundleLoadFromFile = false; + return UnityEngine_AssetBundle_LoadFromFile_Internal_Orig(path, crc, offset); + } + DEFINE_HOOK(void, FootShadowManipulator_OnInstantiate, (Il2cppUtils::Il2CppObject* self, void* method)) { Log::DebugFmt("FootShadowManipulator_OnInstantiate HOOKED"); if (Config::hideCharacterShadow) return; @@ -793,6 +1632,24 @@ namespace LinkuraLocal::HookDebug { ADD_HOOK(MRS_AppsCoverScreen_SetActiveCoverImage, Il2cppUtils::GetMethodPointer("Assembly-CSharp.dll", "Inspix.LiveMain", "AppsCoverScreen", "SetActiveCoverImage")); ADD_HOOK(CharacterVisibleReceiver_UpdateAvatarVisibility, Il2cppUtils::GetMethodPointer("Assembly-CSharp.dll", "Inspix.Character", "CharacterVisibleReceiver", "UpdateAvatarVisibility")); ADD_HOOK(Hailstorm_AssetDownloadJob_get_UrlBase, Il2cppUtils::GetMethodPointer("Core.dll", "Hailstorm", "AssetDownloadJob", "get_UrlBase")); + ADD_HOOK(Hailstorm_AssetDownloadJob_get_LocalBase, Il2cppUtils::GetMethodPointer("Core.dll", "Hailstorm", "AssetDownloadJob", "get_LocalBase")); + + // Experimental: try to bypass iOS AssetBundle build-target validation on Android. + ADD_HOOK(UnityEngine_Application_get_platform, Il2cppUtils::il2cpp_resolve_icall("UnityEngine.Application::get_platform()")); + // Hook the real native implementation in libunity via icall resolution (not the il2cpp stub). + g_unityLoadFromFileInternalAddr = reinterpret_cast(Il2cppUtils::il2cpp_resolve_icall( + "UnityEngine.AssetBundle::LoadFromFile_Internal(System.String,System.UInt32,System.UInt64)")); + if (g_unityLoadFromFileInternalAddr != 0) { + // Pre-scan patch sites before we install the hook (hooking may overwrite the prologue). + { + std::lock_guard lock(g_buildTargetPatchMutex); + ensure_build_target_patch_sites_locked(); + ensure_build_target_bypass_sites_locked(); + } + ADD_HOOK(UnityEngine_AssetBundle_LoadFromFile_Internal, reinterpret_cast(g_unityLoadFromFileInternalAddr)); + } else { + Log::WarnFmt("Resolve icall failed: UnityEngine.AssetBundle::LoadFromFile_Internal(...) is NULL."); + } ADD_HOOK(FootShadowManipulator_OnInstantiate, Il2cppUtils::GetMethodPointer("Core.dll", "Inspix.Character.FootShadow", "FootShadowManipulator", "OnInstantiate")); ADD_HOOK(ItemManipulator_OnInstantiate, Il2cppUtils::GetMethodPointer("Core.dll", "Inspix.Character.Item", "ItemManipulator", "OnInstantiate")); @@ -833,4 +1690,4 @@ namespace LinkuraLocal::HookDebug { #pragma endregion } -} \ No newline at end of file +} diff --git a/app/src/main/cpp/LinkuraLocalify/hooks/HookShare.cpp b/app/src/main/cpp/LinkuraLocalify/hooks/HookShare.cpp index b9b64b0..10d0848 100644 --- a/app/src/main/cpp/LinkuraLocalify/hooks/HookShare.cpp +++ b/app/src/main/cpp/LinkuraLocalify/hooks/HookShare.cpp @@ -697,7 +697,29 @@ namespace LinkuraLocal::HookShare { #pragma endregion #pragma region oldVersion + static Il2cppUtils::Il2CppString* OverrideInspixUserAgentPlatform(Il2cppUtils::Il2CppString* value, bool toIos) { + if (!value) return value; + auto s = value->ToString(); + constexpr std::string_view kAndroid = "inspix-android/"; + constexpr std::string_view kIos = "inspix-ios/"; + const auto target = toIos ? kIos : kAndroid; + if (s.rfind(kAndroid, 0) == 0) s.replace(0, kAndroid.size(), target); + else if (s.rfind(kIos, 0) == 0) s.replace(0, kIos.size(), target); + else return value; + return Il2cppUtils::Il2CppString::New(s); + } + DEFINE_HOOK(void, Configuration_AddDefaultHeader, (void* self, Il2cppUtils::Il2CppString* key, Il2cppUtils::Il2CppString* value, void* mtd)) { + // Toggle platform headers for Login-as-iOS feature. + if (key) { + const auto key_str = key->ToString(); + if (key_str == "x-device-type" || key_str == "X-Device-Type") { + value = Il2cppUtils::Il2CppString::New(Config::loginAsIOS ? "ios" : "android"); + } else if (key_str == "User-Agent" || key_str == "user-agent") { + value = OverrideInspixUserAgentPlatform(value, Config::loginAsIOS); + } + } + if (Config::enableLegacyCompatibility) { Log::DebugFmt("Configuration_AddDefaultHeader HOOKED, %s=%s", key->ToString().c_str(), value->ToString().c_str()); auto key_str = key->ToString(); @@ -716,10 +738,13 @@ namespace LinkuraLocal::HookShare { if (Config::enableLegacyCompatibility) { Log::DebugFmt("Configuration_set_UserAgent HOOKED, %s", value->ToString().c_str()); auto value_str = value->ToString(); - if (value_str.starts_with("inspix-android")) { - value = Il2cppUtils::Il2CppString::New("inspix-android/" + Config::latestClientVersion.toString()); + if (value_str.starts_with("inspix-android") || value_str.starts_with("inspix-ios")) { + // Keep legacy behavior (override version), but switch UA prefix by current platform toggle. + value = Il2cppUtils::Il2CppString::New(std::string(Config::loginAsIOS ? "inspix-ios/" : "inspix-android/") + Config::latestClientVersion.toString()); } } + // Non-legacy path: keep version but swap prefix. + value = OverrideInspixUserAgentPlatform(value, Config::loginAsIOS); Configuration_set_UserAgent_Orig(self, value ,mtd); } @@ -762,6 +787,56 @@ namespace LinkuraLocal::HookShare { } return Core_SynchronizeResourceVersion_Orig(self, requestedVersion, mtd); } + + static void* Resolve_Hailstorm_HashLib_Crc32_Calc_Ptr() { + struct Candidate { + const char* namespaze; + const char* klass; + }; + + // IL2CPP nested type naming is commonly "Outer/Inner" (sometimes "Outer+Inner"). + // Try several variants because UnityResolve::Assembly::Get doesn't always index stripped/nested types. + constexpr Candidate candidates[] = { + {"Hailstorm.HashLib", "Crc32"}, + {"Hailstorm", "HashLib/Crc32"}, + {"Hailstorm", "HashLib+Crc32"}, + {"Hailstorm", "HashLib.Crc32"}, + }; + + for (const auto& c : candidates) { + void* klass = Il2cppUtils::GetClassIl2cpp("Core.dll", c.namespaze, c.klass); + if (!klass) continue; + auto mi = Il2cppUtils::il2cpp_class_get_method_from_name(klass, "Calc", 1); + if (!mi || !mi->methodPointer) continue; + Log::InfoFmt("Resolved Hailstorm.HashLib.Crc32.Calc at %p via %s::%s", mi->methodPointer, c.namespaze, c.klass); + return reinterpret_cast(mi->methodPointer); + } + + Log::Error("Failed to resolve Hailstorm.HashLib.Crc32.Calc (tried multiple nested-type name variants)."); + return nullptr; + } + + // Hailstorm.HashLib.Crc32.Calc(ReadOnlySpan source) -> arm64: (char16_t* ptr, int len, MethodInfo*) + // We hook here to swap the platform string used in Catalog key derivation: "android" -> "ios" (test only). + DEFINE_HOOK(uint32_t, Hailstorm_HashLibCrc32Calc, (const char16_t* source, int32_t length, void* mtd)) { + if (!Config::loginAsIOS) { + return Hailstorm_HashLibCrc32Calc_Orig(source, length, mtd); + } + if (source && length > 0) { + const std::u16string_view sv(source, static_cast(length)); + if (sv == u"android") { + static bool logged = false; + if (!logged) { + logged = true; + Log::InfoFmt("Crc32.Calc: swapping platform string \"%s\" -> \"ios\"", Misc::ToUTF8(sv).c_str()); + } + + auto iosStr = Il2cppUtils::Il2CppString::New("ios"); + return Hailstorm_HashLibCrc32Calc_Orig(iosStr->chars, iosStr->length, mtd); + } + } + return Hailstorm_HashLibCrc32Calc_Orig(source, length, mtd); + } DEFINE_HOOK(Il2cppUtils::Il2CppString*, Application_get_version, ()) { Il2cppUtils::Il2CppString* result = Application_get_version_Orig(); if (Config::enableLegacyCompatibility) { @@ -910,6 +985,7 @@ namespace LinkuraLocal::HookShare { ADD_HOOK(Configuration_set_UserAgent, Il2cppUtils::GetMethodPointer("Assembly-CSharp.dll", "Org.OpenAPITools.Client", "Configuration", "set_UserAgent")); // ADD_HOOK(AssetManager_SynchronizeResourceVersion, Il2cppUtils::GetMethodPointer("Core.dll", "Hailstorm", "AssetManager", "SynchronizeResourceVersion")); ADD_HOOK(Core_SynchronizeResourceVersion, Il2cppUtils::GetMethodPointer("Core.dll", "", "Core", "SynchronizeResourceVersion")); + ADD_HOOK(Hailstorm_HashLibCrc32Calc, Resolve_Hailstorm_HashLib_Crc32_Calc_Ptr()); ADD_HOOK(Application_get_version, Il2cppUtils::il2cpp_resolve_icall("UnityEngine.Application::get_version")); ADD_HOOK(ArchiveApi_ArchiveGetWithTimelineDataWithHttpInfoAsync, Il2cppUtils::GetMethodPointer("Assembly-CSharp.dll", "Org.OpenAPITools.Api", "ArchiveApi", "ArchiveGetWithTimelineDataWithHttpInfoAsync")); ADD_HOOK(ArchiveApi_ArchiveGetFesTimelineDataWithHttpInfoAsync, Il2cppUtils::GetMethodPointer("Assembly-CSharp.dll", "Org.OpenAPITools.Api", "ArchiveApi", "ArchiveGetFesTimelineDataWithHttpInfoAsync")); diff --git a/app/src/main/java/io/github/chocolzs/linkura/localify/ConfigUpdateListener.kt b/app/src/main/java/io/github/chocolzs/linkura/localify/ConfigUpdateListener.kt index a05eef9..465d61a 100644 --- a/app/src/main/java/io/github/chocolzs/linkura/localify/ConfigUpdateListener.kt +++ b/app/src/main/java/io/github/chocolzs/linkura/localify/ConfigUpdateListener.kt @@ -24,6 +24,7 @@ interface ConfigListener { fun onTextTestChanged(value: Boolean) fun onReplaceFontChanged(value: Boolean) fun onLazyInitChanged(value: Boolean) + fun onLoginAsIOSChanged(value: Boolean) fun onEnableFreeCameraChanged(value: Boolean) fun onMemorizeFreeCameraPosChanged(value: Boolean) fun onTargetFpsChanged(s: CharSequence, start: Int, before: Int, count: Int) @@ -175,6 +176,12 @@ interface ConfigUpdateListener: ConfigListener, IHasConfigItems { sendConfigUpdate(config) } + override fun onLoginAsIOSChanged(value: Boolean) { + config.loginAsIOS = value + saveConfig() + sendConfigUpdate(config) + } + override fun onTextTestChanged(value: Boolean) { config.textTest = value saveConfig() diff --git a/app/src/main/java/io/github/chocolzs/linkura/localify/MainActivity.kt b/app/src/main/java/io/github/chocolzs/linkura/localify/MainActivity.kt index a45a4d1..8bdf736 100644 --- a/app/src/main/java/io/github/chocolzs/linkura/localify/MainActivity.kt +++ b/app/src/main/java/io/github/chocolzs/linkura/localify/MainActivity.kt @@ -141,7 +141,11 @@ class MainActivity : ComponentActivity(), ConfigUpdateListener, IConfigurableAct if (isFirstLaunch || (currentTime - lastRefreshTime) > oneHourInMs) { LogExporter.addLogEntry("MainActivity", "I", "Performing automatic archive refresh on first launch") val defaultMetadataUrl = getString(R.string.replay_default_metadata_url) + val defaultVersionInfoUrl = getString(R.string.replay_default_version_info_url) val savedMetadataUrl = prefs.getString("metadata_url", defaultMetadataUrl) ?: defaultMetadataUrl + val savedVersionInfoUrl = prefs.getString("client_res_url", null) + ?: AssetsRepository.deriveClientResUrl(savedMetadataUrl) + ?: defaultVersionInfoUrl CoroutineScope(Dispatchers.IO).launch { try { @@ -158,7 +162,7 @@ class MainActivity : ComponentActivity(), ConfigUpdateListener, IConfigurableAct try { LogExporter.addLogEntry("MainActivity", "I", "Fetching client resources") - val clientResResult = AssetsRepository.fetchClientRes(savedMetadataUrl) + val clientResResult = AssetsRepository.fetchClientRes(savedVersionInfoUrl) clientResResult.onSuccess { clientRes -> AssetsRepository.saveClientRes(this@MainActivity, clientRes) diff --git a/app/src/main/java/io/github/chocolzs/linkura/localify/ipc/ConfigUpdateManager.kt b/app/src/main/java/io/github/chocolzs/linkura/localify/ipc/ConfigUpdateManager.kt index fae5329..05ba1dd 100644 --- a/app/src/main/java/io/github/chocolzs/linkura/localify/ipc/ConfigUpdateManager.kt +++ b/app/src/main/java/io/github/chocolzs/linkura/localify/ipc/ConfigUpdateManager.kt @@ -37,6 +37,7 @@ class ConfigUpdateManager private constructor() { if (config.renderHighResolution != null) renderHighResolution = config.renderHighResolution if (config.fesArchiveUnlockTicket != null) fesArchiveUnlockTicket = config.fesArchiveUnlockTicket if (config.lazyInit != null) lazyInit = config.lazyInit + if (config.loginAsIOS != null) loginAsIos = config.loginAsIOS if (config.replaceFont != null) replaceFont = config.replaceFont if (config.textTest != null) textTest = config.textTest if (config.dumpText != null) dumpText = config.dumpText @@ -82,4 +83,4 @@ class ConfigUpdateManager private constructor() { false } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/chocolzs/linkura/localify/mainUtils/AssetsRepository.kt b/app/src/main/java/io/github/chocolzs/linkura/localify/mainUtils/AssetsRepository.kt index a55e34e..bb864c3 100644 --- a/app/src/main/java/io/github/chocolzs/linkura/localify/mainUtils/AssetsRepository.kt +++ b/app/src/main/java/io/github/chocolzs/linkura/localify/mainUtils/AssetsRepository.kt @@ -160,11 +160,19 @@ object AssetsRepository { return saveArchiveConfig(context, updatedConfig) } - suspend fun fetchClientRes(metadataUrl: String): Result>> = withContext(Dispatchers.IO) { - try { + fun deriveClientResUrl(metadataUrl: String): String? { + return try { val metaUrl = URL(metadataUrl) + "${metaUrl.protocol}://${metaUrl.host}/client-res" + } catch (e: Exception) { + Log.e(TAG, "Failed to derive client resource URL from metadata URL", e) + null + } + } - val url = URL("${metaUrl.protocol}://${metaUrl.host}/client-res") + suspend fun fetchClientRes(clientResUrl: String): Result>> = withContext(Dispatchers.IO) { + try { + val url = URL(clientResUrl) val connection = url.openConnection() as HttpURLConnection connection.requestMethod = "GET" connection.connectTimeout = 10000 @@ -216,4 +224,4 @@ object AssetsRepository { val clientRes = loadClientRes(context) return clientRes?.get(version) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/chocolzs/linkura/localify/models/LinkuraConfig.kt b/app/src/main/java/io/github/chocolzs/linkura/localify/models/LinkuraConfig.kt index 5bb8cce..f8d5b88 100644 --- a/app/src/main/java/io/github/chocolzs/linkura/localify/models/LinkuraConfig.kt +++ b/app/src/main/java/io/github/chocolzs/linkura/localify/models/LinkuraConfig.kt @@ -9,6 +9,7 @@ data class LinkuraConfig ( var renderHighResolution: Boolean = true, var fesArchiveUnlockTicket: Boolean = false, var lazyInit: Boolean = true, + var loginAsIOS: Boolean = false, var replaceFont: Boolean = true, var textTest: Boolean = false, var dumpText: Boolean = false, diff --git a/app/src/main/java/io/github/chocolzs/linkura/localify/ui/pages/subPages/HomePage.kt b/app/src/main/java/io/github/chocolzs/linkura/localify/ui/pages/subPages/HomePage.kt index 993cbd9..0c96d71 100644 --- a/app/src/main/java/io/github/chocolzs/linkura/localify/ui/pages/subPages/HomePage.kt +++ b/app/src/main/java/io/github/chocolzs/linkura/localify/ui/pages/subPages/HomePage.kt @@ -113,6 +113,10 @@ fun HomePage(modifier: Modifier = Modifier, v -> context?.onLazyInitChanged(v) } + GakuSwitch(modifier, stringResource(R.string.login_as_ios), checked = config.value.loginAsIOS) { + v -> context?.onLoginAsIOSChanged(v) + } + GakuSwitch(modifier, stringResource(R.string.app_update_check), checked = programConfig.value.checkAppUpdate) { v -> context?.onPCheckAppUpdateChanged(v) } diff --git a/app/src/main/java/io/github/chocolzs/linkura/localify/ui/pages/subPages/ResourceManagementPage.kt b/app/src/main/java/io/github/chocolzs/linkura/localify/ui/pages/subPages/ResourceManagementPage.kt index a78d6f6..e2ab10e 100644 --- a/app/src/main/java/io/github/chocolzs/linkura/localify/ui/pages/subPages/ResourceManagementPage.kt +++ b/app/src/main/java/io/github/chocolzs/linkura/localify/ui/pages/subPages/ResourceManagementPage.kt @@ -213,6 +213,7 @@ private fun ReplayTabPage( ) { val config = getConfigState(context, previewData) val defaultMetadataUrl = stringResource(R.string.replay_default_metadata_url) + val defaultVersionInfoUrl = stringResource(R.string.replay_default_version_info_url) // Load saved metadata URL or use default var localMetadataUrl by remember { @@ -221,6 +222,14 @@ private fun ReplayTabPage( ?.getString("metadata_url", defaultMetadataUrl) ?: defaultMetadataUrl ) } + var localVersionInfoUrl by remember { + mutableStateOf( + context?.getSharedPreferences("linkura_prefs", 0) + ?.getString("client_res_url", null) + ?: AssetsRepository.deriveClientResUrl(localMetadataUrl) + ?: defaultVersionInfoUrl + ) + } val replaySettingsViewModel: ReplaySettingsCollapsibleBoxViewModel = viewModel(factory = ReplaySettingsCollapsibleBoxViewModelFactory(initiallyExpanded = false)) @@ -265,9 +274,9 @@ private fun ReplayTabPage( // Fetch client resources function (silent) suspend fun fetchClientResources() { - if (localMetadataUrl.isBlank()) return + if (localVersionInfoUrl.isBlank()) return try { - val result = AssetsRepository.fetchClientRes(localMetadataUrl) + val result = AssetsRepository.fetchClientRes(localVersionInfoUrl) result.onSuccess { clientRes -> context?.let { ctx -> AssetsRepository.saveClientRes(ctx, clientRes) @@ -380,6 +389,55 @@ private fun ReplayTabPage( if (config.value.enableLegacyCompatibility) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Bottom + ) { + GakuTextInput( + value = localVersionInfoUrl, + onValueChange = { newUrl -> + localVersionInfoUrl = newUrl + context?.getSharedPreferences("linkura_prefs", 0) + ?.edit() + ?.putString("client_res_url", newUrl) + ?.apply() + }, + modifier = Modifier.weight(1f), + label = { + Text(text = stringResource(R.string.replay_settings_version_info_url)) + } + ) + + IconButton( + onClick = { + localVersionInfoUrl = defaultVersionInfoUrl + context?.getSharedPreferences("linkura_prefs", 0) + ?.edit() + ?.putString("client_res_url", defaultVersionInfoUrl) + ?.apply() + }, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(R.string.replay_settings_reset_url), + tint = MaterialTheme.colorScheme.primary + ) + } + } + + Text( + text = localVersionInfoUrl, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(start = 16.dp) + ) + } + Text(stringResource(R.string.config_legacy_resource_mode_title)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -541,7 +599,7 @@ private fun ReplayTabPage( }, modifier = Modifier.weight(1f), label = { - Text(text = stringResource(R.string.resource_url)) + Text(text = stringResource(R.string.replay_settings_resource_prefix_url)) } ) @@ -620,6 +678,7 @@ private fun ReplayTabPage( modifier = Modifier.padding(start = 16.dp) ) } + } } } @@ -1265,4 +1324,4 @@ private fun LocaleTabPage( Spacer(Modifier.height(bottomSpacerHeight)) } -} \ No newline at end of file +} diff --git a/app/src/main/proto/linkura_messages.proto b/app/src/main/proto/linkura_messages.proto index b991f7a..51e5078 100644 --- a/app/src/main/proto/linkura_messages.proto +++ b/app/src/main/proto/linkura_messages.proto @@ -70,6 +70,7 @@ message ConfigUpdate { optional bool render_high_resolution = 4; optional bool fes_archive_unlock_ticket = 5; optional bool lazy_init = 6; + optional bool login_as_ios = 48; optional bool replace_font = 7; optional bool text_test = 8; optional bool dump_text = 9; @@ -173,4 +174,4 @@ message VirtualJoystickInput { float right_trigger = 7; // Right trigger (0.0 to 1.0) float hat_x = 8; // D-pad X axis (-1.0 to 1.0) float hat_y = 9; // D-pad Y axis (-1.0 to 1.0) -} \ No newline at end of file +} diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2fcaf6b..734d93e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -76,6 +76,7 @@ Eliminar recurso del plugin Usar recurso ZIP remoto URL del recurso + URL de prefijo de repetición Descargar Archivo no válido Este archivo no es un paquete ZIP de recursos de traducción válido. @@ -184,6 +185,9 @@ Enlace de metadatos de la repetición Restablecer al valor predeterminado https://assets.chocoie.com/archive + URL de información de versión + Enlace de información de versión + https://assets.chocoie.com/client-res Mostrar solo repeticiones con captura de movimiento Mostrar solo repeticiones de captura de movimiento reproducibles Activar hora de inicio personalizada de la repetición diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d3a6fa8..bca470a 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -319,6 +319,7 @@ Xposed スコープは再パッチなしで動的に変更が可能です。 パッチ済みの APK を予約する リソース設定 リソース URL + リプレイ接頭辞URL ルートウエイト 検索 com.google.android.material.search.SearchBar$ScrollingViewBehavior @@ -451,6 +452,9 @@ Xposed スコープは再パッチなしで動的に変更が可能です。 再生メタデータリンク リセット https://assets.chocoie.com/archive + バージョン情報URL + バージョン情報リンク + https://assets.chocoie.com/client-res モーションキャプチャ再生をフィルタリング 再生可能なモーションキャプチャのみ カスタム開始時間を有効にする diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 255d80c..7581f52 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -76,6 +76,7 @@ 플러그인 리소스 삭제 원격 ZIP 리소스 사용 리소스 URL + 리플레이 접두사 URL 다운로드 잘못된 파일 이 파일은 올바른 ZIP 번역 리소스 팩이 아닙니다. @@ -184,6 +185,9 @@ 리플레이 메타데이터 링크 기본값으로 재설정 https://assets.chocoie.com/archive + 버전 정보 URL + 버전 정보 링크 + https://assets.chocoie.com/client-res 모션 캡처가 있는 리플레이만 표시 재생 가능한 모션 캡처 리플레이만 표시 사용자 지정 리플레이 시작 시간 활성화 diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index f49aa63..a63a2a4 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -76,6 +76,7 @@ ลบทรัพยากรของปลั๊กอิน ใช้ทรัพยากร ZIP จากระยะไกล URL ทรัพยากร + URL คำนำหน้าการเล่นซ้ำ ดาวน์โหลด ไฟล์ไม่ถูกต้อง ไฟล์นี้ไม่ใช่แพ็กทรัพยากรแปลภาษาแบบ ZIP ที่ถูกต้อง @@ -184,6 +185,9 @@ ลิงก์เมตาดาต้าของรีเพลย์ รีเซ็ตเป็นค่าเริ่มต้น https://assets.chocoie.com/archive + URL ข้อมูลเวอร์ชัน + ลิงก์ข้อมูลเวอร์ชัน + https://assets.chocoie.com/client-res แสดงเฉพาะรีเพลย์ที่มีโมชั่นแคปเจอร์ แสดงเฉพาะรีเพลย์โมชั่นแคปเจอร์ที่เล่นได้ เปิดใช้เวลาเริ่มรีเพลย์แบบกำหนดเอง diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index dcb92d4..f6ea4f7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -76,6 +76,7 @@ 清除游戏目录内的插件资源 使用远程 ZIP 数据 资源地址 + 回放前缀地址 下载 文件解析失败 此文件不是一个有效的 ZIP 翻译资源包 @@ -202,6 +203,9 @@ 回放元信息链接 复原 https://assets.chocoie.com/archive + 版本信息地址 + 版本信息地址 + https://assets.chocoie.com/client-res 仅显示含动捕的回放 仅显示可播放的动捕回放 自定义回放开始时间 @@ -299,4 +303,4 @@ 忽略 安装 - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a2296d4..6a49509 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -76,6 +76,7 @@ 刪除外掛資源 使用遠端 ZIP 資源 資源網址 + 回放前綴位址 下載 無效檔案 此檔案不是有效的 ZIP 翻譯資源包。 @@ -184,6 +185,9 @@ 回放中繼資料連結 重設為預設 https://assets.chocoie.com/archive + 版本資訊位址 + 版本資訊位址 + https://assets.chocoie.com/client-res 只顯示含動作捕捉的回放 只顯示可播放的動作捕捉回放 啟用自訂回放開始時間 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e209c9..dcf798e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -76,6 +76,7 @@ Delete Plugin Resource Use Remote ZIP Resource Resource URL + Replay Prefix URL Download Invalid file This file is not a valid ZIP translation resource pack. @@ -202,6 +203,9 @@ Replay metadata link Reset to Default https://assets.chocoie.com/archive + Version Info URL + Version info link + https://assets.chocoie.com/client-res Only Show Replays with Motion Capture Only Show Playable Motion Capture Replays Enable Custom Replay Start Time @@ -300,4 +304,4 @@ Ignore Install - \ No newline at end of file +