#include "zelda3/game_data.h" #include "absl/strings/str_format.h" #include "app/gfx/util/compression.h" #include "util/log.h" #include "util/macro.h" #include "zelda3/dungeon/dungeon_rom_addresses.h" #include #ifdef __EMSCRIPTEN__ #include "app/platform/wasm/wasm_loading_manager.h" #endif namespace yaze { namespace zelda3 { namespace { constexpr uint32_t kUncompressedSheetSize = 0x0800; // constexpr uint32_t kTile16Ptr = 0x78000; // Helper to get address from bytes uint32_t AddressFromBytes(uint8_t bank, uint8_t high, uint8_t low) { return (bank << 16) | (high << 8) | low; } // Helper to convert SNES to PC address uint32_t SnesToPc(uint32_t snes_addr) { return (snes_addr & 0x7FFF) | ((snes_addr & 0x7F0000) >> 1); } struct PaletteSlice { size_t offset = 0; int length = 0; bool valid = false; }; PaletteSlice GetDefaultPaletteSlice(const gfx::SnesPalette& palette) { const int palette_size = static_cast(palette.size()); if (palette_size <= 0) { return {}; } const bool has_explicit_transparent = palette_size >= 16 && (palette_size % 16 == 0); PaletteSlice slice; slice.offset = has_explicit_transparent ? 1 : 0; slice.length = has_explicit_transparent ? 15 : std::min(palette_size, 15); slice.valid = slice.length > 0 && (slice.offset + static_cast(slice.length) <= palette.size()); if (!slice.valid) { slice.offset = 0; slice.length = std::min(palette_size, 15); slice.valid = slice.length > 0 && (slice.offset + static_cast(slice.length) <= palette.size()); } return slice; } void ApplyDefaultPalette(gfx::Bitmap& bitmap, const gfx::SnesPalette& palette) { auto slice = GetDefaultPaletteSlice(palette); if (!slice.valid) { return; } bitmap.SetPaletteWithTransparent(palette, slice.offset, slice.length); } // Helper to convert PC to SNES address // uint32_t PcToSnes(uint32_t pc_addr) { // return ((pc_addr & 0x7FFF) | 0x8000) | ((pc_addr & 0x3F8000) << 1); // } // ============================================================================ // Graphics Address Resolution // ============================================================================ /// Resolves a graphics sheet index to its PC (file) offset in the ROM. /// /// ALTTP stores graphics sheet addresses using three separate pointer tables, /// where each table contains one byte of the 24-bit SNES address: /// - ptr1 table: Bank byte of address (bits 16-23) /// - ptr2 table: High byte of address (bits 8-15) /// - ptr3 table: Low byte of address (bits 0-7) /// /// For US ROMs, these tables are located at: /// - kOverworldGfxPtr1 = 0x4F80 (bank bytes) /// - kOverworldGfxPtr2 = 0x505F (high bytes) /// - kOverworldGfxPtr3 = 0x513E (low bytes) /// /// Example for sheet index 0: /// SNES addr = (data[0x4F80] << 16) | (data[0x505F] << 8) | data[0x513E] /// PC offset = SnesToPc(SNES addr) /// /// @param data Pointer to ROM data buffer /// @param addr Graphics sheet index (0-222) /// @param ptr1 Offset of bank-byte pointer table in ROM /// @param ptr2 Offset of high-byte pointer table in ROM /// @param ptr3 Offset of low-byte pointer table in ROM /// @param rom_size ROM size for bounds checking /// @return PC offset where graphics data begins, or rom_size if OOB /// } // namespace /// @warning Callers must verify the returned offset is within ROM bounds /// before attempting to read or decompress data at that location. uint32_t GetGraphicsAddress(const uint8_t* data, uint8_t addr, uint32_t ptr1, uint32_t ptr2, uint32_t ptr3, size_t rom_size) { if (ptr1 > UINT32_MAX - addr || ptr1 + addr >= rom_size || ptr2 > UINT32_MAX - addr || ptr2 + addr >= rom_size || ptr3 > UINT32_MAX - addr || ptr3 + addr >= rom_size) { return static_cast(rom_size); } return SnesToPc(AddressFromBytes(data[ptr1 + addr], data[ptr2 + addr], data[ptr3 + addr])); } absl::Status LoadGameData(Rom& rom, GameData& data, const LoadOptions& options) { data.Clear(); data.set_rom(&rom); if (options.populate_metadata) { RETURN_IF_ERROR(LoadMetadata(rom, data)); } if (options.load_palettes) { RETURN_IF_ERROR(LoadPalettes(rom, data)); } if (options.load_gfx_groups) { RETURN_IF_ERROR(LoadGfxGroups(rom, data)); } if (options.load_graphics) { RETURN_IF_ERROR(LoadGraphics(rom, data)); } if (options.expand_rom) { if (rom.size() < 1048576 * 2) { rom.Expand(1048576 * 2); } } return absl::OkStatus(); } absl::Status SaveGameData(Rom& rom, GameData& data) { if (core::FeatureFlags::get().kSaveAllPalettes) { // TODO: Implement SaveAllPalettes logic using Rom::WriteColor // This was previously in Rom::SaveAllPalettes return data.palette_groups.for_each([&](gfx::PaletteGroup& group) -> absl::Status { for (size_t i = 0; i < group.size(); ++i) { auto* palette = group.mutable_palette(i); for (size_t j = 0; j < palette->size(); ++j) { gfx::SnesColor color = (*palette)[j]; if (color.is_modified()) { RETURN_IF_ERROR(rom.WriteColor( gfx::GetPaletteAddress(group.name(), i, j), color)); color.set_modified(false); } } } return absl::OkStatus(); }); } if (core::FeatureFlags::get().kSaveGfxGroups) { RETURN_IF_ERROR(SaveGfxGroups(rom, data)); } // TODO: Implement SaveAllGraphicsData logic return absl::OkStatus(); } absl::Status LoadMetadata(const Rom& rom, GameData& data) { constexpr uint32_t kTitleStringOffset = 0x7FC0; constexpr uint32_t kTitleStringLength = 20; if (rom.size() < kTitleStringOffset + kTitleStringLength) { return absl::OutOfRangeError("ROM too small for metadata"); } // Check version byte at offset + 0x19 (0x7FD9) if (kTitleStringOffset + 0x19 < rom.size()) { // Access directly via data() since ReadByte is non-const uint8_t version_byte = rom.data()[kTitleStringOffset + 0x19]; data.version = (version_byte == 0) ? zelda3_version::JP : zelda3_version::US; } auto title_bytes = rom.ReadByteVector(kTitleStringOffset, kTitleStringLength); if (title_bytes.ok()) { data.title.assign(title_bytes->begin(), title_bytes->end()); } return absl::OkStatus(); } absl::Status LoadPalettes(const Rom& rom, GameData& data) { // Create a vector from rom data for palette loading const std::vector& rom_vec = rom.vector(); return gfx::LoadAllPalettes(rom_vec, data.palette_groups); } absl::Status LoadGfxGroups(Rom& rom, GameData& data) { if (kVersionConstantsMap.find(data.version) == kVersionConstantsMap.end()) { return absl::FailedPreconditionError("Unsupported ROM version"); } auto version_constants = kVersionConstantsMap.at(data.version); // Load Main Blocksets auto main_ptr_res = rom.ReadWord(kGfxGroupsPointer); if (main_ptr_res.ok()) { uint32_t main_ptr = SnesToPc(*main_ptr_res); for (uint32_t i = 0; i < kNumMainBlocksets; i++) { for (int j = 0; j < 8; j++) { auto val = rom.ReadByte(main_ptr + (i * 8) + j); if (val.ok()) data.main_blockset_ids[i][j] = *val; } } } // Load Room Blocksets for (uint32_t i = 0; i < kNumRoomBlocksets; i++) { for (int j = 0; j < 4; j++) { auto val = rom.ReadByte(kEntranceGfxGroup + (i * 4) + j); if (val.ok()) data.room_blockset_ids[i][j] = *val; } } // Load Sprite Blocksets for (uint32_t i = 0; i < kNumSpritesets; i++) { for (int j = 0; j < 4; j++) { auto val = rom.ReadByte(version_constants.kSpriteBlocksetPointer + (i * 4) + j); if (val.ok()) data.spriteset_ids[i][j] = *val; } } // Load Palette Sets for (uint32_t i = 0; i < kNumPalettesets; i++) { for (int j = 0; j < 4; j++) { auto val = rom.ReadByte(version_constants.kDungeonPalettesGroups + (i * 4) + j); if (val.ok()) data.paletteset_ids[i][j] = *val; } } return absl::OkStatus(); } absl::Status SaveGfxGroups(Rom& rom, const GameData& data) { auto version_constants = kVersionConstantsMap.at(data.version); ASSIGN_OR_RETURN(auto main_ptr, rom.ReadWord(kGfxGroupsPointer)); main_ptr = SnesToPc(main_ptr); // Save Main Blocksets for (uint32_t i = 0; i < kNumMainBlocksets; i++) { for (int j = 0; j < 8; j++) { RETURN_IF_ERROR(rom.WriteByte(main_ptr + (i * 8) + j, data.main_blockset_ids[i][j])); } } // Save Room Blocksets for (uint32_t i = 0; i < kNumRoomBlocksets; i++) { for (int j = 0; j < 4; j++) { RETURN_IF_ERROR(rom.WriteByte(kEntranceGfxGroup + (i * 4) + j, data.room_blockset_ids[i][j])); } } // Save Sprite Blocksets for (uint32_t i = 0; i < kNumSpritesets; i++) { for (int j = 0; j < 4; j++) { RETURN_IF_ERROR(rom.WriteByte(version_constants.kSpriteBlocksetPointer + (i * 4) + j, data.spriteset_ids[i][j])); } } // Save Palette Sets for (uint32_t i = 0; i < kNumPalettesets; i++) { for (int j = 0; j < 4; j++) { RETURN_IF_ERROR(rom.WriteByte(version_constants.kDungeonPalettesGroups + (i * 4) + j, data.paletteset_ids[i][j])); } } return absl::OkStatus(); } // ============================================================================ // Main Graphics Loading // ============================================================================ /// Loads all 223 graphics sheets from the ROM into bitmap format. /// /// ALTTP uses 223 graphics sheets (kNumGfxSheets) stored in three formats: /// /// Sheet Categories: /// - Sheets 0-112: Compressed 3BPP (overworld, dungeons, sprites) /// - Sheets 113-114: 2BPP sheets (skipped here, loaded separately) /// - Sheets 115-126: Uncompressed 3BPP (special graphics) /// - Sheets 127-217: Compressed 3BPP (additional graphics) /// - Sheets 218+: 2BPP sheets (skipped here) /// /// Compression Format: /// Graphics data is compressed using Nintendo's LC-LZ2 algorithm. Each sheet /// is decompressed to a 0x800 (2048) byte buffer, then converted from SNES /// planar format to linear 8BPP for easier manipulation. /// /// Graphics Buffer: /// All sheet data is also appended to data.graphics_buffer for legacy /// compatibility. Sheets that fail to load are filled with 0xFF bytes to /// maintain correct indexing. /// /// @param rom The loaded ROM to read graphics from /// @param data The GameData structure to populate with graphics /// @return OkStatus on success, or error status /// /// @warning The DecompressV2 size parameter MUST be 0x800, not 0. /// Passing size=0 causes immediate return of empty data, which /// was a regression bug that caused all graphics to appear as /// solid purple/brown (0xFF fill). absl::Status LoadGraphics(Rom& rom, GameData& data) { if (kVersionConstantsMap.find(data.version) == kVersionConstantsMap.end()) { return absl::FailedPreconditionError("Unsupported ROM version for graphics"); } auto version_constants = kVersionConstantsMap.at(data.version); data.graphics_buffer.clear(); #ifdef __EMSCRIPTEN__ auto loading_handle = app::platform::WasmLoadingManager::BeginLoading("Loading Graphics"); #endif // Initialize Diagnostics auto& diag = data.diagnostics; diag.rom_size = rom.size(); diag.ptr1_loc = version_constants.kOverworldGfxPtr1; diag.ptr2_loc = version_constants.kOverworldGfxPtr2; diag.ptr3_loc = version_constants.kOverworldGfxPtr3; for (uint32_t i = 0; i < kNumGfxSheets; i++) { #ifdef __EMSCRIPTEN__ app::platform::WasmLoadingManager::UpdateProgress(loading_handle, static_cast(i) / kNumGfxSheets); #endif diag.sheets[i].index = i; std::vector sheet; bool bpp3 = false; uint32_t offset = 0; // Uncompressed 3BPP (115-126) if (i >= 115 && i <= 126) { diag.sheets[i].is_compressed = false; offset = GetGraphicsAddress(rom.data(), i, version_constants.kOverworldGfxPtr1, version_constants.kOverworldGfxPtr2, version_constants.kOverworldGfxPtr3, rom.size()); diag.sheets[i].pc_offset = offset; auto read_res = rom.ReadByteVector(offset, zelda3::kUncompressedSheetSize); if (read_res.ok()) { sheet = *read_res; diag.sheets[i].decompression_succeeded = true; bpp3 = true; } else { sheet.assign(zelda3::kUncompressedSheetSize, 0); diag.sheets[i].decompression_succeeded = false; } } // 2BPP (113-114, 218+) - Skipped in main loop else if (i == 113 || i == 114 || i >= 218) { diag.sheets[i].is_compressed = true; bpp3 = false; } // Compressed 3BPP (Standard) else { diag.sheets[i].is_compressed = true; offset = GetGraphicsAddress(rom.data(), i, version_constants.kOverworldGfxPtr1, version_constants.kOverworldGfxPtr2, version_constants.kOverworldGfxPtr3, rom.size()); diag.sheets[i].pc_offset = offset; if (offset < rom.size()) { // Decompress using LC-LZ2 algorithm with 0x800 byte output buffer. // IMPORTANT: The size parameter (0x800) must NOT be 0, or DecompressV2 // returns an empty vector immediately. This was a regression bug. // See: docs/internal/graphics-loading-regression-2024.md auto decomp_res = gfx::lc_lz2::DecompressV2(rom.data(), offset, 0x800, 1, rom.size()); if (decomp_res.ok()) { sheet = *decomp_res; diag.sheets[i].decompression_succeeded = true; bpp3 = true; } else { diag.sheets[i].decompression_succeeded = false; } } } // Post-process if (bpp3) { auto converted_sheet = gfx::SnesTo8bppSheet(sheet, 3); if (converted_sheet.size() != 4096) converted_sheet.resize(4096, 0); data.raw_gfx_sheets[i] = converted_sheet; data.gfx_bitmaps[i].Create(gfx::kTilesheetWidth, gfx::kTilesheetHeight, gfx::kTilesheetDepth, converted_sheet); // Apply default palettes if (!data.palette_groups.empty()) { gfx::SnesPalette default_palette; if (i < 113 && data.palette_groups.dungeon_main.size() > 0) { default_palette = data.palette_groups.dungeon_main[0]; } else if (i < 128 && data.palette_groups.sprites_aux1.size() > 0) { default_palette = data.palette_groups.sprites_aux1[0]; } else if (data.palette_groups.hud.size() > 0) { default_palette = data.palette_groups.hud.palette(0); } if (!default_palette.empty()) { ApplyDefaultPalette(data.gfx_bitmaps[i], default_palette); } else { // Fallback to grayscale if no palette found std::vector grayscale; for (int color_idx = 0; color_idx < 16; ++color_idx) { float val = color_idx / 15.0f; grayscale.emplace_back(ImVec4(val, val, val, 1.0f)); } // Ensure index 0 is transparent for SNES compatibility if (!grayscale.empty()) { grayscale[0].set_transparent(true); } data.gfx_bitmaps[i].SetPalette(gfx::SnesPalette(grayscale)); } } else { // Fallback to grayscale if no palette groups loaded std::vector grayscale; for (int color_idx = 0; color_idx < 16; ++color_idx) { float val = color_idx / 15.0f; grayscale.emplace_back(ImVec4(val, val, val, 1.0f)); } // Ensure index 0 is transparent for SNES compatibility if (!grayscale.empty()) { grayscale[0].set_transparent(true); } data.gfx_bitmaps[i].SetPalette(gfx::SnesPalette(grayscale)); } data.graphics_buffer.insert(data.graphics_buffer.end(), data.gfx_bitmaps[i].data(), data.gfx_bitmaps[i].data() + data.gfx_bitmaps[i].size()); } else { // Placeholder - Fill with 0 (transparent) instead of 0xFF (white) std::vector placeholder(4096, 0); data.raw_gfx_sheets[i] = placeholder; data.gfx_bitmaps[i].Create(gfx::kTilesheetWidth, gfx::kTilesheetHeight, gfx::kTilesheetDepth, placeholder); data.graphics_buffer.resize(data.graphics_buffer.size() + 4096, 0); } } diag.Analyze(); #ifdef __EMSCRIPTEN__ app::platform::WasmLoadingManager::EndLoading(loading_handle); #endif return absl::OkStatus(); } // ============================================================================ // Link Graphics Loading // ============================================================================ absl::StatusOr> LoadLinkGraphics( const Rom& rom) { std::array link_graphics; for (uint32_t i = 0; i < kNumLinkSheets; i++) { auto link_sheet_data_result = rom.ReadByteVector(/*offset=*/kLinkGfxOffset + (i * kLinkGfxLength), /*length=*/kLinkGfxLength); if (!link_sheet_data_result.ok()) { return link_sheet_data_result.status(); } auto link_sheet_8bpp = gfx::SnesTo8bppSheet(*link_sheet_data_result, /*bpp=*/4); if (link_sheet_8bpp.size() != 4096) link_sheet_8bpp.resize(4096, 0); link_graphics[i].Create(gfx::kTilesheetWidth, gfx::kTilesheetHeight, gfx::kTilesheetDepth, link_sheet_8bpp); // Palette is applied by the caller since GameData may not be available here } return link_graphics; } // ============================================================================ // 2BPP Graphics Loading // ============================================================================ absl::StatusOr> Load2BppGraphics(const Rom& rom) { std::vector sheet; const uint8_t sheets[] = {0x71, 0x72, 0xDA, 0xDB, 0xDC, 0xDD, 0xDE}; // Get version constants - default to US if we don't know auto version_constants = kVersionConstantsMap.at(zelda3_version::US); for (const auto& sheet_id : sheets) { auto offset = GetGraphicsAddress(rom.data(), sheet_id, version_constants.kOverworldGfxPtr1, version_constants.kOverworldGfxPtr2, version_constants.kOverworldGfxPtr3, rom.size()); if (offset >= rom.size()) { return absl::OutOfRangeError( absl::StrFormat("2BPP graphics sheet %u offset %u exceeds ROM size %zu", sheet_id, offset, rom.size())); } // Decompress using LC-LZ2 algorithm with 0x800 byte output buffer. auto decomp_result = gfx::lc_lz2::DecompressV2(rom.data(), offset, 0x800, 1, rom.size()); if (!decomp_result.ok()) { return decomp_result.status(); } auto converted_sheet = gfx::SnesTo8bppSheet(*decomp_result, 2); for (const auto& each_pixel : converted_sheet) { sheet.push_back(each_pixel); } } return sheet; } // ============================================================================ // Font Graphics Loading // ============================================================================ absl::StatusOr LoadFontGraphics(const Rom& rom) { // Font sprites are located at 0x70000, 2BPP format constexpr uint32_t kFontDataSize = 0x4000; // 16KB of font data auto font_data_result = rom.ReadByteVector(kFontSpriteLocation, kFontDataSize); if (!font_data_result.ok()) { return font_data_result.status(); } // Convert from 2BPP SNES format to 8BPP auto font_8bpp = gfx::SnesTo8bppSheet(*font_data_result, /*bpp=*/2); gfx::Bitmap font_bitmap; font_bitmap.Create(gfx::kTilesheetWidth * 2, gfx::kTilesheetHeight * 4, gfx::kTilesheetDepth, font_8bpp); return font_bitmap; } // ============================================================================ // Graphics Saving // ============================================================================ absl::Status SaveAllGraphicsData( [[maybe_unused]] Rom& rom, [[maybe_unused]] const std::array& sheets) { // For now, return OK status - full implementation would write sheets back // to ROM at their respective addresses with proper compression LOG_INFO("SaveAllGraphicsData", "Graphics save not yet fully implemented"); return absl::OkStatus(); } } // namespace zelda3 } // namespace yaze