1477 lines
47 KiB
Markdown
1477 lines
47 KiB
Markdown
# Gemini Task: Fix Dungeon Object Rendering
|
||
|
||
## Build Instructions
|
||
|
||
```bash
|
||
# Configure and build (use dedicated build_gemini directory)
|
||
./scripts/gemini_build.sh
|
||
|
||
# Or manually:
|
||
cmake --preset mac-gemini
|
||
cmake --build build_gemini --target yaze -j8
|
||
|
||
# Run the app to test
|
||
./build_gemini/Debug/yaze.app/Contents/MacOS/yaze --rom_file=zelda3.sfc --editor=Dungeon
|
||
|
||
# Run all stable tests (GTest executable)
|
||
./build_gemini/Debug/yaze_test_stable
|
||
|
||
# Run specific test suites with gtest_filter
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="*Room*"
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="*Dungeon*"
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="*ObjectDrawer*"
|
||
|
||
# List available tests
|
||
./build_gemini/Debug/yaze_test_stable --gtest_list_tests
|
||
```
|
||
|
||
---
|
||
|
||
## Executive Summary
|
||
|
||
**Root Cause**: The dungeon rendering system has TWO bugs:
|
||
1. **Missing 3BPP→4BPP conversion**: ROM data is copied raw without format conversion
|
||
2. **Wrong palette offset multiplier**: Uses `* 16` (4BPP) but should use `* 8` (3BPP)
|
||
|
||
**The Correct Fix**: Either:
|
||
- **Option A**: Convert 3BPP to 4BPP during buffer copy, then `* 16` is correct
|
||
- **Option B**: Keep raw 3BPP data, change multiplier back to `* 8`
|
||
|
||
ZScream uses Option A (full 4BPP conversion). This document provides the exact algorithm.
|
||
|
||
---
|
||
|
||
## Critical Bug Analysis
|
||
|
||
### Bug #1: Palette Offset Calculation (object_drawer.cc:911)
|
||
|
||
**Current Code (WRONG for 3BPP data):**
|
||
```cpp
|
||
uint8_t palette_offset = (tile_info.palette_ & 0x07) * 16;
|
||
```
|
||
|
||
**What ZScream Does (Reference Implementation):**
|
||
```csharp
|
||
// ZScreamDungeon/GraphicsManager.cs lines 1043-1044
|
||
gfx16Pointer[index + r ^ 1] = (byte)((pixel & 0x0F) + (tile.palette * 16));
|
||
gfx16Pointer[index + r] = (byte)(((pixel >> 4) & 0x0F) + (tile.palette * 16));
|
||
```
|
||
|
||
**Key Insight**: ZScream uses `* 16` because it CONVERTS the data to 4BPP first. Without that conversion, yaze should use `* 8`.
|
||
|
||
### Bug #2: Missing BPP Conversion (room.cc:228-295)
|
||
|
||
**Current Code (Copies raw 3BPP data):**
|
||
```cpp
|
||
void Room::CopyRoomGraphicsToBuffer() {
|
||
auto gfx_buffer_data = rom()->mutable_graphics_buffer();
|
||
int sheet_pos = 0;
|
||
for (int i = 0; i < 16; i++) {
|
||
int block_offset = blocks_[i] * kGfxBufferRoomOffset; // 2048 bytes/block
|
||
while (data < kGfxBufferRoomOffset) {
|
||
current_gfx16_[data + sheet_pos] = (*gfx_buffer_data)[data + block_offset];
|
||
data++;
|
||
}
|
||
sheet_pos += kGfxBufferRoomOffset;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Problem**: This copies raw bytes without any BPP format conversion!
|
||
|
||
---
|
||
|
||
## ZScream Reference Implementation
|
||
|
||
### Buffer Sizes (GraphicsManager.cs:20-95)
|
||
|
||
```csharp
|
||
// Graphics buffer: 32KB (128×512 pixels / 2 nibbles per byte)
|
||
currentgfx16Ptr = Marshal.AllocHGlobal((128 * 512) / 2) // 32,768 bytes
|
||
|
||
// Room backgrounds: 256KB each (512×512 pixels @ 8BPP)
|
||
roomBg1Ptr = Marshal.AllocHGlobal(512 * 512) // 262,144 bytes
|
||
roomBg2Ptr = Marshal.AllocHGlobal(512 * 512) // 262,144 bytes
|
||
```
|
||
|
||
### Sheet Classification (Constants.cs:20-21)
|
||
|
||
```csharp
|
||
Uncompressed3BPPSize = 0x0600 // 1536 bytes per 3BPP sheet (24 bytes/tile × 64 tiles)
|
||
UncompressedSheetSize = 0x0800 // 2048 bytes per 2BPP sheet
|
||
|
||
// 3BPP sheets: 0-112, 115-126, 127-217 (dungeon/overworld graphics)
|
||
// 2BPP sheets: 113-114, 218-222 (fonts, UI elements)
|
||
```
|
||
|
||
### 3BPP to 4BPP Conversion Algorithm (GraphicsManager.cs:379-400)
|
||
|
||
**This is the exact algorithm yaze needs to implement:**
|
||
|
||
```csharp
|
||
// For each 3BPP sheet:
|
||
for (int j = 0; j < 4; j++) { // 4 rows of tiles
|
||
for (int i = 0; i < 16; i++) { // 16 tiles per row
|
||
for (int y = 0; y < 8; y++) { // 8 pixel rows per tile
|
||
// Read 3 bitplanes from ROM (SNES planar format)
|
||
byte lineBits0 = data[(y * 2) + (i * 24) + (j * 384) + sheetPosition];
|
||
byte lineBits1 = data[(y * 2) + (i * 24) + (j * 384) + 1 + sheetPosition];
|
||
byte lineBits2 = data[(y) + (i * 24) + (j * 384) + 16 + sheetPosition];
|
||
|
||
// For each pair of pixels (4 nibbles = 4 pixels, but processed as 2 pairs)
|
||
for (int x = 0; x < 4; x++) {
|
||
byte pixdata = 0;
|
||
byte pixdata2 = 0;
|
||
|
||
// Extract pixel 1 color (bits from all 3 planes)
|
||
if ((lineBits0 & mask[x * 2]) == mask[x * 2]) pixdata += 1;
|
||
if ((lineBits1 & mask[x * 2]) == mask[x * 2]) pixdata += 2;
|
||
if ((lineBits2 & mask[x * 2]) == mask[x * 2]) pixdata += 4;
|
||
|
||
// Extract pixel 2 color
|
||
if ((lineBits0 & mask[x * 2 + 1]) == mask[x * 2 + 1]) pixdata2 += 1;
|
||
if ((lineBits1 & mask[x * 2 + 1]) == mask[x * 2 + 1]) pixdata2 += 2;
|
||
if ((lineBits2 & mask[x * 2 + 1]) == mask[x * 2 + 1]) pixdata2 += 4;
|
||
|
||
// Pack into 4BPP format (2 pixels per byte, 4 bits each)
|
||
int destIndex = (y * 64) + x + (i * 4) + (j * 512) + (s * 2048);
|
||
newData[destIndex] = (byte)((pixdata << 4) | pixdata2);
|
||
}
|
||
}
|
||
}
|
||
sheetPosition += 0x0600; // Advance by 1536 bytes per 3BPP sheet
|
||
}
|
||
|
||
// Bit extraction mask
|
||
byte[] mask = { 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 };
|
||
```
|
||
|
||
### Tile Drawing to Buffer (GraphicsManager.cs:140-164)
|
||
|
||
```csharp
|
||
public static void DrawTileToBuffer(Tile tile, byte* canvas, byte* tiledata) {
|
||
// Calculate tile position in graphics buffer
|
||
int tx = (tile.ID / 16 * 512) + ((tile.ID & 0xF) * 4);
|
||
byte palnibble = (byte)(tile.Palette << 4); // Palette offset (0, 16, 32, ...)
|
||
byte r = tile.HFlipByte;
|
||
|
||
for (int yl = 0; yl < 512; yl += 64) { // Each line is 64 bytes apart
|
||
int my = (tile.VFlip ? 448 - yl : yl);
|
||
|
||
for (int xl = 0; xl < 4; xl++) { // 4 nibble-pairs per tile row
|
||
int mx = 2 * (tile.HFlip ? 3 - xl : xl);
|
||
byte pixel = tiledata[tx + yl + xl];
|
||
|
||
// Unpack nibbles and apply palette offset
|
||
canvas[mx + my + r ^ 1] = (byte)((pixel & 0x0F) | palnibble);
|
||
canvas[mx + my + r] = (byte)((pixel >> 4) | palnibble);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## SNES Disassembly Reference
|
||
|
||
### Do3bppToWRAM4bpp Algorithm (bank_00.asm:9759-9892)
|
||
|
||
**WRAM Addresses:**
|
||
- `$7E9000-$7E91FF`: Primary 4BPP conversion buffer (512 bytes)
|
||
- Planes 0-3: `$7E9000 + offset`
|
||
- Plane 4 (palette): `$7E9010 + offset`
|
||
|
||
**Byte Layout:**
|
||
```
|
||
3BPP Format (24 bytes per tile):
|
||
Bytes 0-1: Row 0, Planes 0-1 (interleaved)
|
||
Bytes 2-3: Row 1, Planes 0-1
|
||
...
|
||
Bytes 16: Row 0, Plane 2
|
||
Bytes 17: Row 1, Plane 2
|
||
...
|
||
|
||
4BPP Format (32 bytes per tile):
|
||
Bytes 0-15: Rows 0-7, Planes 0-1 (2 bytes per row)
|
||
Bytes 16-31: Rows 0-7, Planes 2-3 (2 bytes per row)
|
||
```
|
||
|
||
**Conversion Pseudocode:**
|
||
```c
|
||
void Convert3BppTo4Bpp(uint8_t* source_3bpp, uint8_t* wram_dest, int num_tiles) {
|
||
for (int tile = 0; tile < num_tiles; tile++) {
|
||
uint8_t* palette_offset = source_3bpp + 0x10;
|
||
|
||
for (int word = 0; word < 4; word++) {
|
||
// Read 2 bytes from 3BPP source
|
||
wram_dest[0] = source_3bpp[0];
|
||
source_3bpp += 2;
|
||
|
||
// Read palette plane byte
|
||
wram_dest[0x10] = palette_offset[0] & 0xFF;
|
||
palette_offset += 1;
|
||
|
||
wram_dest += 2;
|
||
}
|
||
wram_dest += 0x10; // 32 bytes per 4BPP tile
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Existing yaze Conversion Functions
|
||
|
||
### Available in src/app/gfx/types/snes_tile.cc
|
||
|
||
**Recommended Function to Use:**
|
||
```cpp
|
||
// Line 117-129: Direct BPP conversion at tile level
|
||
std::vector<uint8_t> ConvertBpp(std::span<uint8_t> tiles,
|
||
uint32_t from_bpp,
|
||
uint32_t to_bpp);
|
||
|
||
// Usage:
|
||
std::vector<uint8_t> converted = gfx::ConvertBpp(tiles_data, 3, 4);
|
||
```
|
||
|
||
**Alternative - Sheet Level:**
|
||
```cpp
|
||
// Line 131+: Convert full graphics sheet
|
||
auto sheet_8bpp = gfx::SnesTo8bppSheet(data, 3); // 3 = source BPP
|
||
```
|
||
|
||
### WARNING: BppFormatManager Has a Bug
|
||
|
||
**In src/app/gfx/util/bpp_format_manager.cc:314-318:**
|
||
```cpp
|
||
std::vector<uint8_t> BppFormatManager::Convert3BppTo8Bpp(...) {
|
||
// BUG: Delegates to 4BPP conversion without actual 3BPP handling!
|
||
return Convert4BppTo8Bpp(data, width, height);
|
||
}
|
||
```
|
||
|
||
**Do NOT use BppFormatManager for 3BPP conversion - use snes_tile.cc functions instead.**
|
||
|
||
---
|
||
|
||
## Implementation Options
|
||
|
||
### Option A: Full 4BPP Conversion (Recommended - Matches ZScream)
|
||
|
||
This is the recommended approach because it matches ZScream's working implementation and provides the clearest separation between ROM format (3BPP) and rendering format (4BPP).
|
||
|
||
---
|
||
|
||
#### Step 1: Replace `Room::CopyRoomGraphicsToBuffer()` in room.cc
|
||
|
||
**File**: `src/zelda3/dungeon/room.cc`
|
||
**Lines to replace**: 228-295 (the entire `CopyRoomGraphicsToBuffer()` function)
|
||
|
||
**Replace the ENTIRE function with this code:**
|
||
|
||
```cpp
|
||
void Room::CopyRoomGraphicsToBuffer() {
|
||
if (!rom_ || !rom_->is_loaded()) {
|
||
printf("[CopyRoomGraphicsToBuffer] ROM not loaded\n");
|
||
return;
|
||
}
|
||
|
||
auto gfx_buffer_data = rom()->mutable_graphics_buffer();
|
||
if (!gfx_buffer_data || gfx_buffer_data->empty()) {
|
||
printf("[CopyRoomGraphicsToBuffer] Graphics buffer is null or empty\n");
|
||
return;
|
||
}
|
||
|
||
printf("[CopyRoomGraphicsToBuffer] Room %d: Converting 3BPP to 4BPP\n",
|
||
room_id_);
|
||
|
||
// Bit extraction mask (MSB to LSB)
|
||
static const uint8_t kBitMask[8] = {
|
||
0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01
|
||
};
|
||
|
||
// Clear destination buffer
|
||
std::fill(current_gfx16_.begin(), current_gfx16_.end(), 0);
|
||
|
||
int bytes_converted = 0;
|
||
int dest_pos = 0;
|
||
|
||
// Process each of the 16 graphics blocks
|
||
for (int block = 0; block < 16; block++) {
|
||
// Validate block index
|
||
if (blocks_[block] < 0 || blocks_[block] > 255) {
|
||
// Skip invalid blocks, but advance destination position
|
||
dest_pos += 2048; // 64 tiles * 32 bytes per 4BPP tile
|
||
continue;
|
||
}
|
||
|
||
// Source offset in ROM graphics buffer (3BPP format)
|
||
// Each 3BPP sheet is 1536 bytes (64 tiles * 24 bytes/tile)
|
||
int src_sheet_offset = blocks_[block] * 1536;
|
||
|
||
// Validate source bounds
|
||
if (src_sheet_offset < 0 ||
|
||
src_sheet_offset + 1536 > static_cast<int>(gfx_buffer_data->size())) {
|
||
dest_pos += 2048;
|
||
continue;
|
||
}
|
||
|
||
// Convert 64 tiles per block (arranged as 16x4 grid in sheet)
|
||
for (int tile_row = 0; tile_row < 4; tile_row++) { // 4 rows of tiles
|
||
for (int tile_col = 0; tile_col < 16; tile_col++) { // 16 tiles per row
|
||
int tile_index = tile_row * 16 + tile_col;
|
||
|
||
// Source offset for this tile in 3BPP format
|
||
// ZScream formula: (i * 24) + (j * 384) where i=tile_col, j=tile_row
|
||
int tile_src = src_sheet_offset + (tile_col * 24) + (tile_row * 384);
|
||
|
||
// Convert 8 pixel rows
|
||
for (int row = 0; row < 8; row++) {
|
||
// Read 3 bitplanes from SNES planar format
|
||
// Planes 0-1 are interleaved at bytes 0-15
|
||
// Plane 2 is at bytes 16-23
|
||
uint8_t plane0 = (*gfx_buffer_data)[tile_src + (row * 2)];
|
||
uint8_t plane1 = (*gfx_buffer_data)[tile_src + (row * 2) + 1];
|
||
uint8_t plane2 = (*gfx_buffer_data)[tile_src + 16 + row];
|
||
|
||
// Convert 8 pixels to 4 nibble-pairs (4BPP packed format)
|
||
for (int nibble_pair = 0; nibble_pair < 4; nibble_pair++) {
|
||
uint8_t pix1 = 0; // First pixel of pair
|
||
uint8_t pix2 = 0; // Second pixel of pair
|
||
|
||
// Extract first pixel color from 3 bitplanes
|
||
int bit_index1 = nibble_pair * 2;
|
||
if (plane0 & kBitMask[bit_index1]) pix1 |= 1;
|
||
if (plane1 & kBitMask[bit_index1]) pix1 |= 2;
|
||
if (plane2 & kBitMask[bit_index1]) pix1 |= 4;
|
||
|
||
// Extract second pixel color from 3 bitplanes
|
||
int bit_index2 = nibble_pair * 2 + 1;
|
||
if (plane0 & kBitMask[bit_index2]) pix2 |= 1;
|
||
if (plane1 & kBitMask[bit_index2]) pix2 |= 2;
|
||
if (plane2 & kBitMask[bit_index2]) pix2 |= 4;
|
||
|
||
// Pack into 4BPP format: high nibble = pix1, low nibble = pix2
|
||
// Destination uses ZScream's layout:
|
||
// (row * 64) + nibble_pair + (tile_col * 4) + (tile_row * 512) + (block * 2048)
|
||
int dest_index = (row * 64) + nibble_pair + (tile_col * 4) +
|
||
(tile_row * 512) + (block * 2048);
|
||
|
||
if (dest_index >= 0 &&
|
||
dest_index < static_cast<int>(current_gfx16_.size())) {
|
||
current_gfx16_[dest_index] = (pix1 << 4) | pix2;
|
||
if (pix1 != 0 || pix2 != 0) bytes_converted++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
printf("[CopyRoomGraphicsToBuffer] Room %d: Converted %d non-zero pixel pairs\n",
|
||
room_id_, bytes_converted);
|
||
|
||
LoadAnimatedGraphics();
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
#### Step 2: Replace `ObjectDrawer::DrawTileToBitmap()` in object_drawer.cc
|
||
|
||
**File**: `src/zelda3/dungeon/object_drawer.cc`
|
||
**Lines to replace**: 890-971 (the entire `DrawTileToBitmap()` function)
|
||
|
||
**Replace the ENTIRE function with this code:**
|
||
|
||
```cpp
|
||
void ObjectDrawer::DrawTileToBitmap(gfx::Bitmap& bitmap,
|
||
const gfx::TileInfo& tile_info, int pixel_x,
|
||
int pixel_y, const uint8_t* tiledata) {
|
||
// Draw an 8x8 tile directly to bitmap at pixel coordinates
|
||
// Graphics data is in 4BPP packed format (2 pixels per byte)
|
||
if (!tiledata) return;
|
||
|
||
// DEBUG: Check if bitmap is valid
|
||
if (!bitmap.is_active() || bitmap.width() == 0 || bitmap.height() == 0) {
|
||
LOG_DEBUG("ObjectDrawer", "ERROR: Invalid bitmap - active=%d, size=%dx%d",
|
||
bitmap.is_active(), bitmap.width(), bitmap.height());
|
||
return;
|
||
}
|
||
|
||
// Calculate tile position in 4BPP graphics buffer
|
||
// Layout: 16 tiles per row, each tile is 4 bytes wide (8 pixels / 2)
|
||
// Row stride: 64 bytes (16 tiles * 4 bytes)
|
||
int tile_col = tile_info.id_ % 16;
|
||
int tile_row = tile_info.id_ / 16;
|
||
int tile_base_x = tile_col * 4; // 4 bytes per tile horizontally
|
||
int tile_base_y = tile_row * 512; // 512 bytes per tile row (8 rows * 64 bytes)
|
||
|
||
// Palette offset: 4BPP uses 16 colors per palette
|
||
uint8_t palette_offset = (tile_info.palette_ & 0x07) * 16;
|
||
|
||
// DEBUG: Log tile info for first few tiles
|
||
static int debug_tile_count = 0;
|
||
if (debug_tile_count < 5) {
|
||
printf("[ObjectDrawer] DrawTile4BPP: id=0x%03X pos=(%d,%d) base=(%d,%d) pal=%d\n",
|
||
tile_info.id_, pixel_x, pixel_y, tile_base_x, tile_base_y,
|
||
tile_info.palette_);
|
||
debug_tile_count++;
|
||
}
|
||
|
||
// Draw 8x8 pixels (processing pixel pairs from packed bytes)
|
||
int pixels_written = 0;
|
||
int pixels_transparent = 0;
|
||
|
||
for (int py = 0; py < 8; py++) {
|
||
// Source row with vertical mirroring
|
||
int src_row = tile_info.vertical_mirror_ ? (7 - py) : py;
|
||
|
||
for (int nibble_pair = 0; nibble_pair < 4; nibble_pair++) {
|
||
// Source column with horizontal mirroring
|
||
int src_col = tile_info.horizontal_mirror_ ? (3 - nibble_pair) : nibble_pair;
|
||
|
||
// Calculate source index in 4BPP buffer
|
||
// ZScream layout: (row * 64) + nibble_pair + tile_base
|
||
int src_index = (src_row * 64) + src_col + tile_base_x + tile_base_y;
|
||
uint8_t packed_byte = tiledata[src_index];
|
||
|
||
// Unpack the two pixels from nibbles
|
||
uint8_t pix1, pix2;
|
||
if (tile_info.horizontal_mirror_) {
|
||
// When mirrored, swap nibble order
|
||
pix1 = packed_byte & 0x0F; // Low nibble first
|
||
pix2 = (packed_byte >> 4) & 0x0F; // High nibble second
|
||
} else {
|
||
pix1 = (packed_byte >> 4) & 0x0F; // High nibble first
|
||
pix2 = packed_byte & 0x0F; // Low nibble second
|
||
}
|
||
|
||
// Calculate destination pixel positions
|
||
int px1 = nibble_pair * 2;
|
||
int px2 = nibble_pair * 2 + 1;
|
||
|
||
// Write first pixel
|
||
if (pix1 != 0) {
|
||
uint8_t final_color = pix1 + palette_offset;
|
||
int dest_x = pixel_x + px1;
|
||
int dest_y = pixel_y + py;
|
||
|
||
if (dest_x >= 0 && dest_x < bitmap.width() &&
|
||
dest_y >= 0 && dest_y < bitmap.height()) {
|
||
int dest_index = dest_y * bitmap.width() + dest_x;
|
||
if (dest_index >= 0 &&
|
||
dest_index < static_cast<int>(bitmap.mutable_data().size())) {
|
||
bitmap.mutable_data()[dest_index] = final_color;
|
||
pixels_written++;
|
||
}
|
||
}
|
||
} else {
|
||
pixels_transparent++;
|
||
}
|
||
|
||
// Write second pixel
|
||
if (pix2 != 0) {
|
||
uint8_t final_color = pix2 + palette_offset;
|
||
int dest_x = pixel_x + px2;
|
||
int dest_y = pixel_y + py;
|
||
|
||
if (dest_x >= 0 && dest_x < bitmap.width() &&
|
||
dest_y >= 0 && dest_y < bitmap.height()) {
|
||
int dest_index = dest_y * bitmap.width() + dest_x;
|
||
if (dest_index >= 0 &&
|
||
dest_index < static_cast<int>(bitmap.mutable_data().size())) {
|
||
bitmap.mutable_data()[dest_index] = final_color;
|
||
pixels_written++;
|
||
}
|
||
}
|
||
} else {
|
||
pixels_transparent++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Mark bitmap as modified if we wrote any pixels
|
||
if (pixels_written > 0) {
|
||
bitmap.set_modified(true);
|
||
}
|
||
|
||
// DEBUG: Log pixel writing stats for first few tiles
|
||
if (debug_tile_count <= 5) {
|
||
printf("[ObjectDrawer] Tile 0x%03X: wrote %d pixels, %d transparent\n",
|
||
tile_info.id_, pixels_written, pixels_transparent);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
#### Step 3: Verify Constants in room.h
|
||
|
||
**File**: `src/zelda3/dungeon/room.h`
|
||
**Line 412**: Ensure buffer size is correct
|
||
|
||
```cpp
|
||
std::array<uint8_t, 0x8000> current_gfx16_; // 32KB = 16 blocks * 2048 bytes
|
||
```
|
||
|
||
This is CORRECT. 32KB holds 16 blocks of 64 tiles each in 4BPP format:
|
||
- 16 blocks × 64 tiles × 32 bytes/tile = 32,768 bytes = 0x8000
|
||
|
||
---
|
||
|
||
### Option B: Keep 3BPP, Fix Palette Offset (Simpler but Less Correct)
|
||
|
||
**Step 1: Change palette offset back to `* 8` in object_drawer.cc:911**
|
||
```cpp
|
||
uint8_t palette_offset = (tile_info.palette_ & 0x07) * 8; // 8 colors per 3BPP palette
|
||
```
|
||
|
||
**Step 2: Ensure graphics buffer is already converted to 8BPP indexed**
|
||
|
||
Check if `rom()->mutable_graphics_buffer()` already contains 8BPP indexed data (it should, based on ROM loading code).
|
||
|
||
**Note**: This option is simpler but may not render correctly if the graphics buffer format doesn't match expectations. Option A is recommended.
|
||
|
||
---
|
||
|
||
## Testing Strategy
|
||
|
||
### Test Infrastructure Notes
|
||
|
||
> **IMPORTANT**: The test utility functions have been updated to properly initialize the full editor system. If you're writing new GUI tests, use the provided test utilities:
|
||
|
||
**Test Utilities** (defined in `test/test_utils.cc`):
|
||
|
||
| Function | Purpose |
|
||
|----------|---------|
|
||
| `gui::LoadRomInTest(ctx, rom_path)` | Loads ROM and initializes ALL editors (calls full `LoadAssets()` flow) |
|
||
| `gui::OpenEditorInTest(ctx, "Dungeon")` | Opens an editor via the **View** menu (NOT "Editors" menu!) |
|
||
|
||
**Menu Structure Note**: Editors are under the `View` menu, not `Editors`:
|
||
- Correct: `ctx->MenuClick("View/Dungeon")`
|
||
- Incorrect: `ctx->MenuClick("Editors/Dungeon")` ← This will fail!
|
||
|
||
**Full Initialization Flow**: `LoadRomInTest()` calls `Controller::LoadRomForTesting()` which:
|
||
1. Calls `EditorManager::OpenRomOrProject()`
|
||
2. Finds/creates a session for the ROM
|
||
3. Calls `ConfigureEditorDependencies()`
|
||
4. Calls `LoadAssets()` which:
|
||
- Initializes all editors (registers their cards)
|
||
- Loads graphics data into `gfx::Arena`
|
||
- Loads dungeon/overworld/sprite data from ROM
|
||
5. Updates UI state (hides welcome screen, shows editor selection)
|
||
|
||
Without this full flow, editors will appear as empty windows.
|
||
|
||
---
|
||
|
||
### Quick Build & Test Cycle
|
||
|
||
```bash
|
||
# 1. Build the project
|
||
cmake --build build_gemini --target yaze -j8
|
||
|
||
# 2. Run unit tests to verify no regressions
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="*Dungeon*:*Room*:*ObjectDrawer*"
|
||
|
||
# 3. Visual test with the app
|
||
./build_gemini/Debug/yaze.app/Contents/MacOS/yaze --rom_file=zelda3.sfc --editor=Dungeon
|
||
|
||
# 4. Run specific palette test to verify fix
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="*PaletteOffset*"
|
||
```
|
||
|
||
---
|
||
|
||
### Unit Tests to Run After Implementation
|
||
|
||
**Existing tests that MUST pass:**
|
||
|
||
```bash
|
||
# Core dungeon tests
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="DungeonObjectRenderingTests.*"
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="DungeonPaletteTest.*"
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="*Room*"
|
||
|
||
# All dungeon-related tests
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="*Dungeon*:*Object*:*Room*"
|
||
```
|
||
|
||
**Key test files:**
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `test/integration/zelda3/dungeon_palette_test.cc` | Validates palette offset calculation |
|
||
| `test/integration/zelda3/dungeon_object_rendering_tests.cc` | Tests ObjectDrawer with BackgroundBuffer |
|
||
| `test/integration/zelda3/dungeon_room_test.cc` | Tests Room loading and graphics |
|
||
| `test/e2e/dungeon_object_drawing_test.cc` | End-to-end drawing verification |
|
||
|
||
---
|
||
|
||
### New Test to Add: 3BPP to 4BPP Conversion Test
|
||
|
||
**Create file**: `test/unit/zelda3/dungeon/bpp_conversion_test.cc`
|
||
|
||
```cpp
|
||
#include <gtest/gtest.h>
|
||
#include <array>
|
||
#include <cstdint>
|
||
|
||
namespace yaze {
|
||
namespace zelda3 {
|
||
namespace test {
|
||
|
||
class Bpp3To4ConversionTest : public ::testing::Test {
|
||
protected:
|
||
// Simulates the conversion algorithm
|
||
static const uint8_t kBitMask[8];
|
||
|
||
void Convert3BppTo4Bpp(const uint8_t* src_3bpp, uint8_t* dest_4bpp) {
|
||
// Convert one 8x8 tile from 3BPP (24 bytes) to 4BPP packed (32 bytes)
|
||
for (int row = 0; row < 8; row++) {
|
||
uint8_t plane0 = src_3bpp[row * 2];
|
||
uint8_t plane1 = src_3bpp[row * 2 + 1];
|
||
uint8_t plane2 = src_3bpp[16 + row];
|
||
|
||
for (int nibble_pair = 0; nibble_pair < 4; nibble_pair++) {
|
||
uint8_t pix1 = 0, pix2 = 0;
|
||
|
||
int bit1 = nibble_pair * 2;
|
||
int bit2 = nibble_pair * 2 + 1;
|
||
|
||
if (plane0 & kBitMask[bit1]) pix1 |= 1;
|
||
if (plane1 & kBitMask[bit1]) pix1 |= 2;
|
||
if (plane2 & kBitMask[bit1]) pix1 |= 4;
|
||
|
||
if (plane0 & kBitMask[bit2]) pix2 |= 1;
|
||
if (plane1 & kBitMask[bit2]) pix2 |= 2;
|
||
if (plane2 & kBitMask[bit2]) pix2 |= 4;
|
||
|
||
dest_4bpp[row * 4 + nibble_pair] = (pix1 << 4) | pix2;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const uint8_t Bpp3To4ConversionTest::kBitMask[8] = {
|
||
0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01
|
||
};
|
||
|
||
// Test that all-zero 3BPP produces all-zero 4BPP
|
||
TEST_F(Bpp3To4ConversionTest, ZeroInputProducesZeroOutput) {
|
||
std::array<uint8_t, 24> src_3bpp = {}; // All zeros
|
||
std::array<uint8_t, 32> dest_4bpp = {};
|
||
|
||
Convert3BppTo4Bpp(src_3bpp.data(), dest_4bpp.data());
|
||
|
||
for (int i = 0; i < 32; i++) {
|
||
EXPECT_EQ(dest_4bpp[i], 0) << "Byte " << i << " should be zero";
|
||
}
|
||
}
|
||
|
||
// Test that all-ones in plane0 produces correct pattern
|
||
TEST_F(Bpp3To4ConversionTest, Plane0OnlyProducesColorIndex1) {
|
||
std::array<uint8_t, 24> src_3bpp = {};
|
||
// Set plane0 to all 1s for first row
|
||
src_3bpp[0] = 0xFF; // Row 0, plane 0
|
||
|
||
std::array<uint8_t, 32> dest_4bpp = {};
|
||
Convert3BppTo4Bpp(src_3bpp.data(), dest_4bpp.data());
|
||
|
||
// First row should have color index 1 for all pixels
|
||
// Packed: (1 << 4) | 1 = 0x11
|
||
EXPECT_EQ(dest_4bpp[0], 0x11);
|
||
EXPECT_EQ(dest_4bpp[1], 0x11);
|
||
EXPECT_EQ(dest_4bpp[2], 0x11);
|
||
EXPECT_EQ(dest_4bpp[3], 0x11);
|
||
}
|
||
|
||
// Test that all planes set produces color index 7
|
||
TEST_F(Bpp3To4ConversionTest, AllPlanesProducesColorIndex7) {
|
||
std::array<uint8_t, 24> src_3bpp = {};
|
||
// Set all planes for first row
|
||
src_3bpp[0] = 0xFF; // Row 0, plane 0
|
||
src_3bpp[1] = 0xFF; // Row 0, plane 1
|
||
src_3bpp[16] = 0xFF; // Row 0, plane 2
|
||
|
||
std::array<uint8_t, 32> dest_4bpp = {};
|
||
Convert3BppTo4Bpp(src_3bpp.data(), dest_4bpp.data());
|
||
|
||
// First row should have color index 7 for all pixels
|
||
// Packed: (7 << 4) | 7 = 0x77
|
||
EXPECT_EQ(dest_4bpp[0], 0x77);
|
||
EXPECT_EQ(dest_4bpp[1], 0x77);
|
||
EXPECT_EQ(dest_4bpp[2], 0x77);
|
||
EXPECT_EQ(dest_4bpp[3], 0x77);
|
||
}
|
||
|
||
// Test alternating pixel pattern
|
||
TEST_F(Bpp3To4ConversionTest, AlternatingPixelsCorrectlyPacked) {
|
||
std::array<uint8_t, 24> src_3bpp = {};
|
||
// Alternate: 0xAA = 10101010 (pixels 0,2,4,6 set)
|
||
src_3bpp[0] = 0xAA; // Plane 0 only
|
||
|
||
std::array<uint8_t, 32> dest_4bpp = {};
|
||
Convert3BppTo4Bpp(src_3bpp.data(), dest_4bpp.data());
|
||
|
||
// Pixels 0,2,4,6 have color 1; pixels 1,3,5,7 have color 0
|
||
// Packed: (1 << 4) | 0 = 0x10
|
||
EXPECT_EQ(dest_4bpp[0], 0x10);
|
||
EXPECT_EQ(dest_4bpp[1], 0x10);
|
||
EXPECT_EQ(dest_4bpp[2], 0x10);
|
||
EXPECT_EQ(dest_4bpp[3], 0x10);
|
||
}
|
||
|
||
// Test output buffer size matches expected 4BPP format
|
||
TEST_F(Bpp3To4ConversionTest, OutputSizeIs32BytesPerTile) {
|
||
// 8 rows * 4 bytes per row = 32 bytes
|
||
// Each row has 8 pixels, 2 pixels per byte = 4 bytes per row
|
||
constexpr int kExpectedOutputSize = 32;
|
||
std::array<uint8_t, 24> src_3bpp = {};
|
||
std::array<uint8_t, kExpectedOutputSize> dest_4bpp = {};
|
||
|
||
Convert3BppTo4Bpp(src_3bpp.data(), dest_4bpp.data());
|
||
// If we got here without crash, size is correct
|
||
SUCCEED();
|
||
}
|
||
|
||
} // namespace test
|
||
} // namespace zelda3
|
||
} // namespace yaze
|
||
```
|
||
|
||
**Add to test/CMakeLists.txt:**
|
||
```cmake
|
||
# Under the stable test sources, add:
|
||
test/unit/zelda3/dungeon/bpp_conversion_test.cc
|
||
```
|
||
|
||
---
|
||
|
||
### Update Existing Palette Test
|
||
|
||
**File**: `test/integration/zelda3/dungeon_palette_test.cc`
|
||
|
||
**Add this test to verify 4BPP conversion works end-to-end:**
|
||
|
||
```cpp
|
||
TEST_F(DungeonPaletteTest, PaletteOffsetWorksWithConvertedData) {
|
||
gfx::Bitmap bitmap(8, 8);
|
||
bitmap.Create(8, 8, 8, std::vector<uint8_t>(64, 0));
|
||
|
||
// Create 4BPP packed tile data (simulating converted buffer)
|
||
// Layout: 512 bytes per tile row, 4 bytes per tile
|
||
// For tile 0: base_x=0, base_y=0
|
||
std::vector<uint8_t> tiledata(512 * 8, 0);
|
||
|
||
// Set pixel pair at row 0: high nibble = 3, low nibble = 5
|
||
tiledata[0] = 0x35;
|
||
|
||
gfx::TileInfo tile_info;
|
||
tile_info.id_ = 0;
|
||
tile_info.palette_ = 2; // Palette 2 → offset 32
|
||
tile_info.horizontal_mirror_ = false;
|
||
tile_info.vertical_mirror_ = false;
|
||
tile_info.over_ = false;
|
||
|
||
drawer_->DrawTileToBitmap(bitmap, tile_info, 0, 0, tiledata.data());
|
||
|
||
const auto& data = bitmap.vector();
|
||
// Pixel 0 (high nibble 3) + offset 32 = 35
|
||
EXPECT_EQ(data[0], 35);
|
||
// Pixel 1 (low nibble 5) + offset 32 = 37
|
||
EXPECT_EQ(data[1], 37);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Visual Verification Checklist
|
||
|
||
After implementing Option A, manually verify these scenarios:
|
||
|
||
**1. Open Room 0 (Sanctuary Interior)**
|
||
```bash
|
||
./build_gemini/Debug/yaze.app/Contents/MacOS/yaze --rom_file=zelda3.sfc --editor=Dungeon --cards="Room 0"
|
||
```
|
||
- [ ] Floor tiles render with correct brown/gray colors
|
||
- [ ] Walls have proper shading gradients
|
||
- [ ] No "rainbow" or garbled color patterns
|
||
- [ ] Tiles align properly (no 1-pixel shifts)
|
||
|
||
**2. Open Room 1 (Hyrule Castle Entrance)**
|
||
- [ ] Castle wall patterns are recognizable
|
||
- [ ] Door frames render correctly
|
||
- [ ] Torch sconces have correct coloring
|
||
|
||
**3. Open Room 263 (Ganon's Tower)**
|
||
- [ ] Complex tile patterns render correctly
|
||
- [ ] Multiple palette usage is visible
|
||
- [ ] No missing or black tiles
|
||
|
||
**4. Check All Palettes (0-7)**
|
||
- Open any room and modify object palette values
|
||
- [ ] Palette 0: First 16 colors work
|
||
- [ ] Palette 7: Last palette range (colors 112-127) works
|
||
- [ ] No overflow into adjacent palette ranges
|
||
|
||
---
|
||
|
||
### Debug Output Verification
|
||
|
||
When running with the fix, you should see console output like:
|
||
|
||
```
|
||
[CopyRoomGraphicsToBuffer] Room 0: Converting 3BPP to 4BPP
|
||
[CopyRoomGraphicsToBuffer] Room 0: Converted 12543 non-zero pixel pairs
|
||
[ObjectDrawer] DrawTile4BPP: id=0x010 pos=(40,40) base=(0,512) pal=2
|
||
[ObjectDrawer] Tile 0x010: wrote 42 pixels, 22 transparent
|
||
```
|
||
|
||
**Good signs:**
|
||
- "Converting 3BPP to 4BPP" message appears
|
||
- Non-zero pixel pairs > 0 (typically 5000-15000 per room)
|
||
- Tile positions (`base=`) show reasonable values
|
||
- Pixels written > 0
|
||
|
||
**Bad signs:**
|
||
- "Converted 0 non-zero pixel pairs" → Source data not found
|
||
- All tiles show "wrote 0 pixels" → Addressing formula wrong
|
||
- Crash or segfault → Buffer bounds issue
|
||
|
||
---
|
||
|
||
## Quick Verification Test (Inline Debug)
|
||
|
||
**Add this debug code temporarily to verify data format:**
|
||
|
||
```cpp
|
||
// In CopyRoomGraphicsToBuffer(), add after the conversion loop:
|
||
printf("=== 4BPP Conversion Debug ===\n");
|
||
printf("First 32 bytes of converted buffer:\n");
|
||
for (int i = 0; i < 32; i++) {
|
||
printf("%02X ", current_gfx16_[i]);
|
||
if ((i + 1) % 16 == 0) printf("\n");
|
||
}
|
||
printf("\nExpected: Mixed nibbles (values like 00, 11, 22, 35, 77, etc.)\n");
|
||
printf("If all zeros: Conversion failed or source data missing\n");
|
||
printf("If values > 0x77: Wrong addressing\n");
|
||
```
|
||
|
||
---
|
||
|
||
## File Modification Summary
|
||
|
||
| File | Line | Change |
|
||
|------|------|--------|
|
||
| `src/zelda3/dungeon/room.cc` | 228-295 | Add 3BPP→4BPP conversion in `CopyRoomGraphicsToBuffer()` |
|
||
| `src/zelda3/dungeon/object_drawer.cc` | 911 | Keep `* 16` if converting, or change to `* 8` if not |
|
||
| `src/zelda3/dungeon/object_drawer.cc` | 935 | Update buffer addressing formula |
|
||
| `src/zelda3/dungeon/room.h` | 412 | Keep `0x8000` buffer size (32KB is correct) |
|
||
|
||
---
|
||
|
||
## Success Criteria
|
||
|
||
1. Dungeon objects render with correct colors (not garbled/shifted)
|
||
2. Object shapes are correct (proper tile boundaries)
|
||
3. All 296 rooms load without graphical corruption
|
||
4. No performance regression (rooms should render in <100ms)
|
||
5. Palette sub-indices 0-7 map to correct colors in dungeon palette
|
||
|
||
---
|
||
|
||
## Useful Debug Commands
|
||
|
||
```bash
|
||
# Run with debug logging
|
||
./build_gemini/Debug/yaze.app/Contents/MacOS/yaze --debug --log_file=debug.log --rom_file=zelda3.sfc --editor=Dungeon
|
||
|
||
# Open specific room for testing
|
||
./build_gemini/Debug/yaze.app/Contents/MacOS/yaze --rom_file=zelda3.sfc --editor=Dungeon --cards="Room 0"
|
||
|
||
# Run specific dungeon-related tests
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="*Room*:*Dungeon*:*Object*"
|
||
|
||
# Run tests with verbose output
|
||
./build_gemini/Debug/yaze_test_stable --gtest_filter="*Room*" --gtest_also_run_disabled_tests
|
||
```
|
||
|
||
---
|
||
|
||
## Troubleshooting Guide
|
||
|
||
### Issue: All tiles render as solid color or black
|
||
|
||
**Cause**: Source graphics buffer offset is wrong (reading zeros or wrong data)
|
||
|
||
**Debug Steps:**
|
||
1. Add debug print in `CopyRoomGraphicsToBuffer()`:
|
||
```cpp
|
||
printf("Block %d: index=%d, src_offset=%d\n", block, blocks_[block], src_sheet_offset);
|
||
```
|
||
2. Check that `blocks_[block]` values are in range 0-222
|
||
3. Verify `src_sheet_offset` doesn't exceed graphics buffer size
|
||
|
||
**Fix**: The source offset calculation may need adjustment. Check if ROM graphics buffer uses different sheet sizes (some may be 2048 bytes instead of 1536).
|
||
|
||
---
|
||
|
||
### Issue: Colors are wrong but shapes are correct
|
||
|
||
**Cause**: Palette offset calculation mismatch
|
||
|
||
**Debug Steps:**
|
||
1. Verify palette offset in `DrawTileToBitmap()`:
|
||
```cpp
|
||
printf("Palette %d -> offset %d\n", tile_info.palette_, palette_offset);
|
||
```
|
||
2. Check expected range: palette 0-7 should give offset 0-112
|
||
|
||
**Fix**: Ensure using `* 16` for 4BPP converted data, not `* 8`.
|
||
|
||
---
|
||
|
||
### Issue: Tiles appear "scrambled" or shifted by pixels
|
||
|
||
**Cause**: Buffer addressing formula is wrong
|
||
|
||
**Debug Steps:**
|
||
1. For a known tile (e.g., tile ID 0), print the source indices:
|
||
```cpp
|
||
printf("Tile %d: base_x=%d, base_y=%d\n", tile_info.id_, tile_base_x, tile_base_y);
|
||
```
|
||
2. Expected for tile 0: base_x=0, base_y=0
|
||
3. Expected for tile 16: base_x=0, base_y=512
|
||
|
||
**Fix**: Check the addressing formula matches ZScream's layout:
|
||
- `tile_base_x = (tile_id % 16) * 4`
|
||
- `tile_base_y = (tile_id / 16) * 512`
|
||
|
||
---
|
||
|
||
### Issue: Horizontal mirroring looks wrong
|
||
|
||
**Cause**: Nibble unpacking order is incorrect when mirrored
|
||
|
||
**Debug Steps:**
|
||
1. Test with a known asymmetric tile
|
||
2. Check the nibble swap logic in `DrawTileToBitmap()`
|
||
|
||
**Fix**: When `horizontal_mirror_` is true:
|
||
- Read nibbles in reverse order from the byte
|
||
- Swap which nibble goes to which pixel position
|
||
|
||
---
|
||
|
||
### Issue: Crash or segfault during rendering
|
||
|
||
**Cause**: Buffer overflow - accessing memory out of bounds
|
||
|
||
**Debug Steps:**
|
||
1. Check all array accesses have bounds validation
|
||
2. Add explicit bounds checks:
|
||
```cpp
|
||
if (src_index >= current_gfx16_.size()) {
|
||
printf("ERROR: src_index %d >= buffer size %zu\n", src_index, current_gfx16_.size());
|
||
return;
|
||
}
|
||
```
|
||
|
||
**Fix**: Ensure:
|
||
- `current_gfx16_` size is 0x8000 (32768 bytes)
|
||
- Source index never exceeds buffer size
|
||
- Destination bitmap index is within bitmap bounds
|
||
|
||
---
|
||
|
||
### Issue: Test `DungeonPaletteTest.PaletteOffsetIsCorrectFor4BPP` fails
|
||
|
||
**Cause**: The test was written for old linear buffer layout
|
||
|
||
**Fix**: Update the test to use the new 4BPP packed layout:
|
||
```cpp
|
||
// Old test assumed linear layout: src_index = y * 128 + x
|
||
// New test needs: src_index = (row * 64) + nibble_pair + tile_base
|
||
```
|
||
|
||
The test file at `test/integration/zelda3/dungeon_palette_test.cc` may need updates to match the new addressing scheme.
|
||
|
||
---
|
||
|
||
### Issue: `rom()->mutable_graphics_buffer()` returns wrong format
|
||
|
||
**Cause**: ROM loading may already convert graphics to different format
|
||
|
||
**Debug Steps:**
|
||
1. Check what format the graphics buffer contains:
|
||
```cpp
|
||
auto gfx_buf = rom()->mutable_graphics_buffer();
|
||
printf("Graphics buffer size: %zu\n", gfx_buf->size());
|
||
printf("First 16 bytes: ");
|
||
for (int i = 0; i < 16; i++) printf("%02X ", (*gfx_buf)[i]);
|
||
printf("\n");
|
||
```
|
||
2. Compare against expected 3BPP pattern
|
||
|
||
**If ROM already converts to 8BPP:**
|
||
- Option A conversion is still correct (just reading from different source format)
|
||
- May need to adjust source read offsets
|
||
|
||
---
|
||
|
||
### Common Constants Reference
|
||
|
||
| Constant | Value | Meaning |
|
||
|----------|-------|---------|
|
||
| 3BPP tile size | 24 bytes | 8 rows × 3 bytes/row |
|
||
| 4BPP tile size | 32 bytes | 8 rows × 4 bytes/row |
|
||
| 3BPP sheet size | 1536 bytes | 64 tiles × 24 bytes |
|
||
| 4BPP sheet size | 2048 bytes | 64 tiles × 32 bytes |
|
||
| Tiles per row | 16 | Sheet is 16×4 tiles |
|
||
| Row stride (4BPP) | 64 bytes | 16 tiles × 4 bytes |
|
||
| Tile row stride | 512 bytes | 8 pixel rows × 64 bytes |
|
||
| Block stride | 2048 bytes | One full 4BPP sheet |
|
||
| Total buffer | 32768 bytes | 16 blocks × 2048 bytes |
|
||
|
||
---
|
||
|
||
## Stretch Goal: Cinematic GUI Test
|
||
|
||
Create an interactive GUI test that visually demonstrates dungeon object rendering with deliberate pauses for observation. This test is useful for:
|
||
- Verifying the fix works visually in the actual editor
|
||
- Demonstrating rendering to stakeholders
|
||
- Debugging rendering issues in real-time
|
||
|
||
### Create File: `test/e2e/dungeon_cinematic_rendering_test.cc`
|
||
|
||
```cpp
|
||
/**
|
||
* @file dungeon_cinematic_rendering_test.cc
|
||
* @brief Cinematic test for watching dungeon objects render in slow-motion
|
||
*
|
||
* This test opens multiple dungeon rooms with deliberate pauses between
|
||
* operations so you can visually observe the object rendering process.
|
||
*
|
||
* Run with:
|
||
* ./build_gemini/Debug/yaze_test_gui --gtest_filter="*Cinematic*"
|
||
*
|
||
* Or register with ImGuiTestEngine for interactive execution.
|
||
*/
|
||
|
||
#define IMGUI_DEFINE_MATH_OPERATORS
|
||
|
||
#include <chrono>
|
||
#include <thread>
|
||
#include <vector>
|
||
|
||
#include "app/controller.h"
|
||
#include "rom/rom.h"
|
||
#include "gtest/gtest.h"
|
||
#include "imgui.h"
|
||
#include "imgui_test_engine/imgui_te_context.h"
|
||
#include "imgui_test_engine/imgui_te_engine.h"
|
||
#include "test_utils.h"
|
||
|
||
namespace yaze {
|
||
namespace test {
|
||
|
||
// =============================================================================
|
||
// Cinematic Test Configuration
|
||
// =============================================================================
|
||
|
||
struct CinematicConfig {
|
||
int frame_delay_short = 30; // ~0.5 seconds at 60fps
|
||
int frame_delay_medium = 60; // ~1 second
|
||
int frame_delay_long = 120; // ~2 seconds
|
||
int frame_delay_dramatic = 180; // ~3 seconds (for key moments)
|
||
bool log_verbose = true;
|
||
};
|
||
|
||
// =============================================================================
|
||
// Room Tour Data - Interesting rooms to showcase
|
||
// =============================================================================
|
||
|
||
struct RoomShowcase {
|
||
int room_id;
|
||
const char* name;
|
||
const char* description;
|
||
int view_duration; // in frames
|
||
};
|
||
|
||
static const std::vector<RoomShowcase> kCinematicRooms = {
|
||
{0x00, "Sanctuary Interior", "Simple room - good baseline test", 120},
|
||
{0x01, "Hyrule Castle Entrance", "Complex walls and floor patterns", 150},
|
||
{0x02, "Hyrule Castle Main Hall", "Multiple layers and objects", 150},
|
||
{0x10, "Eastern Palace Entrance", "Different tileset/palette", 120},
|
||
{0x20, "Desert Palace Entrance", "Desert-themed graphics", 120},
|
||
{0x44, "Tower of Hera", "Vertical room layout", 120},
|
||
{0x60, "Skull Woods Entrance", "Dark World palette", 150},
|
||
{0x80, "Ice Palace Entrance", "Ice tileset", 120},
|
||
{0xA0, "Misery Mire Entrance", "Swamp tileset", 120},
|
||
{0xC8, "Ganon's Tower Entrance", "Complex multi-layer room", 180},
|
||
};
|
||
|
||
// =============================================================================
|
||
// Cinematic Test Functions
|
||
// =============================================================================
|
||
|
||
/**
|
||
* @brief Main cinematic test - tours through showcase rooms
|
||
*
|
||
* Opens each room with dramatic pauses, allowing visual observation of:
|
||
* - Room loading animation
|
||
* - Object rendering (BG1 and BG2 layers)
|
||
* - Palette application
|
||
* - Tile alignment
|
||
*/
|
||
void E2ETest_Cinematic_DungeonRoomTour(ImGuiTestContext* ctx) {
|
||
CinematicConfig config;
|
||
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo(" CINEMATIC DUNGEON RENDERING TEST");
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo("");
|
||
ctx->LogInfo("This test will open multiple dungeon rooms");
|
||
ctx->LogInfo("with pauses for visual observation.");
|
||
ctx->LogInfo("");
|
||
ctx->Yield(config.frame_delay_dramatic);
|
||
|
||
// Step 1: Load ROM
|
||
ctx->LogInfo(">>> Loading ROM...");
|
||
gui::LoadRomInTest(ctx, "zelda3.sfc");
|
||
ctx->Yield(config.frame_delay_medium);
|
||
ctx->LogInfo(" ROM loaded successfully!");
|
||
ctx->Yield(config.frame_delay_short);
|
||
|
||
// Step 2: Open Dungeon Editor
|
||
ctx->LogInfo(">>> Opening Dungeon Editor...");
|
||
gui::OpenEditorInTest(ctx, "Dungeon");
|
||
ctx->Yield(config.frame_delay_long);
|
||
ctx->LogInfo(" Dungeon Editor ready!");
|
||
ctx->Yield(config.frame_delay_short);
|
||
|
||
// Step 3: Enable Room Selector
|
||
ctx->LogInfo(">>> Enabling Room Selector...");
|
||
if (ctx->WindowInfo("Dungeon Controls").Window != nullptr) {
|
||
ctx->SetRef("Dungeon Controls");
|
||
ctx->ItemClick("Rooms");
|
||
ctx->Yield(config.frame_delay_medium);
|
||
}
|
||
|
||
// Step 4: Tour through rooms
|
||
ctx->LogInfo("");
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo(" BEGINNING ROOM TOUR");
|
||
ctx->LogInfo("========================================");
|
||
ctx->Yield(config.frame_delay_medium);
|
||
|
||
int rooms_visited = 0;
|
||
for (const auto& room : kCinematicRooms) {
|
||
ctx->LogInfo("");
|
||
ctx->LogInfo("----------------------------------------");
|
||
ctx->LogInfo("Room %d/%zu: %s (0x%02X)",
|
||
rooms_visited + 1, kCinematicRooms.size(),
|
||
room.name, room.room_id);
|
||
ctx->LogInfo(" %s", room.description);
|
||
ctx->LogInfo("----------------------------------------");
|
||
ctx->Yield(config.frame_delay_short);
|
||
|
||
// Open the room
|
||
char room_label[32];
|
||
snprintf(room_label, sizeof(room_label), "Room 0x%02X", room.room_id);
|
||
|
||
if (ctx->WindowInfo("Room Selector").Window != nullptr) {
|
||
ctx->SetRef("Room Selector");
|
||
|
||
// Try to find and click the room
|
||
char search_pattern[16];
|
||
snprintf(search_pattern, sizeof(search_pattern), "[%03X]*", room.room_id);
|
||
|
||
ctx->LogInfo(" >>> Opening room...");
|
||
|
||
// Scroll to room if needed
|
||
ctx->ScrollToItem(search_pattern);
|
||
ctx->Yield(config.frame_delay_short);
|
||
|
||
// Double-click to open
|
||
ctx->ItemDoubleClick(search_pattern);
|
||
ctx->Yield(config.frame_delay_short);
|
||
|
||
ctx->LogInfo(" >>> RENDERING IN PROGRESS...");
|
||
ctx->LogInfo(" (Watch BG1/BG2 layers draw)");
|
||
|
||
// Main viewing pause - watch the rendering
|
||
ctx->Yield(room.view_duration);
|
||
|
||
ctx->LogInfo(" >>> Room rendered!");
|
||
rooms_visited++;
|
||
} else {
|
||
ctx->LogWarning(" Room selector not available");
|
||
}
|
||
|
||
ctx->Yield(config.frame_delay_short);
|
||
}
|
||
|
||
// Final summary
|
||
ctx->LogInfo("");
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo(" CINEMATIC TEST COMPLETE");
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo("");
|
||
ctx->LogInfo("Rooms visited: %d/%zu", rooms_visited, kCinematicRooms.size());
|
||
ctx->LogInfo("");
|
||
ctx->LogInfo("Visual checks to verify:");
|
||
ctx->LogInfo(" [ ] Objects rendered with correct colors");
|
||
ctx->LogInfo(" [ ] No rainbow/garbled patterns");
|
||
ctx->LogInfo(" [ ] Tiles properly aligned (no shifts)");
|
||
ctx->LogInfo(" [ ] Different palettes visible in different rooms");
|
||
ctx->LogInfo("");
|
||
ctx->Yield(config.frame_delay_dramatic);
|
||
}
|
||
|
||
/**
|
||
* @brief Layer toggle demonstration
|
||
*
|
||
* Opens a room and toggles BG1/BG2 visibility with pauses
|
||
* to demonstrate layer rendering.
|
||
*/
|
||
void E2ETest_Cinematic_LayerToggleDemo(ImGuiTestContext* ctx) {
|
||
CinematicConfig config;
|
||
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo(" LAYER TOGGLE DEMONSTRATION");
|
||
ctx->LogInfo("========================================");
|
||
ctx->Yield(config.frame_delay_medium);
|
||
|
||
// Setup
|
||
gui::LoadRomInTest(ctx, "zelda3.sfc");
|
||
gui::OpenEditorInTest(ctx, "Dungeon");
|
||
ctx->Yield(config.frame_delay_medium);
|
||
|
||
// Open Room 0
|
||
if (ctx->WindowInfo("Dungeon Controls").Window != nullptr) {
|
||
ctx->SetRef("Dungeon Controls");
|
||
ctx->ItemClick("Rooms");
|
||
ctx->Yield(config.frame_delay_short);
|
||
}
|
||
|
||
if (ctx->WindowInfo("Room Selector").Window != nullptr) {
|
||
ctx->SetRef("Room Selector");
|
||
ctx->ItemDoubleClick("[000]*");
|
||
ctx->Yield(config.frame_delay_long);
|
||
}
|
||
|
||
// Layer toggle demonstration
|
||
if (ctx->WindowInfo("Room 0x00").Window != nullptr) {
|
||
ctx->SetRef("Room 0x00");
|
||
|
||
ctx->LogInfo(">>> Showing both layers (default)");
|
||
ctx->Yield(config.frame_delay_long);
|
||
|
||
// Toggle BG1 off
|
||
if (ctx->ItemExists("Show BG1")) {
|
||
ctx->LogInfo(">>> Hiding BG1 layer...");
|
||
ctx->ItemClick("Show BG1");
|
||
ctx->Yield(config.frame_delay_long);
|
||
ctx->LogInfo(" (Only BG2 visible now)");
|
||
ctx->Yield(config.frame_delay_medium);
|
||
|
||
// Toggle BG1 back on
|
||
ctx->LogInfo(">>> Showing BG1 layer...");
|
||
ctx->ItemClick("Show BG1");
|
||
ctx->Yield(config.frame_delay_long);
|
||
}
|
||
|
||
// Toggle BG2 off
|
||
if (ctx->ItemExists("Show BG2")) {
|
||
ctx->LogInfo(">>> Hiding BG2 layer...");
|
||
ctx->ItemClick("Show BG2");
|
||
ctx->Yield(config.frame_delay_long);
|
||
ctx->LogInfo(" (Only BG1 visible now)");
|
||
ctx->Yield(config.frame_delay_medium);
|
||
|
||
// Toggle BG2 back on
|
||
ctx->LogInfo(">>> Showing BG2 layer...");
|
||
ctx->ItemClick("Show BG2");
|
||
ctx->Yield(config.frame_delay_long);
|
||
}
|
||
}
|
||
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo(" LAYER DEMO COMPLETE");
|
||
ctx->LogInfo("========================================");
|
||
}
|
||
|
||
/**
|
||
* @brief Palette comparison test
|
||
*
|
||
* Opens rooms with different palette indices side by side
|
||
* to verify palette offset calculation.
|
||
*/
|
||
void E2ETest_Cinematic_PaletteShowcase(ImGuiTestContext* ctx) {
|
||
CinematicConfig config;
|
||
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo(" PALETTE SHOWCASE");
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo("");
|
||
ctx->LogInfo("Opening rooms with different palettes to verify");
|
||
ctx->LogInfo("palette offset calculation is correct.");
|
||
ctx->Yield(config.frame_delay_medium);
|
||
|
||
gui::LoadRomInTest(ctx, "zelda3.sfc");
|
||
gui::OpenEditorInTest(ctx, "Dungeon");
|
||
ctx->Yield(config.frame_delay_medium);
|
||
|
||
// Enable room selector
|
||
if (ctx->WindowInfo("Dungeon Controls").Window != nullptr) {
|
||
ctx->SetRef("Dungeon Controls");
|
||
ctx->ItemClick("Rooms");
|
||
ctx->Yield(config.frame_delay_short);
|
||
}
|
||
|
||
// Rooms that use different palette indices
|
||
struct PaletteRoom {
|
||
int room_id;
|
||
const char* name;
|
||
int expected_palette;
|
||
};
|
||
|
||
std::vector<PaletteRoom> palette_rooms = {
|
||
{0x00, "Sanctuary (Palette 0)", 0},
|
||
{0x01, "Hyrule Castle (Palette 1)", 1},
|
||
{0x10, "Eastern Palace (Palette 2)", 2},
|
||
{0x60, "Skull Woods (Dark Palette)", 4},
|
||
};
|
||
|
||
for (const auto& room : palette_rooms) {
|
||
ctx->LogInfo("");
|
||
ctx->LogInfo(">>> %s", room.name);
|
||
ctx->LogInfo(" Expected palette index: %d", room.expected_palette);
|
||
ctx->LogInfo(" Expected color offset: %d", room.expected_palette * 16);
|
||
|
||
if (ctx->WindowInfo("Room Selector").Window != nullptr) {
|
||
ctx->SetRef("Room Selector");
|
||
char pattern[16];
|
||
snprintf(pattern, sizeof(pattern), "[%03X]*", room.room_id);
|
||
ctx->ItemDoubleClick(pattern);
|
||
ctx->Yield(config.frame_delay_dramatic);
|
||
}
|
||
}
|
||
|
||
ctx->LogInfo("");
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo(" PALETTE SHOWCASE COMPLETE");
|
||
ctx->LogInfo("========================================");
|
||
ctx->LogInfo("");
|
||
ctx->LogInfo("Verify each room uses distinct colors!");
|
||
}
|
||
|
||
// =============================================================================
|
||
// GTest Registration (for non-interactive execution)
|
||
// =============================================================================
|
||
|
||
class DungeonCinematicTest : public ::testing::Test {
|
||
protected:
|
||
void SetUp() override {
|
||
// Note: These tests require GUI mode
|
||
// Skip if running in headless mode
|
||
}
|
||
};
|
||
|
||
TEST_F(DungeonCinematicTest, DISABLED_RoomTour) {
|
||
// This test is registered with ImGuiTestEngine
|
||
// Run via: ./build_gemini/Debug/yaze_test_gui --gtest_filter="*Cinematic*"
|
||
GTEST_SKIP() << "Run via GUI test engine";
|
||
}
|
||
|
||
TEST_F(DungeonCinematicTest, DISABLED_LayerDemo) {
|
||
GTEST_SKIP() << "Run via GUI test engine";
|
||
}
|
||
|
||
TEST_F(DungeonCinematicTest, DISABLED_PaletteShowcase) {
|
||
GTEST_SKIP() << "Run via GUI test engine";
|
||
}
|
||
|
||
} // namespace test
|
||
} // namespace yaze
|
||
```
|
||
|
||
---
|
||
|
||
### Register Tests with ImGuiTestEngine
|
||
|
||
**Add to the test registration in your GUI test setup:**
|
||
|
||
```cpp
|
||
// In test setup or controller initialization:
|
||
if (test_engine) {
|
||
ImGuiTestEngine_RegisterTest(
|
||
test_engine, "Dungeon", "Cinematic_RoomTour",
|
||
E2ETest_Cinematic_DungeonRoomTour);
|
||
|
||
ImGuiTestEngine_RegisterTest(
|
||
test_engine, "Dungeon", "Cinematic_LayerToggle",
|
||
E2ETest_Cinematic_LayerToggleDemo);
|
||
|
||
ImGuiTestEngine_RegisterTest(
|
||
test_engine, "Dungeon", "Cinematic_PaletteShowcase",
|
||
E2ETest_Cinematic_PaletteShowcase);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Running the Cinematic Tests
|
||
|
||
```bash
|
||
# Build with GUI tests enabled
|
||
cmake --build build_gemini --target yaze_test_gui -j8
|
||
|
||
# Run all cinematic tests
|
||
./build_gemini/Debug/yaze_test_gui --gtest_filter="*Cinematic*"
|
||
|
||
# Or run interactively via ImGuiTestEngine menu:
|
||
# 1. Launch yaze normally
|
||
# 2. Open Tools > Test Engine
|
||
# 3. Select "Dungeon/Cinematic_RoomTour"
|
||
# 4. Click "Run"
|
||
```
|
||
|
||
---
|
||
|
||
### What to Watch For
|
||
|
||
During the cinematic test:
|
||
|
||
1. **Room Loading Phase**
|
||
- Watch the canvas area for initial rendering
|
||
- Objects should appear in sequence (or all at once, depending on implementation)
|
||
|
||
2. **Color Correctness**
|
||
- Browns/grays for castle walls
|
||
- Distinct palettes for different dungeon types
|
||
- No "rainbow" or garbled colors
|
||
|
||
3. **Layer Separation**
|
||
- When BG1 is hidden, floor/background remains
|
||
- When BG2 is hidden, walls/foreground remains
|
||
- Both layers combine correctly when visible
|
||
|
||
4. **Tile Alignment**
|
||
- No 1-pixel shifts between tiles
|
||
- Object edges line up properly
|
||
- No visible seams in repeated patterns
|
||
|
||
---
|
||
|
||
## Reference Documentation
|
||
|
||
- `docs/internal/agents/dungeon-system-reference.md` - Full dungeon system architecture
|
||
- `docs/internal/architecture/graphics_system_architecture.md` - Graphics pipeline
|
||
- `CLAUDE.md` - Project coding conventions and build instructions
|
||
- ZScreamDungeon source: `/Users/scawful/Code/ZScreamDungeon/ZeldaFullEditor/GraphicsManager.cs`
|
||
- SNES disassembly: `assets/asm/usdasm/bank_00.asm` (lines 9759-9892)
|