From 2ea811eeb20efc45feefc35e08aae3543d0b7676 Mon Sep 17 00:00:00 2001 From: scawful Date: Sat, 4 Oct 2025 00:42:48 -0400 Subject: [PATCH] feat: Enhance DungeonObjectEditor with visual feedback and object manipulation features, including drag-and-drop support and property panel integration --- .../zelda3/dungeon/dungeon_object_editor.cc | 354 ++++++++++++++++-- .../zelda3/dungeon/dungeon_object_editor.h | 18 +- src/app/zelda3/dungeon/room.cc | 199 ++++++++-- src/app/zelda3/dungeon/room.h | 11 + test/CMakeLists.txt | 4 + test/zelda3/dungeon/room_integration_test.cc | 327 ++++++++++++++++ test/zelda3/dungeon/room_manipulation_test.cc | 169 +++++++++ 7 files changed, 1026 insertions(+), 56 deletions(-) create mode 100644 test/zelda3/dungeon/room_integration_test.cc create mode 100644 test/zelda3/dungeon/room_manipulation_test.cc diff --git a/src/app/zelda3/dungeon/dungeon_object_editor.cc b/src/app/zelda3/dungeon/dungeon_object_editor.cc index b05459ed..3400e423 100644 --- a/src/app/zelda3/dungeon/dungeon_object_editor.cc +++ b/src/app/zelda3/dungeon/dungeon_object_editor.cc @@ -8,6 +8,7 @@ #include "app/core/window.h" #include "app/gfx/arena.h" #include "app/gfx/snes_palette.h" +#include "imgui/imgui.h" namespace yaze { namespace zelda3 { @@ -100,10 +101,8 @@ absl::Status DungeonObjectEditor::SaveRoom() { } } - // TODO: Implement actual room saving to ROM - // This would involve writing the room data back to the ROM file - - return absl::OkStatus(); + // Save room objects back to ROM (Phase 1, Task 1.3) + return current_room_->SaveObjects(); } absl::Status DungeonObjectEditor::ClearRoom() { @@ -175,8 +174,11 @@ absl::Status DungeonObjectEditor::InsertObject(int x, int y, int object_type, in } } - // Add object to room - current_room_->AddTileObject(new_object); + // Add object to room using new method (Phase 3) + auto add_status = current_room_->AddObject(new_object); + if (!add_status.ok()) { + return add_status; + } // Select the new object ClearSelection(); @@ -213,8 +215,11 @@ absl::Status DungeonObjectEditor::DeleteObject(size_t object_index) { return status; } - // Remove object from room - current_room_->RemoveTileObject(object_index); + // Remove object from room using new method (Phase 3) + auto remove_status = current_room_->RemoveObject(object_index); + if (!remove_status.ok()) { + return remove_status; + } // Update selection indices for (auto& selected_index : selection_state_.selected_objects) { @@ -531,26 +536,35 @@ absl::Status DungeonObjectEditor::HandleMouseDrag(int start_x, int start_y, int return absl::FailedPreconditionError("No room loaded"); } - // Convert screen coordinates to room coordinates - auto [start_room_x, start_room_y] = ScreenToRoomCoordinates(start_x, start_y); - auto [current_room_x, current_room_y] = ScreenToRoomCoordinates(current_x, current_y); + // Enable dragging if not already (Phase 4) + if (!selection_state_.is_dragging && !selection_state_.selected_objects.empty()) { + selection_state_.is_dragging = true; + selection_state_.drag_start_x = start_x; + selection_state_.drag_start_y = start_y; + + // Create undo point before drag + auto undo_status = CreateUndoPoint(); + if (!undo_status.ok()) { + return undo_status; + } + } - if (editing_state_.current_mode == Mode::kSelect && !selection_state_.selected_objects.empty()) { - // Move selected objects - for (size_t object_index : selection_state_.selected_objects) { - if (object_index < current_room_->GetTileObjectCount()) { - auto& object = current_room_->GetTileObject(object_index); - - // Calculate offset from start position - int offset_x = current_room_x - start_room_x; - int offset_y = current_room_y - start_room_y; - - // Move object - auto status = MoveObject(object_index, object.x_ + offset_x, object.y_ + offset_y); - if (!status.ok()) { - return status; - } - } + // Handle the drag operation (Phase 4) + return HandleDragOperation(current_x, current_y); +} + +absl::Status DungeonObjectEditor::HandleMouseRelease(int x, int y) { + if (current_room_ == nullptr) { + return absl::FailedPreconditionError("No room loaded"); + } + + // End dragging operation (Phase 4) + if (selection_state_.is_dragging) { + selection_state_.is_dragging = false; + + // Notify callbacks about the final positions + if (room_changed_callback_) { + room_changed_callback_(); } } @@ -863,6 +877,292 @@ void DungeonObjectEditor::ClearHistory() { redo_history_.clear(); } +// ============================================================================ +// Phase 4: Visual Feedback and GUI Methods +// ============================================================================ + +// Helper for color blending +static uint32_t BlendColors(uint32_t base, uint32_t tint) { + uint8_t a_tint = (tint >> 24) & 0xFF; + if (a_tint == 0) return base; + + uint8_t r_base = (base >> 16) & 0xFF; + uint8_t g_base = (base >> 8) & 0xFF; + uint8_t b_base = base & 0xFF; + + uint8_t r_tint = (tint >> 16) & 0xFF; + uint8_t g_tint = (tint >> 8) & 0xFF; + uint8_t b_tint = tint & 0xFF; + + float alpha = a_tint / 255.0f; + uint8_t r = r_base * (1.0f - alpha) + r_tint * alpha; + uint8_t g = g_base * (1.0f - alpha) + g_tint * alpha; + uint8_t b = b_base * (1.0f - alpha) + b_tint * alpha; + + return 0xFF000000 | (r << 16) | (g << 8) | b; +} + +void DungeonObjectEditor::RenderSelectionHighlight(gfx::Bitmap& canvas) { + if (!config_.show_selection_highlight || selection_state_.selected_objects.empty()) { + return; + } + + // Draw highlight rectangles around selected objects + for (size_t obj_idx : selection_state_.selected_objects) { + if (obj_idx >= current_room_->GetTileObjectCount()) continue; + + const auto& obj = current_room_->GetTileObject(obj_idx); + int x = obj.x() * 16; + int y = obj.y() * 16; + int w = 16 + (obj.size() * 4); // Approximate width + int h = 16 + (obj.size() * 4); // Approximate height + + // Draw yellow selection box (2px border) - using SetPixel + uint8_t r = (config_.selection_color >> 16) & 0xFF; + uint8_t g = (config_.selection_color >> 8) & 0xFF; + uint8_t b = config_.selection_color & 0xFF; + gfx::SnesColor sel_color(r, g, b); + + for (int py = y; py < y + h; py++) { + for (int px = x; px < x + w; px++) { + if (px < canvas.width() && py < canvas.height() && + (px < x + 2 || px >= x + w - 2 || py < y + 2 || py >= y + h - 2)) { + canvas.SetPixel(px, py, sel_color); + } + } + } + } +} + +void DungeonObjectEditor::RenderLayerVisualization(gfx::Bitmap& canvas) { + if (!config_.show_layer_colors || !current_room_) { + return; + } + + // Apply subtle color tints based on layer (simplified - just mark with colored border) + for (const auto& obj : current_room_->GetTileObjects()) { + int x = obj.x() * 16; + int y = obj.y() * 16; + int w = 16; + int h = 16; + + uint32_t tint_color = 0xFF000000; + switch (obj.GetLayerValue()) { + case 0: tint_color = config_.layer0_color; break; + case 1: tint_color = config_.layer1_color; break; + case 2: tint_color = config_.layer2_color; break; + } + + // Draw 1px border in layer color + uint8_t r = (tint_color >> 16) & 0xFF; + uint8_t g = (tint_color >> 8) & 0xFF; + uint8_t b = tint_color & 0xFF; + gfx::SnesColor layer_color(r, g, b); + + for (int py = y; py < y + h && py < canvas.height(); py++) { + for (int px = x; px < x + w && px < canvas.width(); px++) { + if (px == x || px == x + w - 1 || py == y || py == y + h - 1) { + canvas.SetPixel(px, py, layer_color); + } + } + } + } +} + +void DungeonObjectEditor::RenderObjectPropertyPanel() { + if (!config_.show_property_panel || selection_state_.selected_objects.empty()) { + return; + } + + ImGui::Begin("Object Properties", &config_.show_property_panel); + + if (selection_state_.selected_objects.size() == 1) { + size_t obj_idx = selection_state_.selected_objects[0]; + if (obj_idx < current_room_->GetTileObjectCount()) { + auto& obj = current_room_->GetTileObject(obj_idx); + + ImGui::Text("Object #%zu", obj_idx); + ImGui::Separator(); + + // ID (hex) + int id = obj.id_; + if (ImGui::InputInt("ID (0x)", &id, 1, 16, ImGuiInputTextFlags_CharsHexadecimal)) { + if (id >= 0 && id <= 0xFFF) { + obj.id_ = id; + if (object_changed_callback_) { + object_changed_callback_(obj_idx, obj); + } + } + } + + // Position + int x = obj.x(); + int y = obj.y(); + if (ImGui::InputInt("X Position", &x, 1, 4)) { + if (x >= 0 && x < 64) { + obj.set_x(x); + if (object_changed_callback_) { + object_changed_callback_(obj_idx, obj); + } + } + } + if (ImGui::InputInt("Y Position", &y, 1, 4)) { + if (y >= 0 && y < 64) { + obj.set_y(y); + if (object_changed_callback_) { + object_changed_callback_(obj_idx, obj); + } + } + } + + // Size (for Type 1 objects only) + if (obj.id_ < 0x100) { + int size = obj.size(); + if (ImGui::SliderInt("Size", &size, 0, 15)) { + obj.set_size(size); + if (object_changed_callback_) { + object_changed_callback_(obj_idx, obj); + } + } + } + + // Layer + int layer = obj.GetLayerValue(); + if (ImGui::Combo("Layer", &layer, "Layer 0\0Layer 1\0Layer 2\0")) { + obj.layer_ = static_cast(layer); + if (object_changed_callback_) { + object_changed_callback_(obj_idx, obj); + } + } + + ImGui::Separator(); + + // Action buttons + if (ImGui::Button("Delete Object")) { + auto status = DeleteObject(obj_idx); + (void)status; // Ignore return value for now + } + ImGui::SameLine(); + if (ImGui::Button("Duplicate")) { + RoomObject duplicate = obj; + duplicate.set_x(obj.x() + 1); + auto status = current_room_->AddObject(duplicate); + (void)status; // Ignore return value for now + } + } + } else { + // Multiple objects selected + ImGui::Text("%zu objects selected", selection_state_.selected_objects.size()); + ImGui::Separator(); + + if (ImGui::Button("Delete All Selected")) { + auto status = DeleteSelectedObjects(); + (void)status; // Ignore return value for now + } + + if (ImGui::Button("Clear Selection")) { + auto status = ClearSelection(); + (void)status; // Ignore return value for now + } + } + + ImGui::End(); +} + +void DungeonObjectEditor::RenderLayerControls() { + ImGui::Begin("Layer Controls"); + + // Current layer selection + ImGui::Text("Current Layer:"); + ImGui::RadioButton("Layer 0", &editing_state_.current_layer, 0); + ImGui::SameLine(); + ImGui::RadioButton("Layer 1", &editing_state_.current_layer, 1); + ImGui::SameLine(); + ImGui::RadioButton("Layer 2", &editing_state_.current_layer, 2); + + ImGui::Separator(); + + // Layer visibility toggles + static bool layer_visible[3] = {true, true, true}; + ImGui::Text("Layer Visibility:"); + ImGui::Checkbox("Show Layer 0", &layer_visible[0]); + ImGui::Checkbox("Show Layer 1", &layer_visible[1]); + ImGui::Checkbox("Show Layer 2", &layer_visible[2]); + + ImGui::Separator(); + + // Layer colors + ImGui::Checkbox("Show Layer Colors", &config_.show_layer_colors); + if (config_.show_layer_colors) { + ImGui::ColorEdit4("Layer 0 Tint", (float*)&config_.layer0_color); + ImGui::ColorEdit4("Layer 1 Tint", (float*)&config_.layer1_color); + ImGui::ColorEdit4("Layer 2 Tint", (float*)&config_.layer2_color); + } + + ImGui::Separator(); + + // Object counts per layer + if (current_room_) { + int count0 = 0, count1 = 0, count2 = 0; + for (const auto& obj : current_room_->GetTileObjects()) { + switch (obj.GetLayerValue()) { + case 0: count0++; break; + case 1: count1++; break; + case 2: count2++; break; + } + } + ImGui::Text("Layer 0: %d objects", count0); + ImGui::Text("Layer 1: %d objects", count1); + ImGui::Text("Layer 2: %d objects", count2); + } + + ImGui::End(); +} + +absl::Status DungeonObjectEditor::HandleDragOperation(int current_x, int current_y) { + if (!selection_state_.is_dragging || selection_state_.selected_objects.empty()) { + return absl::OkStatus(); + } + + // Calculate delta from drag start + int dx = current_x - selection_state_.drag_start_x; + int dy = current_y - selection_state_.drag_start_y; + + // Convert pixel delta to grid delta + int grid_dx = dx / config_.grid_size; + int grid_dy = dy / config_.grid_size; + + if (grid_dx == 0 && grid_dy == 0) { + return absl::OkStatus(); // No meaningful movement yet + } + + // Move all selected objects + for (size_t obj_idx : selection_state_.selected_objects) { + if (obj_idx >= current_room_->GetTileObjectCount()) continue; + + auto& obj = current_room_->GetTileObject(obj_idx); + int new_x = obj.x() + grid_dx; + int new_y = obj.y() + grid_dy; + + // Clamp to valid range + new_x = std::max(0, std::min(63, new_x)); + new_y = std::max(0, std::min(63, new_y)); + + obj.set_x(new_x); + obj.set_y(new_y); + + if (object_changed_callback_) { + object_changed_callback_(obj_idx, obj); + } + } + + // Update drag start position + selection_state_.drag_start_x = current_x; + selection_state_.drag_start_y = current_y; + + return absl::OkStatus(); +} + absl::StatusOr DungeonObjectEditor::RenderRoom() { if (current_room_ == nullptr) { return absl::FailedPreconditionError("No room loaded"); diff --git a/src/app/zelda3/dungeon/dungeon_object_editor.h b/src/app/zelda3/dungeon/dungeon_object_editor.h index 29fa4389..3d9f587e 100644 --- a/src/app/zelda3/dungeon/dungeon_object_editor.h +++ b/src/app/zelda3/dungeon/dungeon_object_editor.h @@ -77,6 +77,15 @@ class DungeonObjectEditor { int auto_save_interval = 300; // 5 minutes bool validate_objects = true; bool show_collision_bounds = false; + + // Phase 4: Visual feedback settings + bool show_selection_highlight = true; + bool show_layer_colors = true; + bool show_property_panel = true; + uint32_t selection_color = 0xFFFFFF00; // Yellow + uint32_t layer0_color = 0xFFFF0000; // Red tint + uint32_t layer1_color = 0xFF00FF00; // Green tint + uint32_t layer2_color = 0xFF0000FF; // Blue tint }; // Undo/Redo system @@ -117,7 +126,7 @@ class DungeonObjectEditor { bool right_button, bool shift_pressed); absl::Status HandleMouseDrag(int start_x, int start_y, int current_x, int current_y); - absl::Status HandleMouseRelease(int x, int y); + absl::Status HandleMouseRelease(int x, int y); // Phase 4: End drag operations absl::Status HandleScrollWheel(int delta, int x, int y, bool ctrl_pressed); absl::Status HandleKeyPress(int key_code, bool ctrl_pressed, bool shift_pressed); @@ -145,6 +154,13 @@ class DungeonObjectEditor { absl::StatusOr RenderPreview(int x, int y); void SetPreviewPosition(int x, int y); void UpdatePreview(); + + // Phase 4: Visual feedback and GUI + void RenderSelectionHighlight(gfx::Bitmap& canvas); + void RenderLayerVisualization(gfx::Bitmap& canvas); + void RenderObjectPropertyPanel(); // ImGui panel + void RenderLayerControls(); // ImGui controls + absl::Status HandleDragOperation(int current_x, int current_y); // Undo/Redo functionality absl::Status Undo(); diff --git a/src/app/zelda3/dungeon/room.cc b/src/app/zelda3/dungeon/room.cc index d3eec9c8..03d947af 100644 --- a/src/app/zelda3/dungeon/room.cc +++ b/src/app/zelda3/dungeon/room.cc @@ -481,37 +481,16 @@ void Room::ParseObjectsFromLocation(int objects_location) { } if (!door) { - // Parse object with enhanced validation - if (b3 >= 0xF8) { - oid = static_cast((b3 << 4) | - 0x80 + (((b2 & 0x03) << 2) + ((b1 & 0x03)))); - posX = static_cast((b1 & 0xFC) >> 2); - posY = static_cast((b2 & 0xFC) >> 2); - sizeXY = static_cast((((b1 & 0x03) << 2) + (b2 & 0x03))); - } else { - oid = b3; - posX = static_cast((b1 & 0xFC) >> 2); - posY = static_cast((b2 & 0xFC) >> 2); - sizeX = static_cast((b1 & 0x03)); - sizeY = static_cast((b2 & 0x03)); - sizeXY = static_cast(((sizeX << 2) + sizeY)); - } - - if (b1 >= 0xFC) { - oid = static_cast((b3 & 0x3F) + 0x100); - posX = static_cast(((b2 & 0xF0) >> 4) + ((b1 & 0x3) << 4)); - posY = static_cast(((b2 & 0x0F) << 2) + ((b3 & 0xC0) >> 6)); - sizeXY = 0; - } - - // Validate object ID before creating object - if (oid >= 0 && oid <= 0x3FF) { - RoomObject r(oid, posX, posY, sizeXY, static_cast(layer)); + // Use the refactored encoding/decoding functions (Phase 1, Task 1.2) + RoomObject r = RoomObject::DecodeObjectFromBytes(b1, b2, b3, static_cast(layer)); + + // Validate object ID before adding to the room + if (r.id_ >= 0 && r.id_ <= 0x3FF) { r.set_rom(rom_); tile_objects_.push_back(r); - // Handle special object types - HandleSpecialObjects(oid, posX, posY, nbr_of_staircase); + // Handle special object types (staircases, chests, etc.) + HandleSpecialObjects(r.id_, r.x(), r.y(), nbr_of_staircase); } } else { // Handle door objects (placeholder for future implementation) @@ -521,6 +500,170 @@ void Room::ParseObjectsFromLocation(int objects_location) { } } +// ============================================================================ +// Object Saving Implementation (Phase 1, Task 1.3) +// ============================================================================ + +std::vector Room::EncodeObjects() const { + std::vector bytes; + + // Organize objects by layer + std::vector layer0_objects; + std::vector layer1_objects; + std::vector layer2_objects; + + for (const auto& obj : tile_objects_) { + switch (obj.GetLayerValue()) { + case 0: layer0_objects.push_back(obj); break; + case 1: layer1_objects.push_back(obj); break; + case 2: layer2_objects.push_back(obj); break; + } + } + + // Encode Layer 1 (BG2) + for (const auto& obj : layer0_objects) { + auto encoded = obj.EncodeObjectToBytes(); + bytes.push_back(encoded.b1); + bytes.push_back(encoded.b2); + bytes.push_back(encoded.b3); + } + bytes.push_back(0xFF); + bytes.push_back(0xFF); + + // Encode Layer 2 (BG1) + for (const auto& obj : layer1_objects) { + auto encoded = obj.EncodeObjectToBytes(); + bytes.push_back(encoded.b1); + bytes.push_back(encoded.b2); + bytes.push_back(encoded.b3); + } + bytes.push_back(0xFF); + bytes.push_back(0xFF); + + // Encode Layer 3 + for (const auto& obj : layer2_objects) { + auto encoded = obj.EncodeObjectToBytes(); + bytes.push_back(encoded.b1); + bytes.push_back(encoded.b2); + bytes.push_back(encoded.b3); + } + + // Final terminator + bytes.push_back(0xFF); + bytes.push_back(0xFF); + + return bytes; +} + +absl::Status Room::SaveObjects() { + if (rom_ == nullptr) { + return absl::InvalidArgumentError("ROM pointer is null"); + } + + auto rom_data = rom()->vector(); + + // Get object pointer + int object_pointer = (rom_data[room_object_pointer + 2] << 16) + + (rom_data[room_object_pointer + 1] << 8) + + (rom_data[room_object_pointer]); + object_pointer = SnesToPc(object_pointer); + + if (object_pointer < 0 || object_pointer >= (int)rom_->size()) { + return absl::OutOfRangeError("Object pointer out of range"); + } + + int room_address = object_pointer + (room_id_ * 3); + + if (room_address < 0 || room_address + 2 >= (int)rom_->size()) { + return absl::OutOfRangeError("Room address out of range"); + } + + int tile_address = (rom_data[room_address + 2] << 16) + + (rom_data[room_address + 1] << 8) + rom_data[room_address]; + + int objects_location = SnesToPc(tile_address); + + if (objects_location < 0 || objects_location >= (int)rom_->size()) { + return absl::OutOfRangeError("Objects location out of range"); + } + + // Skip graphics/layout header (2 bytes) + int write_pos = objects_location + 2; + + // Encode all objects + auto encoded_bytes = EncodeObjects(); + + // Write encoded bytes to ROM using WriteVector + return rom_->WriteVector(write_pos, encoded_bytes); +} + +// ============================================================================ +// Object Manipulation Methods (Phase 3) +// ============================================================================ + +absl::Status Room::AddObject(const RoomObject& object) { + // Validate object + if (!ValidateObject(object)) { + return absl::InvalidArgumentError("Invalid object parameters"); + } + + // Add to internal list + tile_objects_.push_back(object); + + return absl::OkStatus(); +} + +absl::Status Room::RemoveObject(size_t index) { + if (index >= tile_objects_.size()) { + return absl::OutOfRangeError("Object index out of range"); + } + + tile_objects_.erase(tile_objects_.begin() + index); + + return absl::OkStatus(); +} + +absl::Status Room::UpdateObject(size_t index, const RoomObject& object) { + if (index >= tile_objects_.size()) { + return absl::OutOfRangeError("Object index out of range"); + } + + if (!ValidateObject(object)) { + return absl::InvalidArgumentError("Invalid object parameters"); + } + + tile_objects_[index] = object; + + return absl::OkStatus(); +} + +absl::StatusOr Room::FindObjectAt(int x, int y, int layer) const { + for (size_t i = 0; i < tile_objects_.size(); i++) { + const auto& obj = tile_objects_[i]; + if (obj.x() == x && obj.y() == y && obj.GetLayerValue() == layer) { + return i; + } + } + return absl::NotFoundError("No object found at position"); +} + +bool Room::ValidateObject(const RoomObject& object) const { + // Validate position (0-63 for both X and Y) + if (object.x() < 0 || object.x() > 63) return false; + if (object.y() < 0 || object.y() > 63) return false; + + // Validate layer (0-2) + if (object.GetLayerValue() < 0 || object.GetLayerValue() > 2) return false; + + // Validate object ID range + if (object.id_ < 0 || object.id_ > 0xFFF) return false; + + // Validate size for Type 1 objects + if (object.id_ < 0x100 && object.size() > 15) return false; + + return true; +} + void Room::HandleSpecialObjects(short oid, uint8_t posX, uint8_t posY, int& nbr_of_staircase) { // Handle staircase objects for (short stair : stairsObjects) { diff --git a/src/app/zelda3/dungeon/room.h b/src/app/zelda3/dungeon/room.h index 380cddd8..b34ca4ca 100644 --- a/src/app/zelda3/dungeon/room.h +++ b/src/app/zelda3/dungeon/room.h @@ -244,6 +244,13 @@ class Room { void AddTileObject(const RoomObject& object) { tile_objects_.push_back(object); } + + // Enhanced object manipulation (Phase 3) + absl::Status AddObject(const RoomObject& object); + absl::Status RemoveObject(size_t index); + absl::Status UpdateObject(size_t index, const RoomObject& object); + absl::StatusOr FindObjectAt(int x, int y, int layer) const; + bool ValidateObject(const RoomObject& object) const; void RemoveTileObject(size_t index) { if (index < tile_objects_.size()) { tile_objects_.erase(tile_objects_.begin() + index); @@ -318,6 +325,10 @@ class Room { void ParseObjectsFromLocation(int objects_location); void HandleSpecialObjects(short oid, uint8_t posX, uint8_t posY, int& nbr_of_staircase); + + // Object saving (Phase 1, Task 1.3) + absl::Status SaveObjects(); + std::vector EncodeObjects() const; auto blocks() const { return blocks_; } auto& mutable_blocks() { return blocks_; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8e95b05d..83723a07 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -43,6 +43,8 @@ if(YAZE_BUILD_TESTS AND NOT YAZE_BUILD_TESTS STREQUAL "OFF") unit/zelda3/test_dungeon_objects.cc unit/zelda3/dungeon_component_unit_test.cc zelda3/dungeon/room_object_encoding_test.cc + zelda3/dungeon/room_integration_test.cc + zelda3/dungeon/room_manipulation_test.cc # CLI Services (for catalog serialization tests) ../src/cli/service/resources/resource_catalog.cc @@ -98,6 +100,8 @@ if(YAZE_BUILD_TESTS AND NOT YAZE_BUILD_TESTS STREQUAL "OFF") unit/zelda3/test_dungeon_objects.cc unit/zelda3/dungeon_component_unit_test.cc zelda3/dungeon/room_object_encoding_test.cc + zelda3/dungeon/room_integration_test.cc + zelda3/dungeon/room_manipulation_test.cc # CLI Services (for catalog serialization tests) ../src/cli/service/resources/resource_catalog.cc diff --git a/test/zelda3/dungeon/room_integration_test.cc b/test/zelda3/dungeon/room_integration_test.cc new file mode 100644 index 00000000..9e9234b1 --- /dev/null +++ b/test/zelda3/dungeon/room_integration_test.cc @@ -0,0 +1,327 @@ +// Integration tests for Room object load/save cycle with real ROM data +// Phase 1, Task 2.1: Full round-trip verification + +#include +#include + +#include "app/rom.h" +#include "app/zelda3/dungeon/room.h" +#include "app/zelda3/dungeon/room_object.h" + +// Helper function for SNES to PC address conversion +inline int SnesToPc(int addr) { + int temp = (addr & 0x7FFF) + ((addr / 2) & 0xFF8000); + return (temp + 0x0); +} + +namespace yaze { +namespace zelda3 { +namespace test { + +class RoomIntegrationTest : public ::testing::Test { + protected: + void SetUp() override { + // Load the ROM file + rom_ = std::make_unique(); + + // Check if ROM file exists + const char* rom_path = std::getenv("YAZE_TEST_ROM_PATH"); + if (!rom_path) { + rom_path = "zelda3.sfc"; + } + + auto status = rom_->LoadFromFile(rom_path); + if (!status.ok()) { + GTEST_SKIP() << "ROM file not available: " << status.message(); + } + + // Create backup of ROM data for restoration after tests + original_rom_data_ = rom_->vector(); + } + + void TearDown() override { + // Restore original ROM data + if (rom_ && !original_rom_data_.empty()) { + for (size_t i = 0; i < original_rom_data_.size(); i++) { + rom_->WriteByte(i, original_rom_data_[i]); + } + } + } + + std::unique_ptr rom_; + std::vector original_rom_data_; +}; + +// ============================================================================ +// Test 1: Basic Load/Save Round-Trip +// ============================================================================ + +TEST_F(RoomIntegrationTest, BasicLoadSaveRoundTrip) { + // Load room 0 (Hyrule Castle Entrance) + Room room1(0x00, rom_.get()); + + // Get original object count + size_t original_count = room1.GetTileObjects().size(); + ASSERT_GT(original_count, 0) << "Room should have objects"; + + // Store original objects + auto original_objects = room1.GetTileObjects(); + + // Save the room (should write same data back) + auto save_status = room1.SaveObjects(); + ASSERT_TRUE(save_status.ok()) << save_status.message(); + + // Load the room again + Room room2(0x00, rom_.get()); + + // Verify object count matches + EXPECT_EQ(room2.GetTileObjects().size(), original_count); + + // Verify each object matches + auto reloaded_objects = room2.GetTileObjects(); + ASSERT_EQ(reloaded_objects.size(), original_objects.size()); + + for (size_t i = 0; i < original_objects.size(); i++) { + SCOPED_TRACE("Object " + std::to_string(i)); + + const auto& orig = original_objects[i]; + const auto& reload = reloaded_objects[i]; + + EXPECT_EQ(reload.id_, orig.id_) << "ID mismatch"; + EXPECT_EQ(reload.x(), orig.x()) << "X position mismatch"; + EXPECT_EQ(reload.y(), orig.y()) << "Y position mismatch"; + EXPECT_EQ(reload.size(), orig.size()) << "Size mismatch"; + EXPECT_EQ(reload.GetLayerValue(), orig.GetLayerValue()) << "Layer mismatch"; + } +} + +// ============================================================================ +// Test 2: Multi-Room Verification +// ============================================================================ + +TEST_F(RoomIntegrationTest, MultiRoomLoadSaveRoundTrip) { + // Test several different rooms to ensure broad coverage + std::vector test_rooms = {0x00, 0x01, 0x02, 0x10, 0x20}; + + for (int room_id : test_rooms) { + SCOPED_TRACE("Room " + std::to_string(room_id)); + + // Load room + Room room1(room_id, rom_.get()); + auto original_objects = room1.GetTileObjects(); + + if (original_objects.empty()) { + continue; // Skip empty rooms + } + + // Save objects + auto save_status = room1.SaveObjects(); + ASSERT_TRUE(save_status.ok()) << save_status.message(); + + // Reload and verify + Room room2(room_id, rom_.get()); + auto reloaded_objects = room2.GetTileObjects(); + + EXPECT_EQ(reloaded_objects.size(), original_objects.size()); + + // Verify objects match + for (size_t i = 0; i < std::min(original_objects.size(), reloaded_objects.size()); i++) { + const auto& orig = original_objects[i]; + const auto& reload = reloaded_objects[i]; + + EXPECT_EQ(reload.id_, orig.id_); + EXPECT_EQ(reload.x(), orig.x()); + EXPECT_EQ(reload.y(), orig.y()); + EXPECT_EQ(reload.size(), orig.size()); + EXPECT_EQ(reload.GetLayerValue(), orig.GetLayerValue()); + } + } +} + +// ============================================================================ +// Test 3: Layer Verification +// ============================================================================ + +TEST_F(RoomIntegrationTest, LayerPreservation) { + // Load a room known to have multiple layers + Room room(0x01, rom_.get()); + + auto objects = room.GetTileObjects(); + ASSERT_GT(objects.size(), 0); + + // Count objects per layer + int layer0_count = 0, layer1_count = 0, layer2_count = 0; + for (const auto& obj : objects) { + switch (obj.GetLayerValue()) { + case 0: layer0_count++; break; + case 1: layer1_count++; break; + case 2: layer2_count++; break; + } + } + + // Save and reload + ASSERT_TRUE(room.SaveObjects().ok()); + + Room room2(0x01, rom_.get()); + auto reloaded = room2.GetTileObjects(); + + // Verify layer counts match + int reload_layer0 = 0, reload_layer1 = 0, reload_layer2 = 0; + for (const auto& obj : reloaded) { + switch (obj.GetLayerValue()) { + case 0: reload_layer0++; break; + case 1: reload_layer1++; break; + case 2: reload_layer2++; break; + } + } + + EXPECT_EQ(reload_layer0, layer0_count); + EXPECT_EQ(reload_layer1, layer1_count); + EXPECT_EQ(reload_layer2, layer2_count); +} + +// ============================================================================ +// Test 4: Object Type Distribution +// ============================================================================ + +TEST_F(RoomIntegrationTest, ObjectTypeDistribution) { + Room room(0x00, rom_.get()); + + auto objects = room.GetTileObjects(); + ASSERT_GT(objects.size(), 0); + + // Count object types + int type1_count = 0; // ID < 0x100 + int type2_count = 0; // ID 0x100-0x13F + int type3_count = 0; // ID >= 0xF00 + + for (const auto& obj : objects) { + if (obj.id_ >= 0xF00) { + type3_count++; + } else if (obj.id_ >= 0x100) { + type2_count++; + } else { + type1_count++; + } + } + + // Save and reload + ASSERT_TRUE(room.SaveObjects().ok()); + + Room room2(0x00, rom_.get()); + auto reloaded = room2.GetTileObjects(); + + // Verify type distribution matches + int reload_type1 = 0, reload_type2 = 0, reload_type3 = 0; + for (const auto& obj : reloaded) { + if (obj.id_ >= 0xF00) { + reload_type3++; + } else if (obj.id_ >= 0x100) { + reload_type2++; + } else { + reload_type1++; + } + } + + EXPECT_EQ(reload_type1, type1_count); + EXPECT_EQ(reload_type2, type2_count); + EXPECT_EQ(reload_type3, type3_count); +} + +// ============================================================================ +// Test 5: Binary Data Verification +// ============================================================================ + +TEST_F(RoomIntegrationTest, BinaryDataExactMatch) { + // This test verifies that saving doesn't change ROM data + // when no modifications are made + + Room room(0x02, rom_.get()); + + // Get the ROM location where objects are stored + auto rom_data = rom_->vector(); + int object_pointer = (rom_data[0x874C + 2] << 16) + + (rom_data[0x874C + 1] << 8) + + (rom_data[0x874C]); + object_pointer = SnesToPc(object_pointer); + + int room_address = object_pointer + (0x02 * 3); + int tile_address = (rom_data[room_address + 2] << 16) + + (rom_data[room_address + 1] << 8) + + rom_data[room_address]; + int objects_location = SnesToPc(tile_address); + + // Read original bytes (up to 500 bytes should cover most rooms) + std::vector original_bytes; + for (int i = 0; i < 500 && objects_location + i < (int)rom_data.size(); i++) { + original_bytes.push_back(rom_data[objects_location + i]); + // Stop at final terminator + if (i > 0 && original_bytes[i] == 0xFF && original_bytes[i-1] == 0xFF) { + // Check if this is the final terminator (3rd layer end) + bool might_be_final = true; + for (int j = i - 10; j < i - 1; j += 2) { + if (j >= 0 && original_bytes[j] == 0xFF && original_bytes[j+1] == 0xFF) { + // Found another FF FF marker, keep going + break; + } + } + if (might_be_final) break; + } + } + + // Save objects (should write identical data) + ASSERT_TRUE(room.SaveObjects().ok()); + + // Read bytes after save + rom_data = rom_->vector(); + std::vector saved_bytes; + for (size_t i = 0; i < original_bytes.size() && objects_location + i < rom_data.size(); i++) { + saved_bytes.push_back(rom_data[objects_location + i]); + } + + // Verify binary match + ASSERT_EQ(saved_bytes.size(), original_bytes.size()); + for (size_t i = 0; i < original_bytes.size(); i++) { + EXPECT_EQ(saved_bytes[i], original_bytes[i]) + << "Byte mismatch at offset " << i; + } +} + +// ============================================================================ +// Test 6: Known Room Data Verification +// ============================================================================ + +TEST_F(RoomIntegrationTest, KnownRoomData) { + // Room 0x00 (Hyrule Castle Entrance) - verify known objects exist + Room room(0x00, rom_.get()); + + auto objects = room.GetTileObjects(); + ASSERT_GT(objects.size(), 0) << "Room 0x00 should have objects"; + + // Verify we can find common object types + bool found_type1 = false; + bool found_layer0 = false; + bool found_layer1 = false; + + for (const auto& obj : objects) { + if (obj.id_ < 0x100) found_type1 = true; + if (obj.GetLayerValue() == 0) found_layer0 = true; + if (obj.GetLayerValue() == 1) found_layer1 = true; + } + + EXPECT_TRUE(found_type1) << "Should have Type 1 objects"; + EXPECT_TRUE(found_layer0) << "Should have Layer 0 objects"; + + // Verify coordinates are in valid range (0-63) + for (const auto& obj : objects) { + EXPECT_GE(obj.x(), 0); + EXPECT_LE(obj.x(), 63); + EXPECT_GE(obj.y(), 0); + EXPECT_LE(obj.y(), 63); + } +} + +} // namespace test +} // namespace zelda3 +} // namespace yaze + diff --git a/test/zelda3/dungeon/room_manipulation_test.cc b/test/zelda3/dungeon/room_manipulation_test.cc new file mode 100644 index 00000000..7e37d3b4 --- /dev/null +++ b/test/zelda3/dungeon/room_manipulation_test.cc @@ -0,0 +1,169 @@ +// Tests for Room object manipulation methods (Phase 3) + +#include +#include "app/rom.h" +#include "app/zelda3/dungeon/room.h" +#include "app/zelda3/dungeon/room_object.h" + +namespace yaze { +namespace zelda3 { +namespace test { + +class RoomManipulationTest : public ::testing::Test { + protected: + void SetUp() override { + rom_ = std::make_unique(); + // Create a minimal ROM for testing + std::vector dummy_data(0x200000, 0); + rom_->LoadFromData(dummy_data, false); + + room_ = std::make_unique(0, rom_.get()); + } + + std::unique_ptr rom_; + std::unique_ptr room_; +}; + +TEST_F(RoomManipulationTest, AddObject) { + RoomObject obj(0x10, 10, 20, 3, 0); + + auto status = room_->AddObject(obj); + ASSERT_TRUE(status.ok()); + + auto objects = room_->GetTileObjects(); + EXPECT_EQ(objects.size(), 1); + EXPECT_EQ(objects[0].id_, 0x10); + EXPECT_EQ(objects[0].x(), 10); + EXPECT_EQ(objects[0].y(), 20); +} + +TEST_F(RoomManipulationTest, AddInvalidObject) { + // Invalid X position (> 63) + RoomObject obj(0x10, 100, 20, 3, 0); + + auto status = room_->AddObject(obj); + EXPECT_FALSE(status.ok()); + EXPECT_EQ(room_->GetTileObjects().size(), 0); +} + +TEST_F(RoomManipulationTest, RemoveObject) { + RoomObject obj1(0x10, 10, 20, 3, 0); + RoomObject obj2(0x20, 15, 25, 2, 1); + + room_->AddObject(obj1); + room_->AddObject(obj2); + + EXPECT_EQ(room_->GetTileObjects().size(), 2); + + auto status = room_->RemoveObject(0); + ASSERT_TRUE(status.ok()); + + auto objects = room_->GetTileObjects(); + EXPECT_EQ(objects.size(), 1); + EXPECT_EQ(objects[0].id_, 0x20); +} + +TEST_F(RoomManipulationTest, RemoveInvalidIndex) { + auto status = room_->RemoveObject(0); + EXPECT_FALSE(status.ok()); +} + +TEST_F(RoomManipulationTest, UpdateObject) { + RoomObject obj(0x10, 10, 20, 3, 0); + room_->AddObject(obj); + + RoomObject updated(0x20, 15, 25, 5, 1); + auto status = room_->UpdateObject(0, updated); + ASSERT_TRUE(status.ok()); + + auto objects = room_->GetTileObjects(); + EXPECT_EQ(objects[0].id_, 0x20); + EXPECT_EQ(objects[0].x(), 15); + EXPECT_EQ(objects[0].y(), 25); +} + +TEST_F(RoomManipulationTest, FindObjectAt) { + RoomObject obj1(0x10, 10, 20, 3, 0); + RoomObject obj2(0x20, 15, 25, 2, 1); + + room_->AddObject(obj1); + room_->AddObject(obj2); + + auto result = room_->FindObjectAt(15, 25, 1); + ASSERT_TRUE(result.ok()); + EXPECT_EQ(result.value(), 1); + + auto not_found = room_->FindObjectAt(99, 99, 0); + EXPECT_FALSE(not_found.ok()); +} + +TEST_F(RoomManipulationTest, ValidateObject) { + // Valid Type 1 object + RoomObject valid1(0x10, 10, 20, 3, 0); + EXPECT_TRUE(room_->ValidateObject(valid1)); + + // Valid Type 2 object + RoomObject valid2(0x110, 30, 40, 0, 1); + EXPECT_TRUE(room_->ValidateObject(valid2)); + + // Invalid X (> 63) + RoomObject invalid_x(0x10, 100, 20, 3, 0); + EXPECT_FALSE(room_->ValidateObject(invalid_x)); + + // Invalid layer (> 2) + RoomObject invalid_layer(0x10, 10, 20, 3, 5); + EXPECT_FALSE(room_->ValidateObject(invalid_layer)); + + // Invalid size for Type 1 (> 15) + RoomObject invalid_size(0x10, 10, 20, 20, 0); + EXPECT_FALSE(room_->ValidateObject(invalid_size)); +} + +TEST_F(RoomManipulationTest, MultipleOperations) { + // Add several objects + for (int i = 0; i < 5; i++) { + RoomObject obj(0x10 + i, i * 5, i * 6, i, 0); + ASSERT_TRUE(room_->AddObject(obj).ok()); + } + + EXPECT_EQ(room_->GetTileObjects().size(), 5); + + // Update middle object + RoomObject updated(0x99, 30, 35, 7, 1); + ASSERT_TRUE(room_->UpdateObject(2, updated).ok()); + + // Verify update + auto objects = room_->GetTileObjects(); + EXPECT_EQ(objects[2].id_, 0x99); + + // Remove first object + ASSERT_TRUE(room_->RemoveObject(0).ok()); + EXPECT_EQ(room_->GetTileObjects().size(), 4); + + // Verify first object is now what was second + EXPECT_EQ(room_->GetTileObjects()[0].id_, 0x11); +} + +TEST_F(RoomManipulationTest, LayerOrganization) { + // Add objects to different layers + RoomObject layer0_obj(0x10, 10, 10, 2, 0); + RoomObject layer1_obj(0x20, 20, 20, 3, 1); + RoomObject layer2_obj(0x30, 30, 30, 4, 2); + + room_->AddObject(layer0_obj); + room_->AddObject(layer1_obj); + room_->AddObject(layer2_obj); + + // Verify can find by layer + EXPECT_TRUE(room_->FindObjectAt(10, 10, 0).ok()); + EXPECT_TRUE(room_->FindObjectAt(20, 20, 1).ok()); + EXPECT_TRUE(room_->FindObjectAt(30, 30, 2).ok()); + + // Wrong layer should not find + EXPECT_FALSE(room_->FindObjectAt(10, 10, 1).ok()); +} + +} // namespace test +} // namespace zelda3 +} // namespace yaze +