#include #include #include #include #include #include #include #include "app/gfx/resource/arena.h" #include "app/gfx/util/compression.h" #include "rom/rom.h" #include "zelda3/game_data.h" #include "zelda3/game_data.h" #include "testing.h" namespace yaze { namespace test { /** * @brief E2E Test Suite for Graphics Editor Save Operations * * Validates the complete graphics editing workflow: * 1. Load ROM and graphics sheets * 2. Modify pixel data in sheets * 3. Save changes (8BPP→SNES indexed + LC-LZ2 compression) * 4. Reload ROM and verify edits persisted * 5. Verify no data corruption occurred */ class GraphicsEditorSaveTest : public ::testing::Test { protected: void SetUp() override { // Skip tests if ROM is not available if (getenv("YAZE_SKIP_ROM_TESTS")) { GTEST_SKIP() << "ROM tests disabled"; } // Get ROM path from environment or use default (vanilla.sfc to avoid edited ROMs) const char* rom_path_env = getenv("YAZE_TEST_ROM_PATH"); vanilla_rom_path_ = rom_path_env ? rom_path_env : "vanilla.sfc"; if (!std::filesystem::exists(vanilla_rom_path_)) { GTEST_SKIP() << "Test ROM not found: " << vanilla_rom_path_; } // Create test ROM copies test_rom_path_ = "test_graphics_edit.sfc"; backup_rom_path_ = "test_graphics_backup.sfc"; // Copy vanilla ROM for testing std::filesystem::copy_file( vanilla_rom_path_, test_rom_path_, std::filesystem::copy_options::overwrite_existing); std::filesystem::copy_file( vanilla_rom_path_, backup_rom_path_, std::filesystem::copy_options::overwrite_existing); } void TearDown() override { // Clean up test files if (std::filesystem::exists(test_rom_path_)) { std::filesystem::remove(test_rom_path_); } if (std::filesystem::exists(backup_rom_path_)) { std::filesystem::remove(backup_rom_path_); } } // Helper to load ROM and verify basic integrity static absl::Status LoadAndVerifyROM(const std::string& path, std::unique_ptr& rom) { rom = std::make_unique(); RETURN_IF_ERROR(rom->LoadFromFile(path)); // Basic ROM integrity checks EXPECT_EQ(rom->size(), 0x200000) << "ROM size should be 2MB"; EXPECT_NE(rom->data(), nullptr) << "ROM data should not be null"; return absl::OkStatus(); } // Helper to load graphics sheets from ROM into Arena static absl::Status LoadGraphicsFromRom(Rom& rom) { zelda3::GameData game_data; RETURN_IF_ERROR(zelda3::LoadGameData(rom, game_data)); // Copy loaded sheets to Arena auto& arena_sheets = gfx::Arena::Get().gfx_sheets(); for (size_t i = 0; i < zelda3::kNumGfxSheets; i++) { arena_sheets[i] = std::move(game_data.gfx_bitmaps[i]); } return absl::OkStatus(); } // Helper to save modified sheets to ROM (mirrors GraphicsEditor::Save()) static absl::Status SaveSheetToRom(Rom& rom, uint16_t sheet_id) { if (sheet_id >= zelda3::kNumGfxSheets) { return absl::InvalidArgumentError("Sheet ID out of range"); } auto& sheets = gfx::Arena::Get().gfx_sheets(); auto& sheet = sheets[sheet_id]; if (!sheet.is_active()) { return absl::FailedPreconditionError("Sheet not active"); } // Determine BPP and compression based on sheet range int bpp = 3; // Default 3BPP bool compressed = true; // Sheets 113-114, 218+ are 2BPP if (sheet_id == 113 || sheet_id == 114 || sheet_id >= 218) { bpp = 2; } // Sheets 115-126 are uncompressed if (sheet_id >= 115 && sheet_id <= 126) { compressed = false; } // Convert 8BPP bitmap data to SNES indexed format auto indexed_data = gfx::Bpp8SnesToIndexed(sheet.vector(), bpp); std::vector final_data; if (compressed) { // Compress using Hyrule Magic LC-LZ2 int compressed_size = 0; auto compressed_data = gfx::HyruleMagicCompress( indexed_data.data(), static_cast(indexed_data.size()), &compressed_size, 1); final_data.assign(compressed_data.begin(), compressed_data.begin() + compressed_size); } else { final_data = std::move(indexed_data); } // Calculate ROM offset for this sheet // Use JP version constants as default for vanilla ROM const auto& vc = zelda3::kVersionConstantsMap.at(zelda3_version::JP); uint32_t offset = zelda3::GetGraphicsAddress( rom.data(), static_cast(sheet_id), vc.kOverworldGfxPtr1, vc.kOverworldGfxPtr2, vc.kOverworldGfxPtr3, rom.size()); // Write data to ROM buffer for (size_t i = 0; i < final_data.size(); i++) { RETURN_IF_ERROR(rom.WriteByte(offset + i, final_data[i])); } return absl::OkStatus(); } std::string vanilla_rom_path_; std::string test_rom_path_; std::string backup_rom_path_; }; // Test 1: Single sheet edit, save, and reload verification TEST_F(GraphicsEditorSaveTest, SingleSheetEdit_SaveAndReload) { std::unique_ptr rom; ASSERT_OK(LoadAndVerifyROM(vanilla_rom_path_, rom)); // Load graphics into Arena ASSERT_OK(LoadGraphicsFromRom(*rom)); // Get initial pixel value from sheet 0 auto& sheets = gfx::Arena::Get().gfx_sheets(); auto& sheet = sheets[0]; ASSERT_TRUE(sheet.is_active()) << "Sheet 0 should be active after loading"; // Record original pixel value at (0,0) uint8_t original_pixel = sheet.GetPixel(0, 0); // Modify pixel (cycle to next value, wrapping at 16 for 4-bit indexed) uint8_t new_pixel = (original_pixel + 1) % 16; sheet.WriteToPixel(0, 0, new_pixel); // Verify modification took effect in memory EXPECT_EQ(sheet.GetPixel(0, 0), new_pixel) << "Pixel modification should be reflected immediately"; // Save modified sheet to ROM ASSERT_OK(SaveSheetToRom(*rom, 0)); // Save ROM to file ASSERT_OK(rom->SaveToFile(Rom::SaveSettings{.filename = test_rom_path_})); // --- Reload and verify --- std::unique_ptr reloaded_rom; ASSERT_OK(LoadAndVerifyROM(test_rom_path_, reloaded_rom)); // Load graphics from reloaded ROM ASSERT_OK(LoadGraphicsFromRom(*reloaded_rom)); // Verify pixel change persisted auto& reloaded_sheet = gfx::Arena::Get().gfx_sheets()[0]; EXPECT_EQ(reloaded_sheet.GetPixel(0, 0), new_pixel) << "Pixel modification should persist after save/reload"; } // Test 2: Multiple sheet edits save atomically TEST_F(GraphicsEditorSaveTest, MultipleSheetEdit_Atomicity) { std::unique_ptr rom; ASSERT_OK(LoadAndVerifyROM(vanilla_rom_path_, rom)); ASSERT_OK(LoadGraphicsFromRom(*rom)); auto& sheets = gfx::Arena::Get().gfx_sheets(); // Modify sheets 0, 50, and 100 with distinct values const std::vector test_sheets = {0, 50, 100}; const std::vector test_values = {5, 10, 15}; for (size_t i = 0; i < test_sheets.size(); i++) { auto& sheet = sheets[test_sheets[i]]; if (sheet.is_active()) { sheet.WriteToPixel(0, 0, test_values[i]); } } // Save all modified sheets for (uint16_t sheet_id : test_sheets) { if (sheets[sheet_id].is_active()) { ASSERT_OK(SaveSheetToRom(*rom, sheet_id)); } } // Save ROM ASSERT_OK(rom->SaveToFile(Rom::SaveSettings{.filename = test_rom_path_})); // Reload and verify ALL changes persisted std::unique_ptr reloaded_rom; ASSERT_OK(LoadAndVerifyROM(test_rom_path_, reloaded_rom)); ASSERT_OK(LoadGraphicsFromRom(*reloaded_rom)); auto& reloaded_sheets = gfx::Arena::Get().gfx_sheets(); for (size_t i = 0; i < test_sheets.size(); i++) { if (reloaded_sheets[test_sheets[i]].is_active()) { EXPECT_EQ(reloaded_sheets[test_sheets[i]].GetPixel(0, 0), test_values[i]) << "Sheet " << test_sheets[i] << " modification should persist"; } } } // Test 3: Compression integrity for LC-LZ2 compressed sheets TEST_F(GraphicsEditorSaveTest, CompressionIntegrity_LZ2Sheets) { std::unique_ptr rom; ASSERT_OK(LoadAndVerifyROM(vanilla_rom_path_, rom)); ASSERT_OK(LoadGraphicsFromRom(*rom)); // Sheet 50 should be compressed (in range 0-112) const uint16_t test_sheet = 50; auto& sheets = gfx::Arena::Get().gfx_sheets(); auto& sheet = sheets[test_sheet]; if (!sheet.is_active()) { GTEST_SKIP() << "Sheet 50 not active in this ROM"; } // Record original data for a small region std::vector original_region; for (int y = 0; y < 8; y++) { for (int x = 0; x < 8; x++) { original_region.push_back(sheet.GetPixel(x, y)); } } // Modify a single pixel uint8_t original_pixel = sheet.GetPixel(4, 4); uint8_t new_pixel = (original_pixel + 7) % 16; sheet.WriteToPixel(4, 4, new_pixel); // Save and reload ASSERT_OK(SaveSheetToRom(*rom, test_sheet)); ASSERT_OK(rom->SaveToFile(Rom::SaveSettings{.filename = test_rom_path_})); std::unique_ptr reloaded_rom; ASSERT_OK(LoadAndVerifyROM(test_rom_path_, reloaded_rom)); ASSERT_OK(LoadGraphicsFromRom(*reloaded_rom)); // Verify the modified pixel auto& reloaded_sheet = gfx::Arena::Get().gfx_sheets()[test_sheet]; EXPECT_EQ(reloaded_sheet.GetPixel(4, 4), new_pixel) << "Modified pixel should persist through compression round-trip"; // Verify surrounding pixels weren't corrupted for (int y = 0; y < 8; y++) { for (int x = 0; x < 8; x++) { if (x == 4 && y == 4) continue; // Skip modified pixel int idx = y * 8 + x; EXPECT_EQ(reloaded_sheet.GetPixel(x, y), original_region[idx]) << "Pixel at (" << x << "," << y << ") should not be corrupted"; } } } // Test 4: Uncompressed sheets (115-126) save correctly TEST_F(GraphicsEditorSaveTest, UncompressedSheets_SaveCorrectly) { std::unique_ptr rom; ASSERT_OK(LoadAndVerifyROM(vanilla_rom_path_, rom)); ASSERT_OK(LoadGraphicsFromRom(*rom)); // Sheet 115 is uncompressed const uint16_t test_sheet = 115; auto& sheets = gfx::Arena::Get().gfx_sheets(); auto& sheet = sheets[test_sheet]; if (!sheet.is_active()) { GTEST_SKIP() << "Sheet 115 not active in this ROM"; } // Modify pixel uint8_t original_pixel = sheet.GetPixel(0, 0); uint8_t new_pixel = (original_pixel + 3) % 16; sheet.WriteToPixel(0, 0, new_pixel); // Save and reload ASSERT_OK(SaveSheetToRom(*rom, test_sheet)); ASSERT_OK(rom->SaveToFile(Rom::SaveSettings{.filename = test_rom_path_})); std::unique_ptr reloaded_rom; ASSERT_OK(LoadAndVerifyROM(test_rom_path_, reloaded_rom)); ASSERT_OK(LoadGraphicsFromRom(*reloaded_rom)); auto& reloaded_sheet = gfx::Arena::Get().gfx_sheets()[test_sheet]; EXPECT_EQ(reloaded_sheet.GetPixel(0, 0), new_pixel) << "Uncompressed sheet modification should persist"; } // Test 5: Save without corrupting adjacent sheet data TEST_F(GraphicsEditorSaveTest, SaveWithoutCorruption_AdjacentData) { std::unique_ptr rom; ASSERT_OK(LoadAndVerifyROM(vanilla_rom_path_, rom)); ASSERT_OK(LoadGraphicsFromRom(*rom)); auto& sheets = gfx::Arena::Get().gfx_sheets(); // Record pixel values from adjacent sheets (49 and 51) uint8_t sheet49_pixel = 0; uint8_t sheet51_pixel = 0; if (sheets[49].is_active()) { sheet49_pixel = sheets[49].GetPixel(0, 0); } if (sheets[51].is_active()) { sheet51_pixel = sheets[51].GetPixel(0, 0); } // Modify only sheet 50 if (sheets[50].is_active()) { sheets[50].WriteToPixel(0, 0, 8); ASSERT_OK(SaveSheetToRom(*rom, 50)); } // Save and reload ASSERT_OK(rom->SaveToFile(Rom::SaveSettings{.filename = test_rom_path_})); std::unique_ptr reloaded_rom; ASSERT_OK(LoadAndVerifyROM(test_rom_path_, reloaded_rom)); ASSERT_OK(LoadGraphicsFromRom(*reloaded_rom)); auto& reloaded_sheets = gfx::Arena::Get().gfx_sheets(); // Verify adjacent sheets weren't corrupted if (reloaded_sheets[49].is_active()) { EXPECT_EQ(reloaded_sheets[49].GetPixel(0, 0), sheet49_pixel) << "Sheet 49 should not be corrupted by saving sheet 50"; } if (reloaded_sheets[51].is_active()) { EXPECT_EQ(reloaded_sheets[51].GetPixel(0, 0), sheet51_pixel) << "Sheet 51 should not be corrupted by saving sheet 50"; } } // Test 6: Round-trip with no modifications preserves data TEST_F(GraphicsEditorSaveTest, RoundTrip_NoModifications) { std::unique_ptr rom; ASSERT_OK(LoadAndVerifyROM(vanilla_rom_path_, rom)); ASSERT_OK(LoadGraphicsFromRom(*rom)); // Record sample pixel values from multiple sheets auto& sheets = gfx::Arena::Get().gfx_sheets(); std::map original_pixels; for (uint16_t id : {0, 25, 50, 75, 100, 150, 200}) { if (sheets[id].is_active()) { original_pixels[id] = sheets[id].GetPixel(0, 0); } } // Save ROM without modifications ASSERT_OK(rom->SaveToFile(Rom::SaveSettings{.filename = test_rom_path_})); // Reload std::unique_ptr reloaded_rom; ASSERT_OK(LoadAndVerifyROM(test_rom_path_, reloaded_rom)); ASSERT_OK(LoadGraphicsFromRom(*reloaded_rom)); auto& reloaded_sheets = gfx::Arena::Get().gfx_sheets(); // Verify all recorded pixels match for (const auto& [id, pixel] : original_pixels) { if (reloaded_sheets[id].is_active()) { EXPECT_EQ(reloaded_sheets[id].GetPixel(0, 0), pixel) << "Sheet " << id << " should preserve data through round-trip"; } } } } // namespace test } // namespace yaze