diff --git a/Makefile.common b/Makefile.common
index 411f959f392..bc42f8bd4d9 100644
--- a/Makefile.common
+++ b/Makefile.common
@@ -2404,6 +2404,12 @@ ifeq ($(HAVE_NETWORKING), 1)
deps/rcheevos/src/rapi/rc_api_runtime.o \
deps/rcheevos/src/rapi/rc_api_user.o \
+ # RVZ/WIA disc image support for RetroAchievements
+ ifeq ($(HAVE_ZSTD), 1)
+ DEFINES += -DHAVE_CHEEVOS_RVZ
+ OBJ += cheevos/cheevos_rvz.o
+ endif
+
ifeq ($(HAVE_LUA), 1)
DEFINES += -DHAVE_LUA \
-DLUA_32BITS
diff --git a/cheevos/cheevos.c b/cheevos/cheevos.c
index fdede5e6811..fd4aabbbac0 100644
--- a/cheevos/cheevos.c
+++ b/cheevos/cheevos.c
@@ -52,6 +52,10 @@
#include "streams/chd_stream.h"
#endif
+#ifdef HAVE_CHEEVOS_RVZ
+#include "cheevos_rvz.h"
+#endif
+
#include "cheevos.h"
#include "cheevos_client.h"
#include "cheevos_menu.h"
@@ -1636,8 +1640,33 @@ bool rcheevos_load(const void *data)
gfx_widget_set_cheevos_set_loading(true);
#endif
- rc_client_begin_identify_and_load_game(rcheevos_locals.client, RC_CONSOLE_UNKNOWN,
- info->path, (const uint8_t*)info->data, info->size, rcheevos_client_load_game_callback, NULL);
+ /* Detect RVZ files and determine console type (GameCube or Wii) */
+ {
+ uint32_t console_id = RC_CONSOLE_UNKNOWN;
+
+#ifdef HAVE_CHEEVOS_RVZ
+ if (string_is_equal_noncase(path_get_extension(info->path), "rvz"))
+ {
+ console_id = rcheevos_rvz_get_console_id(info->path);
+
+ /* Only register custom file reader for valid RVZ files */
+ if (console_id != RC_CONSOLE_UNKNOWN)
+ {
+ struct rc_hash_filereader filereader;
+
+ filereader.open = rcheevos_rvz_open;
+ filereader.seek = rcheevos_rvz_seek;
+ filereader.tell = rcheevos_rvz_tell;
+ filereader.read = rcheevos_rvz_read;
+ filereader.close = rcheevos_rvz_close;
+ rc_hash_init_custom_filereader(&filereader);
+ }
+ }
+#endif
+
+ rc_client_begin_identify_and_load_game(rcheevos_locals.client, console_id,
+ info->path, (const uint8_t*)info->data, info->size, rcheevos_client_load_game_callback, NULL);
+ }
return true;
}
diff --git a/cheevos/cheevos_rvz.c b/cheevos/cheevos_rvz.c
new file mode 100644
index 00000000000..cd2284bb0da
--- /dev/null
+++ b/cheevos/cheevos_rvz.c
@@ -0,0 +1,1222 @@
+/* RetroArch - A frontend for libretro.
+ * Copyright (C) 2025 - RetroArch Team
+ *
+ * RetroArch is free software: you can redistribute it and/or modify it under the terms
+ * of the GNU General Public License as published by the Free Software Found-
+ * ation, either version 3 of the License, or (at your option) any later version.
+ *
+ * RetroArch is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
+ * PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with RetroArch.
+ * If not, see .
+ */
+
+#include "cheevos_rvz.h"
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#ifdef HAVE_ZSTD
+#include
+#endif
+
+#include "../verbosity.h"
+#include "../deps/rcheevos/include/rc_consoles.h"
+
+/* RVZ/WIA format constants */
+#define RVZ_MAGIC 0x52565A01 /* "RVZ\x1" in big endian */
+#define WIA_MAGIC 0x57494101 /* "WIA\x1" in big endian */
+
+#define RVZ_HEADER1_SIZE 0x48
+#define RVZ_HEADER2_SIZE 0xDC
+
+/* Compression types */
+#define RVZ_COMPRESSION_NONE 0
+#define RVZ_COMPRESSION_PURGE 1
+#define RVZ_COMPRESSION_BZIP2 2
+#define RVZ_COMPRESSION_LZMA 3
+#define RVZ_COMPRESSION_LZMA2 4
+#define RVZ_COMPRESSION_ZSTD 5
+
+/* Cache configuration */
+#define RVZ_CHUNK_CACHE_SIZE 4 /* Cache up to 4 decompressed chunks */
+
+/* Header structures based on docs/WiaAndRvz.md from Dolphin */
+#pragma pack(push, 1)
+
+typedef struct
+{
+ uint32_t magic;
+ uint32_t version;
+ uint32_t version_compatible;
+ uint32_t header_2_size;
+ uint8_t header_2_hash[20]; /* SHA-1 */
+ uint64_t iso_file_size;
+ uint64_t wia_file_size;
+ uint8_t header_1_hash[20]; /* SHA-1 */
+} rvz_header_1_t;
+
+typedef struct
+{
+ uint32_t disc_type;
+ uint32_t compression_type;
+ int32_t compression_level;
+ uint32_t chunk_size;
+ uint8_t disc_header[0x80];
+ uint32_t num_partition_entries;
+ uint32_t partition_entry_size;
+ uint64_t partition_entries_offset;
+ uint8_t partition_entries_hash[20];
+ uint32_t num_raw_data_entries;
+ uint64_t raw_data_entries_offset;
+ uint32_t raw_data_entries_size;
+ uint32_t num_group_entries;
+ uint64_t group_entries_offset;
+ uint32_t group_entries_size;
+ uint8_t compressor_data_size;
+ uint8_t compressor_data[7];
+} rvz_header_2_t;
+
+typedef struct
+{
+ uint64_t data_offset;
+ uint64_t data_size;
+ uint32_t group_index;
+ uint32_t num_groups;
+} rvz_raw_data_entry_t;
+
+typedef struct
+{
+ uint32_t data_offset; /* Divided by 4 */
+ uint32_t data_size; /* MSB indicates compression */
+ uint32_t rvz_packed_size;
+} rvz_group_entry_t;
+
+#pragma pack(pop)
+
+/* Decompressed chunk cache entry */
+typedef struct
+{
+ uint32_t group_index;
+ uint64_t decompressed_size;
+ uint8_t* data;
+ bool valid;
+} rvz_chunk_cache_entry_t;
+
+/* Lagged Fibonacci Generator for junk data */
+#define LFG_K 521
+#define LFG_J 32
+#define LFG_SEED_SIZE 17
+
+typedef struct
+{
+ uint32_t buffer[LFG_K];
+ uint32_t position_bytes;
+} lfg_state_t;
+
+/* RVZPack decompressor state for two-stage decompression */
+typedef struct
+{
+ uint8_t* intermediate_data; /* Buffer holding Zstd-decompressed data */
+ uint32_t intermediate_size; /* Size of intermediate buffer */
+ uint32_t intermediate_pos; /* Current read position in intermediate buffer */
+ uint32_t rvz_packed_size; /* Expected final output size */
+ uint64_t data_offset; /* Current disc offset (for junk seeding) */
+ uint32_t current_block_size; /* Size of current block being processed */
+ uint32_t current_block_remaining; /* Bytes remaining in current block */
+ bool current_block_is_junk; /* Is current block junk data? */
+ lfg_state_t lfg; /* LFG state for junk generation */
+} rvz_pack_state_t;
+
+/* RVZ file handle */
+struct rcheevos_rvz_file
+{
+ RFILE* file;
+ int64_t position; /* Virtual position in decompressed disc */
+
+ rvz_header_1_t header_1;
+ rvz_header_2_t header_2;
+
+ /* Raw data entries (for non-partitioned data like GameCube) */
+ rvz_raw_data_entry_t* raw_data_entries;
+ uint32_t num_raw_data_entries;
+
+ /* Group entries (map chunks to compressed data) */
+ rvz_group_entry_t* group_entries;
+ uint32_t num_group_entries;
+
+ /* Chunk cache */
+ rvz_chunk_cache_entry_t cache[RVZ_CHUNK_CACHE_SIZE];
+ uint32_t cache_next; /* LRU replacement index */
+};
+
+typedef struct rcheevos_rvz_file rcheevos_rvz_file_t;
+
+/* Helper: Read big-endian values */
+static uint32_t rvz_read_be32(const uint8_t* data)
+{
+ return ((uint32_t)data[0] << 24) |
+ ((uint32_t)data[1] << 16) |
+ ((uint32_t)data[2] << 8) |
+ ((uint32_t)data[3]);
+}
+
+static uint64_t rvz_read_be64(const uint8_t* data)
+{
+ return ((uint64_t)data[0] << 56) |
+ ((uint64_t)data[1] << 48) |
+ ((uint64_t)data[2] << 40) |
+ ((uint64_t)data[3] << 32) |
+ ((uint64_t)data[4] << 24) |
+ ((uint64_t)data[5] << 16) |
+ ((uint64_t)data[6] << 8) |
+ ((uint64_t)data[7]);
+}
+
+/* Helper: Parse header 1 */
+static bool rvz_parse_header_1(RFILE* file, rvz_header_1_t* header)
+{
+ uint8_t buffer[RVZ_HEADER1_SIZE];
+
+ if (filestream_seek(file, 0, SEEK_SET) != 0)
+ return false;
+
+ if (filestream_read(file, buffer, RVZ_HEADER1_SIZE) != RVZ_HEADER1_SIZE)
+ return false;
+
+ /* Parse header fields (RVZ format uses big-endian) */
+ header->magic = rvz_read_be32(buffer);
+ header->version = rvz_read_be32(buffer + 0x04);
+ header->version_compatible = rvz_read_be32(buffer + 0x08);
+ header->header_2_size = rvz_read_be32(buffer + 0x0C);
+ memcpy(header->header_2_hash, buffer + 0x10, 20);
+ header->iso_file_size = rvz_read_be64(buffer + 0x24);
+ header->wia_file_size = rvz_read_be64(buffer + 0x2C);
+ memcpy(header->header_1_hash, buffer + 0x34, 20);
+
+ return true;
+}
+
+/* Helper: Parse header 2 */
+static bool rvz_parse_header_2(RFILE* file, rvz_header_2_t* header)
+{
+ uint8_t buffer[RVZ_HEADER2_SIZE];
+
+ if (filestream_seek(file, RVZ_HEADER1_SIZE, SEEK_SET) != 0)
+ return false;
+
+ if (filestream_read(file, buffer, RVZ_HEADER2_SIZE) != RVZ_HEADER2_SIZE)
+ return false;
+
+ header->disc_type = rvz_read_be32(buffer + 0x00);
+ header->compression_type = rvz_read_be32(buffer + 0x04);
+ header->compression_level = (int32_t)rvz_read_be32(buffer + 0x08);
+ header->chunk_size = rvz_read_be32(buffer + 0x0C);
+ memcpy(header->disc_header, buffer + 0x10, 0x80);
+ header->num_partition_entries = rvz_read_be32(buffer + 0x90);
+ header->partition_entry_size = rvz_read_be32(buffer + 0x94);
+ header->partition_entries_offset = rvz_read_be64(buffer + 0x98);
+ memcpy(header->partition_entries_hash, buffer + 0xA0, 20);
+ header->num_raw_data_entries = rvz_read_be32(buffer + 0xB4);
+ header->raw_data_entries_offset = rvz_read_be64(buffer + 0xB8);
+ header->raw_data_entries_size = rvz_read_be32(buffer + 0xC0);
+ header->num_group_entries = rvz_read_be32(buffer + 0xC4);
+ header->group_entries_offset = rvz_read_be64(buffer + 0xC8);
+ header->group_entries_size = rvz_read_be32(buffer + 0xD0);
+ header->compressor_data_size = buffer[0xD4];
+ memcpy(header->compressor_data, buffer + 0xD5, 7);
+
+ return true;
+}
+
+/* Helper: Decompress data (raw_data_entries, group_entries, etc are compressed) */
+static uint8_t* rvz_decompress_data(RFILE* file, uint64_t offset, uint32_t compressed_size,
+ uint32_t decompressed_size, uint32_t compression_type)
+{
+ uint8_t* compressed_data = NULL;
+ uint8_t* decompressed_data = NULL;
+
+ /* Read compressed data from file */
+ compressed_data = (uint8_t*)malloc(compressed_size);
+ if (!compressed_data)
+ return NULL;
+
+ if (filestream_seek(file, offset, SEEK_SET) != 0)
+ {
+ free(compressed_data);
+ return NULL;
+ }
+
+ if (filestream_read(file, compressed_data, compressed_size) != compressed_size)
+ {
+ free(compressed_data);
+ return NULL;
+ }
+
+ /* Decompress based on compression type */
+ if (compression_type == RVZ_COMPRESSION_NONE)
+ {
+ /* Data is not compressed, return as-is */
+ return compressed_data;
+ }
+
+#ifdef HAVE_ZSTD
+ if (compression_type == RVZ_COMPRESSION_ZSTD)
+ {
+ size_t result;
+ decompressed_data = (uint8_t*)malloc(decompressed_size);
+ if (!decompressed_data)
+ {
+ free(compressed_data);
+ return NULL;
+ }
+
+ result = ZSTD_decompress(decompressed_data, decompressed_size,
+ compressed_data, compressed_size);
+
+ free(compressed_data);
+
+ if (ZSTD_isError(result))
+ {
+ RARCH_ERR("[RVZ] Failed to decompress metadata: %s\n", ZSTD_getErrorName(result));
+ free(decompressed_data);
+ return NULL;
+ }
+
+ return decompressed_data;
+ }
+#endif
+
+ RARCH_ERR("[RVZ] Unsupported compression type for metadata: %u\n", compression_type);
+ free(compressed_data);
+ return NULL;
+}
+
+/* Helper: Parse raw data entries */
+static bool rvz_parse_raw_data_entries(RFILE* file, rcheevos_rvz_file_t* rvz)
+{
+ uint32_t i;
+ uint8_t* buffer;
+ uint32_t entry_size = sizeof(rvz_raw_data_entry_t);
+ uint32_t decompressed_size;
+
+ if (rvz->header_2.num_raw_data_entries == 0)
+ return true;
+
+ rvz->raw_data_entries = (rvz_raw_data_entry_t*)calloc(
+ rvz->header_2.num_raw_data_entries, sizeof(rvz_raw_data_entry_t));
+ if (!rvz->raw_data_entries)
+ return false;
+
+ /* Raw data entries are compressed - decompress them first */
+ decompressed_size = entry_size * rvz->header_2.num_raw_data_entries;
+ buffer = rvz_decompress_data(file,
+ rvz->header_2.raw_data_entries_offset,
+ rvz->header_2.raw_data_entries_size,
+ decompressed_size,
+ rvz->header_2.compression_type);
+ if (!buffer)
+ {
+ RARCH_ERR("[RVZ] Failed to decompress raw data entries\n");
+ return false;
+ }
+
+ for (i = 0; i < rvz->header_2.num_raw_data_entries; i++)
+ {
+ uint8_t* entry = buffer + (i * entry_size);
+ rvz->raw_data_entries[i].data_offset = rvz_read_be64(entry + 0x00);
+ rvz->raw_data_entries[i].data_size = rvz_read_be64(entry + 0x08);
+ rvz->raw_data_entries[i].group_index = rvz_read_be32(entry + 0x10);
+ rvz->raw_data_entries[i].num_groups = rvz_read_be32(entry + 0x14);
+
+ RARCH_LOG("[RVZ] Raw data entry %u: offset=%llu size=%llu group_index=%u num_groups=%u\n",
+ i, (unsigned long long)rvz->raw_data_entries[i].data_offset,
+ (unsigned long long)rvz->raw_data_entries[i].data_size,
+ rvz->raw_data_entries[i].group_index,
+ rvz->raw_data_entries[i].num_groups);
+ }
+
+ free(buffer);
+ rvz->num_raw_data_entries = rvz->header_2.num_raw_data_entries;
+ return true;
+}
+
+/* Helper: Parse group entries */
+static bool rvz_parse_group_entries(RFILE* file, rcheevos_rvz_file_t* rvz)
+{
+ uint32_t i;
+ uint8_t* buffer;
+ uint32_t entry_size = sizeof(rvz_group_entry_t);
+ uint32_t decompressed_size;
+
+ if (rvz->header_2.num_group_entries == 0)
+ return true;
+
+ rvz->group_entries = (rvz_group_entry_t*)calloc(
+ rvz->header_2.num_group_entries, sizeof(rvz_group_entry_t));
+ if (!rvz->group_entries)
+ return false;
+
+ /* Group entries are compressed - decompress them first */
+ decompressed_size = entry_size * rvz->header_2.num_group_entries;
+ buffer = rvz_decompress_data(file,
+ rvz->header_2.group_entries_offset,
+ rvz->header_2.group_entries_size,
+ decompressed_size,
+ rvz->header_2.compression_type);
+ if (!buffer)
+ {
+ RARCH_ERR("[RVZ] Failed to decompress group entries\n");
+ return false;
+ }
+
+ for (i = 0; i < rvz->header_2.num_group_entries; i++)
+ {
+ uint8_t* entry = buffer + (i * entry_size);
+ rvz->group_entries[i].data_offset = rvz_read_be32(entry + 0x00);
+ rvz->group_entries[i].data_size = rvz_read_be32(entry + 0x04);
+ rvz->group_entries[i].rvz_packed_size = rvz_read_be32(entry + 0x08);
+
+ if (i < 5)
+ {
+ RARCH_LOG("[RVZ] Group %u: file_offset=%llu data_size=0x%08X rvz_packed_size=%u\n",
+ i, (unsigned long long)(rvz->group_entries[i].data_offset * 4ULL),
+ rvz->group_entries[i].data_size,
+ rvz->group_entries[i].rvz_packed_size);
+ }
+ }
+
+ free(buffer);
+ rvz->num_group_entries = rvz->header_2.num_group_entries;
+ return true;
+}
+
+/* LFG implementation for junk data generation */
+static void lfg_forward_one(lfg_state_t* lfg)
+{
+ uint32_t i;
+ for (i = 0; i < LFG_J; i++)
+ lfg->buffer[i] ^= lfg->buffer[i + LFG_K - LFG_J];
+
+ for (i = LFG_J; i < LFG_K; i++)
+ lfg->buffer[i] ^= lfg->buffer[i - LFG_J];
+}
+
+static void lfg_initialize(lfg_state_t* lfg)
+{
+ uint32_t i;
+ uint32_t x;
+
+ /* Initialize buffer from seed */
+ for (i = LFG_SEED_SIZE; i < LFG_K; i++)
+ {
+ lfg->buffer[i] = (lfg->buffer[i - 17] << 23) ^
+ (lfg->buffer[i - 16] >> 9) ^
+ lfg->buffer[i - 1];
+ }
+
+ /* Byteswap and shift */
+ for (i = 0; i < LFG_K; i++)
+ {
+ x = lfg->buffer[i];
+ x = (x & 0xFF00FFFF) | ((x >> 2) & 0x00FF0000);
+ /* Byteswap: swap32 from big-endian (buffer) to little-endian for output */
+ lfg->buffer[i] = ((x & 0xFF000000) >> 24) |
+ ((x & 0x00FF0000) >> 8) |
+ ((x & 0x0000FF00) << 8) |
+ ((x & 0x000000FF) << 24);
+ }
+
+ /* Forward 4 times */
+ for (i = 0; i < 4; i++)
+ lfg_forward_one(lfg);
+}
+
+static void lfg_set_seed(lfg_state_t* lfg, const uint8_t* seed)
+{
+ uint32_t i;
+ lfg->position_bytes = 0;
+
+ /* Read seed as big-endian u32s */
+ for (i = 0; i < LFG_SEED_SIZE; i++)
+ {
+ lfg->buffer[i] = rvz_read_be32(seed + i * 4);
+ }
+
+ lfg_initialize(lfg);
+}
+
+static void lfg_forward(lfg_state_t* lfg, uint32_t count)
+{
+ lfg->position_bytes += count;
+ while (lfg->position_bytes >= LFG_K * 4)
+ {
+ lfg_forward_one(lfg);
+ lfg->position_bytes -= LFG_K * 4;
+ }
+}
+
+static void lfg_get_bytes(lfg_state_t* lfg, uint32_t count, uint8_t* out)
+{
+ uint8_t* buffer_bytes = (uint8_t*)lfg->buffer;
+
+ while (count > 0)
+ {
+ uint32_t length = count;
+ if (length > LFG_K * 4 - lfg->position_bytes)
+ length = LFG_K * 4 - lfg->position_bytes;
+
+ memcpy(out, buffer_bytes + lfg->position_bytes, length);
+
+ lfg->position_bytes += length;
+ count -= length;
+ out += length;
+
+ if (lfg->position_bytes == LFG_K * 4)
+ {
+ lfg_forward_one(lfg);
+ lfg->position_bytes = 0;
+ }
+ }
+}
+
+/* Helper: Read big-endian uint32 from buffer */
+static uint32_t rvz_pack_read_u32(const uint8_t* data)
+{
+ return ((uint32_t)data[0] << 24) |
+ ((uint32_t)data[1] << 16) |
+ ((uint32_t)data[2] << 8) |
+ ((uint32_t)data[3]);
+}
+
+/* Helper: RVZPack decompressor - unpack size-prefixed data blocks */
+static bool rvz_pack_decompress(rvz_pack_state_t* pack, uint8_t* output,
+ uint32_t output_size, uint32_t* bytes_written)
+{
+ uint32_t size_field;
+ uint32_t to_copy;
+
+ *bytes_written = 0;
+
+ while (*bytes_written < output_size)
+ {
+ /* Read new block header if needed */
+ if (pack->current_block_remaining == 0)
+ {
+ /* Need at least 4 bytes for size field */
+ if (pack->intermediate_pos + 4 > pack->intermediate_size)
+ {
+ RARCH_LOG("[RVZ] RVZPack: Not enough data for size field\n");
+ return true; /* Partial read is OK */
+ }
+
+ /* Read size field (big-endian) */
+ size_field = rvz_pack_read_u32(pack->intermediate_data + pack->intermediate_pos);
+ pack->intermediate_pos += 4;
+
+ /* Check junk flag (bit 31) */
+ pack->current_block_is_junk = (size_field & 0x80000000) != 0;
+ pack->current_block_size = size_field & 0x7FFFFFFF;
+ pack->current_block_remaining = pack->current_block_size;
+
+ /* Sanity check: block size shouldn't be 0 or too large */
+ if (pack->current_block_size == 0)
+ {
+ RARCH_LOG("[RVZ] RVZPack: Warning - zero-sized block, ending decompression\n");
+ return true;
+ }
+ if (pack->current_block_size > 0x1000000) /* 16MB max */
+ {
+ RARCH_ERR("[RVZ] RVZPack: Block size too large: 0x%08X\n", pack->current_block_size);
+ return false;
+ }
+
+ RARCH_LOG("[RVZ] RVZPack: Block size=0x%08X junk=%d\n",
+ pack->current_block_size, pack->current_block_is_junk);
+
+ /* Handle junk blocks - read LFG seed and initialize */
+ if (pack->current_block_is_junk)
+ {
+ /* Junk blocks have 17 u32s (68 bytes) of LFG seed data */
+ if (pack->intermediate_pos + 68 > pack->intermediate_size)
+ {
+ RARCH_LOG("[RVZ] RVZPack: Not enough data for LFG seed\n");
+ return false;
+ }
+
+ lfg_set_seed(&pack->lfg, pack->intermediate_data + pack->intermediate_pos);
+ pack->intermediate_pos += 68;
+
+ /* Forward LFG by data_offset % 0x8000 */
+ lfg_forward(&pack->lfg, pack->data_offset % 0x8000);
+
+ RARCH_LOG("[RVZ] RVZPack: Junk block - will generate %u bytes with LFG (offset=%llu)\n",
+ pack->current_block_size, (unsigned long long)pack->data_offset);
+ }
+ }
+
+ /* Copy/generate data for current block */
+ to_copy = pack->current_block_remaining;
+ if (to_copy > output_size - *bytes_written)
+ to_copy = output_size - *bytes_written;
+
+ if (pack->current_block_is_junk)
+ {
+ /* Generate junk data using LFG */
+ lfg_get_bytes(&pack->lfg, to_copy, output + *bytes_written);
+ }
+ else
+ {
+ /* Copy real data from intermediate buffer */
+ if (pack->intermediate_pos + to_copy > pack->intermediate_size)
+ {
+ to_copy = pack->intermediate_size - pack->intermediate_pos;
+ if (to_copy == 0)
+ return true; /* No more data available */
+ }
+
+ memcpy(output + *bytes_written,
+ pack->intermediate_data + pack->intermediate_pos,
+ to_copy);
+ pack->intermediate_pos += to_copy;
+ }
+
+ *bytes_written += to_copy;
+ pack->current_block_remaining -= to_copy;
+ pack->data_offset += to_copy;
+ }
+
+ return true;
+}
+
+/* Helper: Decompress a chunk */
+static bool rvz_decompress_chunk(rcheevos_rvz_file_t* rvz, uint32_t group_index,
+ uint8_t** out_data, uint64_t* out_size)
+{
+ rvz_group_entry_t* group;
+ uint64_t compressed_offset;
+ uint32_t compressed_size;
+ uint32_t decompressed_size;
+ uint8_t* compressed_data = NULL;
+ uint8_t* decompressed_data = NULL;
+ bool is_compressed;
+ uint32_t actual_decompressed_size;
+ rvz_pack_state_t pack_state;
+ uint32_t bytes_written;
+
+ if (group_index >= rvz->num_group_entries)
+ {
+ RARCH_ERR("[RVZ] Invalid group index: %u\n", group_index);
+ return false;
+ }
+
+ group = &rvz->group_entries[group_index];
+
+ /* Check if this chunk is compressed */
+ is_compressed = (group->data_size & 0x80000000) != 0;
+ compressed_size = group->data_size & 0x7FFFFFFF;
+
+ /* Special case: all zeros */
+ if (compressed_size == 0)
+ {
+ decompressed_size = rvz->header_2.chunk_size;
+ decompressed_data = (uint8_t*)calloc(1, decompressed_size);
+ if (!decompressed_data)
+ return false;
+
+ *out_data = decompressed_data;
+ *out_size = decompressed_size;
+ return true;
+ }
+
+ compressed_offset = (uint64_t)group->data_offset * 4;
+ /* rvz_packed_size is the actual decompressed size from Zstd */
+ actual_decompressed_size = group->rvz_packed_size ? group->rvz_packed_size : rvz->header_2.chunk_size;
+ /* But chunks should be padded to full chunk_size */
+ decompressed_size = rvz->header_2.chunk_size;
+
+ /* Read compressed data */
+ compressed_data = (uint8_t*)malloc(compressed_size);
+ if (!compressed_data)
+ return false;
+
+ if (filestream_seek(rvz->file, compressed_offset, SEEK_SET) != 0)
+ {
+ free(compressed_data);
+ return false;
+ }
+
+ if (filestream_read(rvz->file, compressed_data, compressed_size) != compressed_size)
+ {
+ free(compressed_data);
+ return false;
+ }
+
+ /* Decompress based on compression type */
+ if (is_compressed)
+ {
+ uint32_t compression_type = rvz->header_2.compression_type;
+
+ switch (compression_type)
+ {
+#ifdef HAVE_ZSTD
+ case RVZ_COMPRESSION_ZSTD:
+ {
+ size_t result;
+ uint8_t* zstd_output;
+ uint32_t zstd_output_size;
+
+ /* Check if we need RVZPack decompression (two-stage) */
+ if (group->rvz_packed_size != 0)
+ {
+ /* Two-stage decompression: Zstd -> RVZPack */
+ RARCH_LOG("[RVZ] Group %u: Two-stage decompression (rvz_packed_size=%u)\n",
+ group_index, group->rvz_packed_size);
+
+ /* Stage 1: Zstd decompress to intermediate buffer */
+ zstd_output_size = group->rvz_packed_size;
+ zstd_output = (uint8_t*)malloc(zstd_output_size);
+ if (!zstd_output)
+ {
+ free(compressed_data);
+ return false;
+ }
+
+ result = ZSTD_decompress(zstd_output, zstd_output_size,
+ compressed_data, compressed_size);
+
+ if (ZSTD_isError(result))
+ {
+ RARCH_ERR("[RVZ] Zstd decompression (stage 1) failed: %s\n", ZSTD_getErrorName(result));
+ free(compressed_data);
+ free(zstd_output);
+ return false;
+ }
+
+ /* Stage 2: RVZPack decompress to final buffer */
+ decompressed_data = (uint8_t*)calloc(1, decompressed_size);
+ if (!decompressed_data)
+ {
+ free(compressed_data);
+ free(zstd_output);
+ return false;
+ }
+
+ memset(&pack_state, 0, sizeof(pack_state));
+ pack_state.intermediate_data = zstd_output;
+ pack_state.intermediate_size = zstd_output_size;
+ pack_state.intermediate_pos = 0;
+ pack_state.rvz_packed_size = group->rvz_packed_size;
+ pack_state.data_offset = 0;
+
+ if (!rvz_pack_decompress(&pack_state, decompressed_data, decompressed_size, &bytes_written))
+ {
+ RARCH_ERR("[RVZ] RVZPack decompression (stage 2) failed\n");
+ free(compressed_data);
+ free(zstd_output);
+ free(decompressed_data);
+ return false;
+ }
+
+ RARCH_LOG("[RVZ] RVZPack: Decompressed %u -> %u bytes\n",
+ zstd_output_size, bytes_written);
+
+ free(zstd_output);
+ }
+ else
+ {
+ /* Single-stage decompression: Just Zstd */
+ RARCH_LOG("[RVZ] Group %u: Single-stage decompression (Zstd only)\n", group_index);
+
+ decompressed_data = (uint8_t*)calloc(1, decompressed_size);
+ if (!decompressed_data)
+ {
+ free(compressed_data);
+ return false;
+ }
+
+ result = ZSTD_decompress(decompressed_data, actual_decompressed_size,
+ compressed_data, compressed_size);
+
+ if (ZSTD_isError(result))
+ {
+ RARCH_ERR("[RVZ] Zstd decompression failed: %s\n", ZSTD_getErrorName(result));
+ free(compressed_data);
+ free(decompressed_data);
+ return false;
+ }
+ }
+ /* Remaining bytes (if any) are already zero from calloc */
+ break;
+ }
+#endif
+ default:
+ RARCH_ERR("[RVZ] Unsupported compression type: %u\n", compression_type);
+ free(compressed_data);
+ return false;
+ }
+ }
+ else
+ {
+ /* Data is uncompressed */
+ if (group->rvz_packed_size != 0)
+ {
+ /* Uncompressed but RVZPack-encoded */
+ RARCH_LOG("[RVZ] Group %u: Uncompressed RVZPack (rvz_packed_size=%u)\n",
+ group_index, group->rvz_packed_size);
+
+ decompressed_data = (uint8_t*)calloc(1, decompressed_size);
+ if (!decompressed_data)
+ {
+ free(compressed_data);
+ return false;
+ }
+
+ memset(&pack_state, 0, sizeof(pack_state));
+ pack_state.intermediate_data = compressed_data;
+ pack_state.intermediate_size = compressed_size;
+ pack_state.intermediate_pos = 0;
+ pack_state.rvz_packed_size = group->rvz_packed_size;
+ pack_state.data_offset = 0;
+
+ if (!rvz_pack_decompress(&pack_state, decompressed_data, decompressed_size, &bytes_written))
+ {
+ RARCH_ERR("[RVZ] RVZPack decompression failed\n");
+ free(compressed_data);
+ free(decompressed_data);
+ return false;
+ }
+
+ RARCH_LOG("[RVZ] RVZPack: Decompressed %u -> %u bytes\n",
+ compressed_size, bytes_written);
+
+ free(compressed_data);
+ compressed_data = NULL; /* Avoid double-free */
+ }
+ else
+ {
+ /* Uncompressed, no RVZPack - just use raw data */
+ decompressed_data = compressed_data;
+ compressed_data = NULL; /* Transfer ownership */
+ }
+ }
+
+ if (compressed_data)
+ free(compressed_data);
+
+ *out_data = decompressed_data;
+ *out_size = decompressed_size;
+ return true;
+}
+
+/* Helper: Get decompressed chunk (with cache) */
+static uint8_t* rvz_get_chunk(rcheevos_rvz_file_t* rvz, uint32_t group_index, uint64_t* out_size)
+{
+ uint32_t i;
+ rvz_chunk_cache_entry_t* entry;
+
+ /* Check cache first */
+ for (i = 0; i < RVZ_CHUNK_CACHE_SIZE; i++)
+ {
+ if (rvz->cache[i].valid && rvz->cache[i].group_index == group_index)
+ {
+ *out_size = rvz->cache[i].decompressed_size;
+ return rvz->cache[i].data;
+ }
+ }
+
+ /* Not in cache, decompress it */
+ entry = &rvz->cache[rvz->cache_next];
+
+ /* Free old cache entry */
+ if (entry->valid && entry->data)
+ {
+ free(entry->data);
+ entry->data = NULL;
+ }
+
+ /* Decompress new chunk */
+ if (!rvz_decompress_chunk(rvz, group_index, &entry->data, &entry->decompressed_size))
+ {
+ entry->valid = false;
+ return NULL;
+ }
+
+ entry->group_index = group_index;
+ entry->valid = true;
+
+ /* Debug: Print bytes at key offsets */
+ if (group_index == 0 && entry->decompressed_size >= 256)
+ {
+ size_t i;
+ RARCH_LOG("[RVZ_CHUNK] Group %u decompressed to %llu bytes.\n",
+ group_index, (unsigned long long)entry->decompressed_size);
+ RARCH_LOG("[RVZ_CHUNK] Bytes 0-31: ");
+ for (i = 0; i < 32; i++)
+ RARCH_LOG("%02X ", entry->data[i]);
+ RARCH_LOG("\n");
+ RARCH_LOG("[RVZ_CHUNK] Bytes 128-159: ");
+ for (i = 128; i < 160; i++)
+ RARCH_LOG("%02X ", entry->data[i]);
+ RARCH_LOG("\n");
+ RARCH_LOG("[RVZ_CHUNK] Bytes 0x2440-0x2453 (apploader start): ");
+ if (entry->decompressed_size >= 0x2454)
+ {
+ for (i = 0x2440; i < 0x2454; i++)
+ RARCH_LOG("%02X ", entry->data[i]);
+ }
+ RARCH_LOG("\n");
+ }
+
+ /* Update LRU */
+ rvz->cache_next = (rvz->cache_next + 1) % RVZ_CHUNK_CACHE_SIZE;
+
+ *out_size = entry->decompressed_size;
+ return entry->data;
+}
+
+/* Public API implementations */
+
+void* rcheevos_rvz_open(const char* path)
+{
+ rcheevos_rvz_file_t* rvz;
+ uint32_t i;
+
+ if (!path)
+ return NULL;
+
+ rvz = (rcheevos_rvz_file_t*)calloc(1, sizeof(rcheevos_rvz_file_t));
+ if (!rvz)
+ return NULL;
+
+ rvz->file = filestream_open(path, RETRO_VFS_FILE_ACCESS_READ,
+ RETRO_VFS_FILE_ACCESS_HINT_NONE);
+ if (!rvz->file)
+ {
+ free(rvz);
+ return NULL;
+ }
+
+ /* Parse headers */
+ if (!rvz_parse_header_1(rvz->file, &rvz->header_1))
+ {
+ RARCH_ERR("[RVZ] Failed to parse header 1\n");
+ filestream_close(rvz->file);
+ free(rvz);
+ return NULL;
+ }
+
+ if (rvz->header_1.magic != RVZ_MAGIC && rvz->header_1.magic != WIA_MAGIC)
+ {
+ RARCH_ERR("[RVZ] Invalid magic: 0x%08X\n", rvz->header_1.magic);
+ filestream_close(rvz->file);
+ free(rvz);
+ return NULL;
+ }
+
+ if (!rvz_parse_header_2(rvz->file, &rvz->header_2))
+ {
+ RARCH_ERR("[RVZ] Failed to parse header 2\n");
+ filestream_close(rvz->file);
+ free(rvz);
+ return NULL;
+ }
+
+ /* Check compression support */
+ if (rvz->header_2.compression_type != RVZ_COMPRESSION_NONE &&
+ rvz->header_2.compression_type != RVZ_COMPRESSION_ZSTD)
+ {
+ RARCH_ERR("[RVZ] Unsupported compression type: %u (only Zstd is currently supported)\n",
+ rvz->header_2.compression_type);
+ filestream_close(rvz->file);
+ free(rvz);
+ return NULL;
+ }
+
+ /* Parse data structures */
+ if (!rvz_parse_raw_data_entries(rvz->file, rvz))
+ {
+ RARCH_ERR("[RVZ] Failed to parse raw data entries\n");
+ filestream_close(rvz->file);
+ free(rvz);
+ return NULL;
+ }
+
+ if (!rvz_parse_group_entries(rvz->file, rvz))
+ {
+ RARCH_ERR("[RVZ] Failed to parse group entries\n");
+ if (rvz->raw_data_entries)
+ free(rvz->raw_data_entries);
+ filestream_close(rvz->file);
+ free(rvz);
+ return NULL;
+ }
+
+ /* Initialize cache */
+ rvz->cache_next = 0;
+ for (i = 0; i < RVZ_CHUNK_CACHE_SIZE; i++)
+ {
+ rvz->cache[i].valid = false;
+ rvz->cache[i].data = NULL;
+ }
+
+ RARCH_LOG("[RVZ] Opened %s format file (compression: %u, chunk_size: %u, iso_size: %llu)\n",
+ rvz->header_1.magic == RVZ_MAGIC ? "RVZ" : "WIA",
+ rvz->header_2.compression_type,
+ rvz->header_2.chunk_size,
+ (unsigned long long)rvz->header_1.iso_file_size);
+
+ return rvz;
+}
+
+void rcheevos_rvz_seek(void* file_handle, int64_t offset, int origin)
+{
+ rcheevos_rvz_file_t* handle = (rcheevos_rvz_file_t*)file_handle;
+ int64_t new_pos;
+
+ if (!handle)
+ return;
+
+ switch (origin)
+ {
+ case SEEK_SET:
+ new_pos = offset;
+ break;
+ case SEEK_CUR:
+ new_pos = handle->position + offset;
+ break;
+ case SEEK_END:
+ new_pos = (int64_t)handle->header_1.iso_file_size + offset;
+ break;
+ default:
+ return;
+ }
+
+ if (new_pos < 0 || new_pos > (int64_t)handle->header_1.iso_file_size)
+ return;
+
+ handle->position = new_pos;
+}
+
+int64_t rcheevos_rvz_tell(void* file_handle)
+{
+ rcheevos_rvz_file_t* handle = (rcheevos_rvz_file_t*)file_handle;
+
+ if (!handle)
+ return -1;
+
+ return handle->position;
+}
+
+size_t rcheevos_rvz_read(void* file_handle, void* buffer, size_t size)
+{
+ rcheevos_rvz_file_t* handle = (rcheevos_rvz_file_t*)file_handle;
+ size_t total_read = 0;
+ uint8_t* output = (uint8_t*)buffer;
+
+ if (!handle || !buffer || size == 0)
+ return 0;
+
+ RARCH_LOG("[RVZ_READ] pos=%lld size=%zu\n",
+ (long long)handle->position, size);
+
+ /* Clamp read to file size */
+ if (handle->position + (int64_t)size > (int64_t)handle->header_1.iso_file_size)
+ size = (size_t)((int64_t)handle->header_1.iso_file_size - handle->position);
+
+ /* Read from disc header (first 128 bytes stored in header 2) */
+ if (handle->position < 128 && total_read < size)
+ {
+ uint64_t offset_in_header = (uint64_t)handle->position;
+ uint64_t available = 128 - offset_in_header;
+ uint64_t to_copy = size - total_read;
+
+ if (to_copy > available)
+ to_copy = available;
+
+ RARCH_LOG("[RVZ_READ] Reading %llu bytes from disc_header at offset %llu\n",
+ (unsigned long long)to_copy, (unsigned long long)offset_in_header);
+
+ memcpy(output + total_read,
+ handle->header_2.disc_header + offset_in_header,
+ (size_t)to_copy);
+
+ total_read += (size_t)to_copy;
+ handle->position += (int64_t)to_copy;
+ }
+
+ /* Read from raw data entries (GameCube discs use this) */
+ if (handle->num_raw_data_entries > 0)
+ {
+ uint32_t i;
+ for (i = 0; i < handle->num_raw_data_entries && total_read < size; i++)
+ {
+ rvz_raw_data_entry_t* entry = &handle->raw_data_entries[i];
+ /* entry_end should account for alignment */
+ uint64_t skipped_data_for_end = entry->data_offset % 0x8000;
+ uint64_t aligned_size = entry->data_size + skipped_data_for_end;
+ int64_t entry_end = (int64_t)((entry->data_offset - skipped_data_for_end) + aligned_size);
+
+ /* Fill gap with zeros if position is before this entry */
+ if (handle->position < (int64_t)entry->data_offset && total_read < size)
+ {
+ uint64_t gap_size = (uint64_t)((int64_t)entry->data_offset - handle->position);
+ uint64_t to_zero = size - total_read;
+
+ if (to_zero > gap_size)
+ to_zero = gap_size;
+
+ memset(output + total_read, 0, (size_t)to_zero);
+ total_read += (size_t)to_zero;
+ handle->position += (int64_t)to_zero;
+ }
+
+ /* Check if current position is within this entry */
+ if (handle->position >= (int64_t)entry->data_offset &&
+ handle->position < entry_end)
+ {
+ uint32_t chunk_size = handle->header_2.chunk_size;
+ /* Dolphin aligns data_offset down to sector boundary (0x8000).
+ * The first skipped_data bytes in the first group are padding. */
+ uint64_t skipped_data = entry->data_offset % 0x8000;
+ uint64_t aligned_offset = entry->data_offset - skipped_data;
+ uint64_t offset_in_groups = (uint64_t)(handle->position - (int64_t)aligned_offset);
+ uint32_t group_index = (uint32_t)(offset_in_groups / chunk_size) + entry->group_index;
+ uint64_t offset_in_chunk = offset_in_groups % chunk_size;
+
+ RARCH_LOG("[RVZ_READ] Entry %u: data_offset=%llu aligned=%llu skipped=%llu data_size=%llu entry_end=%lld\n",
+ i, (unsigned long long)entry->data_offset, (unsigned long long)aligned_offset,
+ (unsigned long long)skipped_data, (unsigned long long)entry->data_size, (long long)entry_end);
+ RARCH_LOG("[RVZ_READ] offset_in_groups=%llu group_index=%u (base=%u) offset_in_chunk=%llu\n",
+ (unsigned long long)offset_in_groups, group_index, entry->group_index,
+ (unsigned long long)offset_in_chunk);
+
+ while (total_read < size && handle->position < entry_end)
+ {
+ uint64_t chunk_data_size;
+ uint8_t* chunk_data;
+ uint64_t available;
+ uint64_t to_copy;
+
+ /* Make sure group_index is still within this entry's groups */
+ if (group_index >= entry->group_index + entry->num_groups)
+ break;
+
+ chunk_data = rvz_get_chunk(handle, group_index, &chunk_data_size);
+ if (!chunk_data)
+ break;
+
+ /* Copy data from this chunk */
+ available = chunk_data_size - offset_in_chunk;
+ to_copy = size - total_read;
+ if (to_copy > available)
+ to_copy = available;
+
+ /* Don't read past entry boundary */
+ if (handle->position + (int64_t)to_copy > entry_end)
+ to_copy = (uint64_t)(entry_end - handle->position);
+
+ RARCH_LOG("[RVZ_READ] Copying %llu bytes from group %u offset %llu (chunk_size=%llu)\n",
+ (unsigned long long)to_copy, group_index,
+ (unsigned long long)offset_in_chunk, (unsigned long long)chunk_data_size);
+
+ memcpy(output + total_read, chunk_data + offset_in_chunk, (size_t)to_copy);
+
+ total_read += (size_t)to_copy;
+ handle->position += (int64_t)to_copy;
+ offset_in_chunk = 0; /* Subsequent chunks start at offset 0 */
+ group_index++;
+ }
+ }
+ }
+ }
+
+ /* Fill any remaining bytes with zeros (reading past all entries) */
+ if (total_read < size)
+ {
+ size_t remaining = size - total_read;
+ memset(output + total_read, 0, remaining);
+ total_read += remaining;
+ handle->position += (int64_t)remaining;
+ }
+
+ return total_read;
+}
+
+void rcheevos_rvz_close(void* file_handle)
+{
+ rcheevos_rvz_file_t* handle = (rcheevos_rvz_file_t*)file_handle;
+ uint32_t i;
+
+ if (!handle)
+ return;
+
+ /* Free cache */
+ for (i = 0; i < RVZ_CHUNK_CACHE_SIZE; i++)
+ {
+ if (handle->cache[i].data)
+ {
+ free(handle->cache[i].data);
+ handle->cache[i].data = NULL;
+ }
+ }
+
+ /* Free data structures */
+ if (handle->raw_data_entries)
+ free(handle->raw_data_entries);
+
+ if (handle->group_entries)
+ free(handle->group_entries);
+
+ /* Close file */
+ if (handle->file)
+ filestream_close(handle->file);
+
+ free(handle);
+}
+
+uint32_t rcheevos_rvz_get_console_id(const char* path)
+{
+ void* rvz = NULL;
+ uint8_t magic[4];
+ uint32_t console_id = RC_CONSOLE_UNKNOWN;
+
+ /* Open the RVZ file */
+ rvz = rcheevos_rvz_open(path);
+ if (!rvz)
+ return RC_CONSOLE_UNKNOWN;
+
+ /* Check for Wii magic word at offset 0x18 */
+ rcheevos_rvz_seek(rvz, 0x18, SEEK_SET);
+ if (rcheevos_rvz_read(rvz, magic, 4) == 4)
+ {
+ if (magic[0] == 0x5D && magic[1] == 0x1C &&
+ magic[2] == 0x9E && magic[3] == 0xA3)
+ {
+ console_id = RC_CONSOLE_WII;
+ goto cleanup;
+ }
+ }
+
+ /* Check for GameCube magic word at offset 0x1C */
+ rcheevos_rvz_seek(rvz, 0x1C, SEEK_SET);
+ if (rcheevos_rvz_read(rvz, magic, 4) == 4)
+ {
+ if (magic[0] == 0xC2 && magic[1] == 0x33 &&
+ magic[2] == 0x9F && magic[3] == 0x3D)
+ {
+ console_id = RC_CONSOLE_GAMECUBE;
+ }
+ }
+
+cleanup:
+ rcheevos_rvz_close(rvz);
+ return console_id;
+}
diff --git a/cheevos/cheevos_rvz.h b/cheevos/cheevos_rvz.h
new file mode 100644
index 00000000000..6ce6c2c7c89
--- /dev/null
+++ b/cheevos/cheevos_rvz.h
@@ -0,0 +1,94 @@
+/* RetroArch - A frontend for libretro.
+ * Copyright (C) 2025 - RetroArch Team
+ *
+ * RetroArch is free software: you can redistribute it and/or modify it under the terms
+ * of the GNU General Public License as published by the Free Software Found-
+ * ation, either version 3 of the License, or (at your option) any later version.
+ *
+ * RetroArch is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
+ * PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with RetroArch.
+ * If not, see .
+ */
+
+#ifndef __RARCH_CHEEVOS_RVZ_H
+#define __RARCH_CHEEVOS_RVZ_H
+
+#include
+#include
+#include
+
+/**
+ * RVZ (and WIA) disc image support for RetroAchievements hashing.
+ *
+ * Provides a virtual file interface that transparently decompresses
+ * RVZ/WIA files on-demand, allowing rcheevos to hash them as if they
+ * were raw disc images.
+ */
+
+/**
+ * rcheevos_rvz_open:
+ * @path : Path to RVZ/WIA file
+ *
+ * Opens an RVZ/WIA file for reading. The file will be parsed and
+ * prepared for on-demand decompression.
+ *
+ * Returns: Handle to RVZ file, or NULL on error
+ */
+void* rcheevos_rvz_open(const char* path);
+
+/**
+ * rcheevos_rvz_seek:
+ * @file_handle : RVZ file handle
+ * @offset : Offset to seek to
+ * @origin : SEEK_SET, SEEK_CUR, or SEEK_END
+ *
+ * Seeks within the virtual decompressed disc image.
+ */
+void rcheevos_rvz_seek(void* file_handle, int64_t offset, int origin);
+
+/**
+ * rcheevos_rvz_tell:
+ * @file_handle : RVZ file handle
+ *
+ * Gets current position in virtual decompressed disc image.
+ *
+ * Returns: Current file position
+ */
+int64_t rcheevos_rvz_tell(void* file_handle);
+
+/**
+ * rcheevos_rvz_read:
+ * @file_handle : RVZ file handle
+ * @buffer : Buffer to read into
+ * @size : Number of bytes to read
+ *
+ * Reads data from virtual decompressed disc image. Will decompress
+ * chunks as needed.
+ *
+ * Returns: Number of bytes actually read
+ */
+size_t rcheevos_rvz_read(void* file_handle, void* buffer, size_t size);
+
+/**
+ * rcheevos_rvz_close:
+ * @file_handle : RVZ file handle
+ *
+ * Closes RVZ file and frees all resources.
+ */
+void rcheevos_rvz_close(void* file_handle);
+
+/**
+ * rcheevos_rvz_get_console_id:
+ * @path : Path to RVZ/WIA file
+ *
+ * Determines the console type (GameCube or Wii) by reading the disc
+ * magic words from the decompressed image.
+ *
+ * Returns: RC_CONSOLE_GAMECUBE, RC_CONSOLE_WII, or RC_CONSOLE_UNKNOWN
+ */
+uint32_t rcheevos_rvz_get_console_id(const char* path);
+
+#endif /* __RARCH_CHEEVOS_RVZ_H */
diff --git a/griffin/griffin.c b/griffin/griffin.c
index d4792cd1126..cbc1ff7d78b 100644
--- a/griffin/griffin.c
+++ b/griffin/griffin.c
@@ -202,6 +202,10 @@ ACHIEVEMENTS
#include "../cheevos/cheevos_client.c"
#include "../cheevos/cheevos_menu.c"
+#if defined(HAVE_CHEEVOS_RVZ)
+#include "../cheevos/cheevos_rvz.c"
+#endif
+
#include "../deps/rcheevos/src/rc_client.c"
#include "../deps/rcheevos/src/rc_compat.c"
#include "../deps/rcheevos/src/rc_libretro.c"
diff --git a/pkg/apple/BaseConfig.xcconfig b/pkg/apple/BaseConfig.xcconfig
index 83c5f01b81f..d50b2ec3bb3 100644
--- a/pkg/apple/BaseConfig.xcconfig
+++ b/pkg/apple/BaseConfig.xcconfig
@@ -19,6 +19,7 @@ OTHER_CFLAGS = $(inherited) -DHAVE_CC_RESAMPLER
OTHER_CFLAGS = $(inherited) -DHAVE_CHD
OTHER_CFLAGS = $(inherited) -DHAVE_CHEATS
OTHER_CFLAGS = $(inherited) -DHAVE_CHEEVOS
+OTHER_CFLAGS = $(inherited) -DHAVE_CHEEVOS_RVZ
OTHER_CFLAGS = $(inherited) -DHAVE_CLOUDSYNC
OTHER_CFLAGS = $(inherited) -DHAVE_COCOA_METAL
OTHER_CFLAGS = $(inherited) -DHAVE_COMMAND