From ffc3ddd854bd8a282940808090ec0a659e534be0 Mon Sep 17 00:00:00 2001 From: scawful Date: Mon, 22 Dec 2025 14:29:26 -0500 Subject: [PATCH] fix(gfx): convert indexed sheets to SNES planar --- src/app/editor/graphics/graphics_editor.cc | 58 +++++++--- src/app/gfx/types/snes_tile.cc | 79 +++++++++++-- src/app/gfx/types/snes_tile.h | 10 +- .../graphics_editor_save_test.cc | 109 ++++++++++++------ 4 files changed, 189 insertions(+), 67 deletions(-) diff --git a/src/app/editor/graphics/graphics_editor.cc b/src/app/editor/graphics/graphics_editor.cc index b82f5ae9..c604f85d 100644 --- a/src/app/editor/graphics/graphics_editor.cc +++ b/src/app/editor/graphics/graphics_editor.cc @@ -2,6 +2,7 @@ #include "graphics_editor.h" // C++ standard library headers +#include #include // Third-party library headers @@ -229,22 +230,6 @@ absl::Status GraphicsEditor::Save() { 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 // Get version constants from game_data auto version_constants = zelda3::kVersionConstantsMap.at(game_data()->version); @@ -254,6 +239,47 @@ absl::Status GraphicsEditor::Save() { version_constants.kOverworldGfxPtr2, version_constants.kOverworldGfxPtr3, rom_->size()); + // Convert 8BPP bitmap data to SNES planar format + auto snes_tile_data = gfx::IndexedToSnesSheet(sheet.vector(), bpp); + + constexpr size_t kDecompressedSheetSize = 0x800; + std::vector base_data; + if (compressed) { + auto decomp_result = gfx::lc_lz2::DecompressV2( + rom_->data(), offset, static_cast(kDecompressedSheetSize), 1, + rom_->size()); + if (!decomp_result.ok()) { + return decomp_result.status(); + } + base_data = std::move(*decomp_result); + } else { + auto read_result = + rom_->ReadByteVector(offset, kDecompressedSheetSize); + if (!read_result.ok()) { + return read_result.status(); + } + base_data = std::move(*read_result); + } + + if (base_data.size() < snes_tile_data.size()) { + base_data.resize(snes_tile_data.size(), 0); + } + std::copy(snes_tile_data.begin(), snes_tile_data.end(), + base_data.begin()); + + std::vector final_data; + if (compressed) { + // Compress using Hyrule Magic LC-LZ2 + int compressed_size = 0; + auto compressed_data = gfx::HyruleMagicCompress( + base_data.data(), static_cast(base_data.size()), + &compressed_size, 1); + final_data.assign(compressed_data.begin(), + compressed_data.begin() + compressed_size); + } else { + final_data = std::move(base_data); + } + // Write data to ROM buffer for (size_t i = 0; i < final_data.size(); i++) { rom_->WriteByte(offset + i, final_data[i]); diff --git a/src/app/gfx/types/snes_tile.cc b/src/app/gfx/types/snes_tile.cc index 2064e3c7..122750df 100644 --- a/src/app/gfx/types/snes_tile.cc +++ b/src/app/gfx/types/snes_tile.cc @@ -1,9 +1,10 @@ #include "snes_tile.h" -#include -#include -#include -#include +#include +#include +#include +#include +#include namespace yaze { namespace gfx { @@ -128,8 +129,8 @@ std::vector ConvertBpp(std::span tiles, uint32_t from_bpp, return converted; } -std::vector SnesTo8bppSheet(std::span sheet, int bpp, - int num_sheets) { +std::vector SnesTo8bppSheet(std::span sheet, int bpp, + int num_sheets) { int xx = 0; // positions where we are at on the sheet int yy = 0; int pos = 0; @@ -196,11 +197,67 @@ std::vector SnesTo8bppSheet(std::span sheet, int bpp, ypos = 0; } } - return sheet_buffer_out; -} - -std::vector Bpp8SnesToIndexed(std::vector data, - uint64_t bpp) { + return sheet_buffer_out; +} + +std::vector IndexedToSnesSheet(std::span sheet, int bpp, + int num_sheets) { + if (sheet.empty()) { + return {}; + } + + const int tiles_per_row = kTilesheetWidth / 8; + const int default_tile_rows = (bpp == 2) ? 8 : 4; + const int computed_tile_rows = + static_cast(sheet.size()) / (kTilesheetWidth * 8); + const int tile_rows = (computed_tile_rows > 0) + ? std::max(default_tile_rows, computed_tile_rows) + : default_tile_rows; + const int tiles_per_sheet = tiles_per_row * tile_rows; + const int total_tiles = tiles_per_sheet * num_sheets; + const int bytes_per_tile = bpp * 8; + const uint8_t max_color = + static_cast((1u << static_cast(bpp)) - 1u); + + std::vector output(total_tiles * bytes_per_tile, 0); + + int xx = 0; + int yy = 0; + int pos = 0; + int ypos = 0; + + for (int i = 0; i < total_tiles; i++) { + snes_tile8 tile = {}; + + for (int y = 0; y < 8; y++) { + for (int x = 0; x < 8; x++) { + const int index = + (x + xx) + (y * kTilesheetWidth) + (yy * kTilesheetWidth * 8); + if (index >= 0 && index < static_cast(sheet.size())) { + tile.data[y * 8 + x] = sheet[index] & max_color; + } + } + } + + auto packed_tile = PackBppTile(tile, bpp); + std::copy(packed_tile.begin(), packed_tile.end(), + output.begin() + (pos * bytes_per_tile)); + + pos++; + ypos++; + xx += 8; + if (ypos >= tiles_per_row) { + yy++; + xx = 0; + ypos = 0; + } + } + + return output; +} + +std::vector Bpp8SnesToIndexed(std::vector data, + uint64_t bpp) { // 3BPP // [r0,bp1],[r0,bp2],[r1,bp1],[r1,bp2],[r2,bp1],[r2,bp2],[r3,bp1],[r3,bp2] // [r4,bp1],[r4,bp2],[r5,bp1],[r5,bp2],[r6,bp1],[r6,bp2],[r7,bp1],[r7,bp2] diff --git a/src/app/gfx/types/snes_tile.h b/src/app/gfx/types/snes_tile.h index d14d253c..e68e3916 100644 --- a/src/app/gfx/types/snes_tile.h +++ b/src/app/gfx/types/snes_tile.h @@ -20,10 +20,12 @@ constexpr int kTilesheetDepth = 8; constexpr uint8_t kGraphicsBitmap[8] = {0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01}; -std::vector SnesTo8bppSheet(std::span sheet, int bpp, - int num_sheets = 1); -std::vector Bpp8SnesToIndexed(std::vector data, - uint64_t bpp = 0); +std::vector SnesTo8bppSheet(std::span sheet, int bpp, + int num_sheets = 1); +std::vector IndexedToSnesSheet(std::span sheet, + int bpp, int num_sheets = 1); +std::vector Bpp8SnesToIndexed(std::vector data, + uint64_t bpp = 0); snes_tile8 UnpackBppTile(std::span data, const uint32_t offset, const uint32_t bpp); diff --git a/test/e2e/rom_dependent/graphics_editor_save_test.cc b/test/e2e/rom_dependent/graphics_editor_save_test.cc index 2b1fb2b6..2cc75011 100644 --- a/test/e2e/rom_dependent/graphics_editor_save_test.cc +++ b/test/e2e/rom_dependent/graphics_editor_save_test.cc @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -10,9 +11,11 @@ #include "app/gfx/resource/arena.h" #include "app/gfx/util/compression.h" #include "rom/rom.h" +#include "test/test_utils.h" #include "zelda3/game_data.h" #include "zelda3/game_data.h" #include "testing.h" +#include "zelda.h" namespace yaze { namespace test { @@ -30,18 +33,10 @@ namespace test { 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_; - } + yaze::test::TestRomManager::SkipIfRomMissing( + yaze::test::RomRole::kVanilla, "GraphicsEditorSaveTest"); + vanilla_rom_path_ = + yaze::test::TestRomManager::GetRomPath(yaze::test::RomRole::kVanilla); // Create test ROM copies test_rom_path_ = "test_graphics_edit.sfc"; @@ -66,6 +61,19 @@ class GraphicsEditorSaveTest : public ::testing::Test { } } + static int GetSheetBpp(uint16_t sheet_id) { + if (sheet_id == 113 || sheet_id == 114 || sheet_id >= 218) { + return 2; + } + return 3; + } + + static uint8_t NextPixelValue(uint16_t sheet_id, uint8_t current, + uint8_t delta = 1) { + const uint8_t max_colors = static_cast(1u << GetSheetBpp(sheet_id)); + return static_cast((current + delta) % max_colors); + } + // Helper to load ROM and verify basic integrity static absl::Status LoadAndVerifyROM(const std::string& path, std::unique_ptr& rom) { @@ -107,43 +115,63 @@ class GraphicsEditorSaveTest : public ::testing::Test { } // Determine BPP and compression based on sheet range - int bpp = 3; // Default 3BPP + const int bpp = GetSheetBpp(sheet_id); 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); + // Calculate ROM offset for this sheet + auto version = zelda3_detect_version(rom.data(), rom.size()); + auto vc_it = zelda3::kVersionConstantsMap.find(version); + if (vc_it == zelda3::kVersionConstantsMap.end() || + vc_it->second.kOverworldGfxPtr1 == 0) { + vc_it = zelda3::kVersionConstantsMap.find(zelda3_version::US); + } + const auto& vc = vc_it->second; + uint32_t offset = zelda3::GetGraphicsAddress( + rom.data(), static_cast(sheet_id), + vc.kOverworldGfxPtr1, vc.kOverworldGfxPtr2, + vc.kOverworldGfxPtr3, rom.size()); + + // Convert 8BPP bitmap data to SNES planar format + auto snes_tile_data = gfx::IndexedToSnesSheet(sheet.vector(), bpp); + + constexpr size_t kDecompressedSheetSize = 0x800; + std::vector base_data; + if (compressed) { + auto decomp_result = gfx::lc_lz2::DecompressV2( + rom.data(), offset, static_cast(kDecompressedSheetSize), 1, + rom.size()); + RETURN_IF_ERROR(decomp_result.status()); + base_data = std::move(*decomp_result); + } else { + auto read_result = rom.ReadByteVector(offset, kDecompressedSheetSize); + RETURN_IF_ERROR(read_result.status()); + base_data = std::move(*read_result); + } + + if (base_data.size() < snes_tile_data.size()) { + base_data.resize(snes_tile_data.size(), 0); + } + std::copy(snes_tile_data.begin(), snes_tile_data.end(), + base_data.begin()); 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()), + base_data.data(), static_cast(base_data.size()), &compressed_size, 1); final_data.assign(compressed_data.begin(), compressed_data.begin() + compressed_size); } else { - final_data = std::move(indexed_data); + final_data = std::move(base_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])); @@ -174,7 +202,7 @@ TEST_F(GraphicsEditorSaveTest, SingleSheetEdit_SaveAndReload) { 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; + uint8_t new_pixel = NextPixelValue(0, original_pixel); sheet.WriteToPixel(0, 0, new_pixel); // Verify modification took effect in memory @@ -210,12 +238,19 @@ TEST_F(GraphicsEditorSaveTest, MultipleSheetEdit_Atomicity) { // 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}; + std::vector test_values; + test_values.reserve(test_sheets.size()); 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]); + uint8_t original_pixel = sheet.GetPixel(0, 0); + uint8_t new_pixel = + NextPixelValue(test_sheets[i], original_pixel, static_cast(i + 1)); + sheet.WriteToPixel(0, 0, new_pixel); + test_values.push_back(new_pixel); + } else { + test_values.push_back(0); } } @@ -268,7 +303,7 @@ TEST_F(GraphicsEditorSaveTest, CompressionIntegrity_LZ2Sheets) { // Modify a single pixel uint8_t original_pixel = sheet.GetPixel(4, 4); - uint8_t new_pixel = (original_pixel + 7) % 16; + uint8_t new_pixel = NextPixelValue(test_sheet, original_pixel, 1); sheet.WriteToPixel(4, 4, new_pixel); // Save and reload @@ -312,7 +347,7 @@ TEST_F(GraphicsEditorSaveTest, UncompressedSheets_SaveCorrectly) { // Modify pixel uint8_t original_pixel = sheet.GetPixel(0, 0); - uint8_t new_pixel = (original_pixel + 3) % 16; + uint8_t new_pixel = NextPixelValue(test_sheet, original_pixel, 1); sheet.WriteToPixel(0, 0, new_pixel); // Save and reload @@ -349,7 +384,9 @@ TEST_F(GraphicsEditorSaveTest, SaveWithoutCorruption_AdjacentData) { // Modify only sheet 50 if (sheets[50].is_active()) { - sheets[50].WriteToPixel(0, 0, 8); + uint8_t original_pixel = sheets[50].GetPixel(0, 0); + uint8_t new_pixel = NextPixelValue(50, original_pixel, 1); + sheets[50].WriteToPixel(0, 0, new_pixel); ASSERT_OK(SaveSheetToRom(*rom, 50)); }