Files
yaze/test/e2e/rom_dependent/graphics_editor_save_test.cc

413 lines
13 KiB
C++

#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <memory>
#include <set>
#include <string>
#include <vector>
#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) {
rom = std::make_unique<Rom>();
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<uint8_t> final_data;
if (compressed) {
// Compress using Hyrule Magic LC-LZ2
int compressed_size = 0;
auto compressed_data = gfx::HyruleMagicCompress(
indexed_data.data(), static_cast<int>(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<uint8_t>(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> 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<Rom> 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> 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<uint16_t> test_sheets = {0, 50, 100};
const std::vector<uint8_t> 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<Rom> 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> 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<uint8_t> 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<Rom> 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> 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<Rom> 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> 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<Rom> 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> 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<uint16_t, uint8_t> 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<Rom> 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