diff --git a/CrashTeamEditor.vcxproj b/CrashTeamEditor.vcxproj
index b371610..38e8b96 100644
--- a/CrashTeamEditor.vcxproj
+++ b/CrashTeamEditor.vcxproj
@@ -48,6 +48,7 @@
+
@@ -92,6 +93,7 @@
+
diff --git a/CrashTeamEditor.vcxproj.filters b/CrashTeamEditor.vcxproj.filters
index 2525aad..970edcc 100644
--- a/CrashTeamEditor.vcxproj.filters
+++ b/CrashTeamEditor.vcxproj.filters
@@ -78,6 +78,9 @@
Source Files
+
+ Source Files
+
Source Files
@@ -197,6 +200,9 @@
Header Files
+
+ Header Files
+
Renderer
diff --git a/src/gui_render_settings.cpp b/src/gui_render_settings.cpp
index bfc064e..a105531 100644
--- a/src/gui_render_settings.cpp
+++ b/src/gui_render_settings.cpp
@@ -25,6 +25,7 @@ bool GuiRenderSettings::showStartpoints = false;
bool GuiRenderSettings::showVisTree = false;
bool GuiRenderSettings::filterActive = true;
bool GuiRenderSettings::showSelectedQuadblockInfo = true;
+bool GuiRenderSettings::showMinimapBounds = false;
Color GuiRenderSettings::defaultFilterColor = Color(static_cast(255), static_cast(128), static_cast(0));
Color GuiRenderSettings::selectedCheckpointColor = Color(static_cast(0), static_cast < unsigned char>(255), static_cast < unsigned char>(255));
int GuiRenderSettings::renderType = 0;
diff --git a/src/gui_render_settings.h b/src/gui_render_settings.h
index 27323de..40769b6 100644
--- a/src/gui_render_settings.h
+++ b/src/gui_render_settings.h
@@ -19,7 +19,7 @@ struct GuiRenderSettings
static float camFovDeg, camZoomMult, camRotateMult, camMoveMult, camSprintMult;
static int camKeyForward, camKeyBack, camKeyLeft, camKeyRight, camKeyUp, camKeyDown, camKeySprint;
static int camOrbitMouseButton;
- static bool showLowLOD, showWireframe, showVerts, showBackfaces, showBspRectTree, showLevel, showCheckpoints, showStartpoints, showVisTree, filterActive, showSelectedQuadblockInfo;
+ static bool showLowLOD, showWireframe, showVerts, showBackfaces, showBspRectTree, showLevel, showCheckpoints, showStartpoints, showVisTree, filterActive, showSelectedQuadblockInfo, showMinimapBounds;
static Color defaultFilterColor, selectedCheckpointColor;
static const std::vector renderTypeLabels;
};
diff --git a/src/io.cpp b/src/io.cpp
index 5063ed3..09ac349 100644
--- a/src/io.cpp
+++ b/src/io.cpp
@@ -85,6 +85,57 @@ void from_json(const nlohmann::json& json, Stars& stars)
if (json.contains("depth")) { stars.zDepth = json["depth"]; }
}
+void to_json(nlohmann::json& json, const MinimapConfig& minimap)
+{
+ json = {
+ {"enabled", minimap.enabled},
+ {"worldEndX", minimap.worldEndX},
+ {"worldEndY", minimap.worldEndY},
+ {"worldStartX", minimap.worldStartX},
+ {"worldStartY", minimap.worldStartY},
+ {"iconSizeX", minimap.iconSizeX},
+ {"iconSizeY", minimap.iconSizeY},
+ {"driverDotStartX", minimap.driverDotStartX},
+ {"driverDotStartY", minimap.driverDotStartY},
+ {"orientationMode", minimap.orientationMode},
+ {"unk", minimap.unk},
+ {"sourceTexturePath", minimap.sourceTexturePath.string()}
+ };
+}
+
+void from_json(const nlohmann::json& json, MinimapConfig& minimap)
+{
+ if (json.contains("enabled")) { json.at("enabled").get_to(minimap.enabled); }
+ if (json.contains("worldEndX")) { json.at("worldEndX").get_to(minimap.worldEndX); }
+ if (json.contains("worldEndY")) { json.at("worldEndY").get_to(minimap.worldEndY); }
+ if (json.contains("worldStartX")) { json.at("worldStartX").get_to(minimap.worldStartX); }
+ if (json.contains("worldStartY")) { json.at("worldStartY").get_to(minimap.worldStartY); }
+ if (json.contains("iconSizeX")) { json.at("iconSizeX").get_to(minimap.iconSizeX); }
+ if (json.contains("iconSizeY")) { json.at("iconSizeY").get_to(minimap.iconSizeY); }
+ if (json.contains("driverDotStartX")) { json.at("driverDotStartX").get_to(minimap.driverDotStartX); }
+ if (json.contains("driverDotStartY")) { json.at("driverDotStartY").get_to(minimap.driverDotStartY); }
+ if (json.contains("orientationMode")) { json.at("orientationMode").get_to(minimap.orientationMode); }
+ if (json.contains("unk")) { json.at("unk").get_to(minimap.unk); }
+
+ // New format: single source texture path
+ if (json.contains("sourceTexturePath"))
+ {
+ std::string path;
+ json.at("sourceTexturePath").get_to(path);
+ if (!path.empty()) { minimap.sourceTexturePath = path; }
+ }
+ // Old format backwards compatibility: if topTexturePath exists but sourceTexturePath doesn't, use topTexturePath
+ else if (json.contains("topTexturePath"))
+ {
+ std::string path;
+ json.at("topTexturePath").get_to(path);
+ if (!path.empty()) { minimap.sourceTexturePath = path; }
+ }
+
+ // Load textures after setting paths
+ minimap.LoadTextures();
+}
+
void ReadBinaryFile(std::vector& v, const std::filesystem::path& path)
{
std::ifstream file(path, std::ios::binary);
diff --git a/src/io.h b/src/io.h
index 6309561..85f2bfb 100644
--- a/src/io.h
+++ b/src/io.h
@@ -4,6 +4,7 @@
#include "path.h"
#include "quadblock.h"
#include "animtexture.h"
+#include "minimap.h"
#include
@@ -26,6 +27,9 @@ void from_json(const nlohmann::json& json, ColorGradient& spawn);
void to_json(nlohmann::json& json, const Stars& stars);
void from_json(const nlohmann::json& json, Stars& stars);
+void to_json(nlohmann::json& json, const MinimapConfig& minimap);
+void from_json(const nlohmann::json& json, MinimapConfig& minimap);
+
void ReadBinaryFile(std::vector& v, const std::filesystem::path& path);
template static inline void Read(std::ifstream& file, T& data)
diff --git a/src/level.cpp b/src/level.cpp
index f2e6aa6..71a0e97 100644
--- a/src/level.cpp
+++ b/src/level.cpp
@@ -8,6 +8,7 @@
#include "renderer.h"
#include "vistree.h"
#include "text3d.h"
+#include "minimap.h"
#include
#include
@@ -77,6 +78,7 @@ void Level::Clear(bool clearErrors)
m_saveScript = false;
m_vrm.clear();
m_lastAnimTextureCount = 0;
+ m_minimapConfig.Clear();
DeleteMaterials(this);
for (Model* model : m_models)
@@ -115,6 +117,11 @@ const std::filesystem::path& Level::GetParentPath() const
return m_parentPath;
}
+MinimapConfig& Level::GetMinimapConfig()
+{
+ return m_minimapConfig;
+}
+
std::vector Level::GetMaterialNames() const
{
std::vector names;
@@ -393,7 +400,7 @@ bool Level::GenerateCheckpoints()
enum class PresetHeader : unsigned
{
- SPAWN, LEVEL, PATH, MATERIAL, TURBO_PAD, ANIM_TEXTURES, SCRIPT
+ SPAWN, LEVEL, PATH, MATERIAL, TURBO_PAD, ANIM_TEXTURES, SCRIPT, MINIMAP
};
bool Level::LoadPreset(const std::filesystem::path& filename)
@@ -527,6 +534,13 @@ bool Level::LoadPreset(const std::filesystem::path& filename)
{
m_pythonScript = json["script"];
}
+ else if (header == PresetHeader::MINIMAP)
+ {
+ if (json.contains("minimap"))
+ {
+ m_minimapConfig = json["minimap"];
+ }
+ }
else
{
m_logMessage += "\nFailed loaded preset: " + filename.string();
@@ -626,6 +640,14 @@ bool Level::SavePreset(const std::filesystem::path& path)
scriptJson["script"] = m_pythonScript;
SaveJSON(dirPath / "script.json", scriptJson);
}
+
+ if (m_minimapConfig.enabled)
+ {
+ nlohmann::json minimapJson = {};
+ minimapJson["header"] = PresetHeader::MINIMAP;
+ minimapJson["minimap"] = m_minimapConfig;
+ SaveJSON(dirPath / "minimap.json", minimapJson);
+ }
return true;
}
@@ -1130,13 +1152,8 @@ bool Level::SaveLEV(const std::filesystem::path& path)
const size_t offOxideGhost = m_oxideGhost.empty() ? 0 : currOffset;
currOffset += m_oxideGhost.size();
+ // Note: extraHeader.offsets[MINIMAP] will be updated later after minimap data is serialized
PSX::LevelExtraHeader extraHeader = {};
- if (offTropyGhost > 0)
- {
- if (offOxideGhost > 0) { extraHeader.count = PSX::LevelExtra::COUNT; }
- else { extraHeader.count = PSX::LevelExtra::N_OXIDE_GHOST; }
- }
- else { extraHeader.count = 0; }
extraHeader.offsets[PSX::LevelExtra::MINIMAP] = 0;
extraHeader.offsets[PSX::LevelExtra::SPAWN] = 0;
extraHeader.offsets[PSX::LevelExtra::CAMERA_END_OF_RACE] = 0;
@@ -1145,6 +1162,12 @@ bool Level::SaveLEV(const std::filesystem::path& path)
extraHeader.offsets[PSX::LevelExtra::N_OXIDE_GHOST] = static_cast(offOxideGhost);
extraHeader.offsets[PSX::LevelExtra::CREDITS] = 0;
+ // Determine count based on highest enabled entry index + 1
+ // Count represents the number of valid entries in the offsets array
+ if (offOxideGhost > 0) { extraHeader.count = PSX::LevelExtra::N_OXIDE_GHOST + 1; }
+ else if (offTropyGhost > 0) { extraHeader.count = PSX::LevelExtra::N_TROPY_GHOST + 1; }
+ // Note: minimap count will be set later if enabled and no ghosts present
+
const size_t offExtraHeader = currOffset;
currOffset += sizeof(extraHeader);
@@ -1173,6 +1196,69 @@ bool Level::SaveLEV(const std::filesystem::path& path)
const size_t offVisMem = currOffset;
currOffset += sizeof(visMem);
+ // Minimap data serialization
+ size_t offMinimapStruct = 0;
+ size_t offLevelIconHeader = 0;
+ size_t offMinimapIcons = 0;
+ std::vector minimapData;
+ std::vector minimapPtrMapOffsets;
+
+ if (m_minimapConfig.IsReady())
+ {
+ // Map struct - this is what extraHeader.offsets[MINIMAP] will point to
+ offMinimapStruct = currOffset;
+ PSX::Map mapStruct = m_minimapConfig.Serialize();
+ size_t mapStructOffset = minimapData.size();
+ minimapData.resize(minimapData.size() + sizeof(PSX::Map));
+ memcpy(&minimapData[mapStructOffset], &mapStruct, sizeof(PSX::Map));
+ currOffset += sizeof(PSX::Map);
+
+ // Icon structs (top and bottom minimap textures)
+ offMinimapIcons = currOffset;
+
+ // Create default UV for full texture
+ QuadUV defaultUV = {{Vec2(0.0f, 0.0f), Vec2(1.0f, 0.0f), Vec2(0.0f, 1.0f), Vec2(1.0f, 1.0f)}};
+
+ // Top icon
+ PSX::Icon topIcon = {};
+ strncpy_s(topIcon.name, sizeof(topIcon.name), "minimap-top", _TRUNCATE);
+ topIcon.globalIconArrayIndex = PSX::ICON_INDEX_MAP_TOP;
+ topIcon.texLayout = m_minimapConfig.topTexture.Serialize(defaultUV);
+ size_t topIconOffset = minimapData.size();
+ minimapData.resize(minimapData.size() + sizeof(PSX::Icon));
+ memcpy(&minimapData[topIconOffset], &topIcon, sizeof(PSX::Icon));
+ currOffset += sizeof(PSX::Icon);
+
+ // Bottom icon
+ PSX::Icon bottomIcon = {};
+ strncpy_s(bottomIcon.name, sizeof(bottomIcon.name), "minimap-bot", _TRUNCATE);
+ bottomIcon.globalIconArrayIndex = PSX::ICON_INDEX_MAP_BOTTOM;
+ bottomIcon.texLayout = m_minimapConfig.bottomTexture.Serialize(defaultUV);
+ size_t bottomIconOffset = minimapData.size();
+ minimapData.resize(minimapData.size() + sizeof(PSX::Icon));
+ memcpy(&minimapData[bottomIconOffset], &bottomIcon, sizeof(PSX::Icon));
+ currOffset += sizeof(PSX::Icon);
+
+ // LevelIconHeader struct (pointed to by header.offIconsLookup)
+ offLevelIconHeader = currOffset;
+ PSX::LevelIconHeader levelIconHeader = {};
+ levelIconHeader.numIcon = 2;
+ levelIconHeader.offFirstIcon = static_cast(offMinimapIcons);
+ levelIconHeader.numIconGroup = 0;
+ levelIconHeader.offFirstIconGroupPtr = 0;
+ size_t levelIconHeaderOffset = minimapData.size();
+ minimapData.resize(minimapData.size() + sizeof(PSX::LevelIconHeader));
+ memcpy(&minimapData[levelIconHeaderOffset], &levelIconHeader, sizeof(PSX::LevelIconHeader));
+ minimapPtrMapOffsets.push_back(currOffset + offsetof(PSX::LevelIconHeader, offFirstIcon)); // Pointer to first icon
+ currOffset += sizeof(PSX::LevelIconHeader);
+
+ // Update extraHeader to point to the minimap Map struct directly
+ extraHeader.offsets[PSX::LevelExtra::MINIMAP] = static_cast(offMinimapStruct);
+
+ // Set count if no ghosts are present (minimap is at index 0, so count = 1)
+ if (extraHeader.count == 0) { extraHeader.count = PSX::LevelExtra::MINIMAP + 1; }
+ }
+
const size_t offPointerMap = currOffset;
header.offMeshInfo = static_cast(offMeshInfo);
@@ -1197,6 +1283,13 @@ bool Level::SaveLEV(const std::filesystem::path& path)
header.offVisMem = static_cast(offVisMem);
header.offLevNavTable = static_cast(offNavHeaders);
+ // Set minimap pointers in header if enabled
+ if (m_minimapConfig.IsReady())
+ {
+ header.offIconsLookup = static_cast(offLevelIconHeader);
+ header.offIcons = static_cast(offMinimapIcons);
+ }
+
#define CALCULATE_OFFSET(s, m, b) static_cast(offsetof(s, m) + b)
std::vector pointerMap =
@@ -1215,8 +1308,16 @@ bool Level::SaveLEV(const std::filesystem::path& path)
CALCULATE_OFFSET(PSX::VisualMem, offBSP[0], offVisMem),
};
+ // Add minimap header pointers to pointer map
+ if (m_minimapConfig.IsReady())
+ {
+ pointerMap.push_back(CALCULATE_OFFSET(PSX::LevHeader, offIconsLookup, offHeader));
+ pointerMap.push_back(CALCULATE_OFFSET(PSX::LevHeader, offIcons, offHeader));
+ }
+
if (offTropyGhost != 0) { pointerMap.push_back(CALCULATE_OFFSET(PSX::LevelExtraHeader, offsets[PSX::LevelExtra::N_TROPY_GHOST], offExtraHeader)); }
if (offOxideGhost != 0) { pointerMap.push_back(CALCULATE_OFFSET(PSX::LevelExtraHeader, offsets[PSX::LevelExtra::N_OXIDE_GHOST], offExtraHeader)); }
+ if (m_minimapConfig.IsReady()) { pointerMap.push_back(CALCULATE_OFFSET(PSX::LevelExtraHeader, offsets[PSX::LevelExtra::MINIMAP], offExtraHeader)); }
for (size_t i = 0; i < animPtrMapOffsets.size(); i++)
{
@@ -1255,6 +1356,12 @@ bool Level::SaveLEV(const std::filesystem::path& path)
offCurrVisibleSet += sizeof(PSX::VisibleSet);
}
+ // Add minimap internal pointers to pointer map
+ for (size_t offset : minimapPtrMapOffsets)
+ {
+ pointerMap.push_back(static_cast(offset));
+ }
+
const size_t pointerMapBytes = pointerMap.size() * sizeof(uint32_t);
Write(file, &offPointerMap, sizeof(uint32_t));
@@ -1290,6 +1397,8 @@ bool Level::SaveLEV(const std::filesystem::path& path)
Write(file, visMemQuadsP1.data(), visMemQuadsP1.size() * sizeof(uint32_t));
Write(file, visMemBSPP1.data(), visMemBSPP1.size() * sizeof(uint32_t));
Write(file, &visMem, sizeof(visMem));
+ // Write minimap data if present
+ if (!minimapData.empty()) { Write(file, minimapData.data(), minimapData.size()); }
Write(file, &pointerMapBytes, sizeof(uint32_t));
Write(file, pointerMap.data(), pointerMapBytes);
file.close();
@@ -1779,6 +1888,27 @@ bool Level::UpdateVRM()
}
}
+ // Add minimap textures if enabled
+ if (m_minimapConfig.IsReady())
+ {
+ std::vector minimapTextures = m_minimapConfig.GetTextures();
+ for (Texture* tex : minimapTextures)
+ {
+ bool foundEqual = false;
+ for (Texture* addedTexture : textures)
+ {
+ if (*tex == *addedTexture)
+ {
+ copyTextureAttributes.push_back({addedTexture, tex});
+ foundEqual = true;
+ break;
+ }
+ }
+ if (foundEqual) { continue; }
+ textures.push_back(tex);
+ }
+ }
+
m_vrm = PackVRM(textures);
if (m_vrm.empty()) { return false; }
@@ -1828,6 +1958,9 @@ void Level::InitModels(Renderer& renderer)
m_models[LevelModels::FILTER] = m_models[LevelModels::LEVEL]->AddModel();
m_models[LevelModels::FILTER]->SetRenderCondition([]() { return GuiRenderSettings::filterActive; });
+
+ m_models[LevelModels::MINIMAP_BOUNDS] = m_models[LevelModels::LEVEL]->AddModel();
+ m_models[LevelModels::MINIMAP_BOUNDS]->SetRenderCondition([]() { return GuiRenderSettings::showMinimapBounds; });
}
void Level::GenerateRenderLevData()
@@ -2012,6 +2145,43 @@ void Level::GenerateRenderStartpointData()
m_models[LevelModels::SPAWN]->GetMesh().SetGeometry(spawnsTriangles, Mesh::RenderFlags::DrawBackfaces | Mesh::RenderFlags::DontOverrideRenderFlags);
}
+void Level::GenerateRenderMinimapBoundsData()
+{
+ if (!m_models[LevelModels::MINIMAP_BOUNDS]) { return; }
+
+ if (!m_minimapConfig.enabled)
+ {
+ m_models[LevelModels::MINIMAP_BOUNDS]->GetMesh().Clear();
+ return;
+ }
+
+ // Convert from fixed-point to world coordinates
+ float minX = static_cast(m_minimapConfig.worldStartX) / static_cast(FP_ONE_GEO);
+ float maxX = static_cast(m_minimapConfig.worldEndX) / static_cast(FP_ONE_GEO);
+ float minZ = static_cast(m_minimapConfig.worldStartY) / static_cast(FP_ONE_GEO);
+ float maxZ = static_cast(m_minimapConfig.worldEndY) / static_cast(FP_ONE_GEO);
+
+ // Add some height to the minimap bounds for better visibility
+ float minY = -10.0f;
+ float maxY = 10.0f;
+
+ // Create bounding box for minimap bounds
+ BoundingBox bbox;
+ bbox.min = Vec3(minX, minY, minZ);
+ bbox.max = Vec3(maxX, maxY, maxZ);
+
+ // Magenta color for minimap bounds
+ Color c = Color(static_cast(255), static_cast(0), static_cast(255));
+
+ std::vector triangles = bbox.ToGeometry();
+ for (Primitive& primitive : triangles)
+ {
+ for (unsigned i = 0; i < primitive.pointCount; i++) { primitive.p[i].color = c; }
+ }
+
+ m_models[LevelModels::MINIMAP_BOUNDS]->GetMesh().SetGeometry(triangles, Mesh::RenderFlags::DrawWireframe | Mesh::RenderFlags::DontOverrideRenderFlags);
+}
+
void Level::GenerateRenderSelectedBlockData(const Quadblock& quadblock, const Vec3& queryPoint)
{
if (!m_models[LevelModels::SELECTED]) { return; }
diff --git a/src/level.h b/src/level.h
index edb697a..045babf 100644
--- a/src/level.h
+++ b/src/level.h
@@ -12,6 +12,7 @@
#include "animtexture.h"
#include "model.h"
#include "vistree.h"
+#include "minimap.h"
#include
#include
@@ -32,7 +33,8 @@ namespace LevelModels
static constexpr size_t SELECTED = 4;
static constexpr size_t MULTI_SELECTED = 5;
static constexpr size_t FILTER = 6;
- static constexpr size_t COUNT = 7;
+ static constexpr size_t MINIMAP_BOUNDS = 7;
+ static constexpr size_t COUNT = 8;
};
class Level
@@ -63,6 +65,7 @@ class Level
void ResetFilter();
void ResetRendererSelection();
void UpdateRenderCheckpointData();
+ MinimapConfig& GetMinimapConfig();
private:
void ManageTurbopad(Quadblock& quadblock);
@@ -86,6 +89,7 @@ class Level
void UpdateFilterRenderData(const Quadblock& qb);
void GenerateRenderBspData();
void GenerateRenderStartpointData();
+ void GenerateRenderMinimapBoundsData();
void GenerateRenderSelectedBlockData(const Quadblock& quadblock, const Vec3& queryPoint);
bool UpdateAnimTextures(float deltaTime);
void ViewportClickHandleBlockSelection(int pixelX, int pixelY, bool appendSelection, const Renderer& rend);
@@ -124,6 +128,7 @@ class Level
std::vector m_animTextures;
BitMatrix m_bspVis;
std::vector m_vrm;
+ MinimapConfig m_minimapConfig;
std::unordered_map> m_materialToQuadblocks;
std::unordered_map m_materialToTexture;
diff --git a/src/levelui.cpp b/src/levelui.cpp
index 06671b6..86484a4 100644
--- a/src/levelui.cpp
+++ b/src/levelui.cpp
@@ -11,6 +11,7 @@
#include "texture.h"
#include "ui.h"
#include "script.h"
+#include "minimap.h"
#include
#include
@@ -501,6 +502,15 @@ void Level::RenderUI(Renderer& renderer)
ImGui::TreePop();
}
+
+ if (ImGui::TreeNode("Minimap"))
+ {
+ if (m_minimapConfig.RenderUI(m_quadblocks) && GuiRenderSettings::showMinimapBounds)
+ {
+ GenerateRenderMinimapBoundsData();
+ }
+ ImGui::TreePop();
+ }
}
ImGui::End();
}
@@ -912,9 +922,9 @@ void Level::RenderUI(Renderer& renderer)
unsigned ret = REND_FLAGS_NONE;
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
- if (ImGui::Checkbox(leftLabel, leftValue)) { ret |= REND_FLAGS_COLUMN_0; }
+ if (leftValue && ImGui::Checkbox(leftLabel, leftValue)) { ret |= REND_FLAGS_COLUMN_0; }
ImGui::TableSetColumnIndex(1);
- if (ImGui::Checkbox(rightLabel, rightValue)) { ret |= REND_FLAGS_COLUMN_1; }
+ if (rightValue && ImGui::Checkbox(rightLabel, rightValue)) { ret |= REND_FLAGS_COLUMN_1; }
return ret;
};
@@ -923,6 +933,8 @@ void Level::RenderUI(Renderer& renderer)
unsigned cpStartPoints = checkboxPair("Show Checkpoints", &GuiRenderSettings::showCheckpoints, "Show Starting Positions", &GuiRenderSettings::showStartpoints);
if (cpStartPoints & REND_FLAGS_COLUMN_1) { GenerateRenderStartpointData(); }
checkboxPair("Show BSP", &GuiRenderSettings::showBspRectTree, "Show Vis Tree", &GuiRenderSettings::showVisTree);
+ unsigned minimapBoundsChanged = checkboxPair("Show Minimap Bounds", &GuiRenderSettings::showMinimapBounds, "", nullptr);
+ if (minimapBoundsChanged & REND_FLAGS_COLUMN_0) { GenerateRenderMinimapBoundsData(); }
ImGui::EndTable();
}
diff --git a/src/minimap.cpp b/src/minimap.cpp
new file mode 100644
index 0000000..4afa104
--- /dev/null
+++ b/src/minimap.cpp
@@ -0,0 +1,317 @@
+#include "minimap.h"
+#include "quadblock.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+void MinimapConfig::CalculateWorldBoundsFromQuadblocks(const std::vector& quadblocks)
+{
+ if (quadblocks.empty()) { return; }
+
+ float minX = std::numeric_limits::max();
+ float minZ = std::numeric_limits::max();
+ float maxX = std::numeric_limits::lowest();
+ float maxZ = std::numeric_limits::lowest();
+
+ bool found = false;
+ for (const Quadblock& qb : quadblocks)
+ {
+ // Only include quadblocks that have a checkpoint assigned
+ if (qb.GetCheckpoint() >= 0)
+ {
+ const BoundingBox& bbox = qb.GetBoundingBox();
+ minX = std::min(minX, bbox.min.x);
+ minZ = std::min(minZ, bbox.min.z);
+ maxX = std::max(maxX, bbox.max.x);
+ maxZ = std::max(maxZ, bbox.max.z);
+ found = true;
+ }
+ }
+
+ // Convert to fixed-point coordinates (FP_ONE_GEO = 64)
+ worldStartX = static_cast(minX * FP_ONE_GEO);
+ worldStartY = static_cast(minZ * FP_ONE_GEO);
+ worldEndX = static_cast(maxX * FP_ONE_GEO);
+ worldEndY = static_cast(maxZ * FP_ONE_GEO);
+}
+
+PSX::Map MinimapConfig::Serialize() const
+{
+ PSX::Map map = {};
+ map.worldEndX = worldEndX;
+ map.worldEndY = worldEndY;
+ map.worldStartX = worldStartX;
+ map.worldStartY = worldStartY;
+ // Icon size is the texture dimensions
+ map.iconSizeX = hasTopTexture ? static_cast(topTexture.GetWidth()) : iconSizeX;
+ map.iconSizeY = hasTopTexture ? static_cast(topTexture.GetHeight()) : iconSizeY;
+ map.driverDotStartX = driverDotStartX;
+ map.driverDotStartY = driverDotStartY;
+ map.orientationMode = orientationMode;
+ map.unk = unk;
+ return map;
+}
+
+void MinimapConfig::Deserialize(const PSX::Map& map)
+{
+ worldEndX = map.worldEndX;
+ worldEndY = map.worldEndY;
+ worldStartX = map.worldStartX;
+ worldStartY = map.worldStartY;
+ iconSizeX = map.iconSizeX;
+ iconSizeY = map.iconSizeY;
+ driverDotStartX = map.driverDotStartX;
+ driverDotStartY = map.driverDotStartY;
+ orientationMode = map.orientationMode;
+ unk = map.unk;
+}
+
+void MinimapConfig::LoadTextures()
+{
+ lastError.clear();
+ hasTopTexture = false;
+ hasBottomTexture = false;
+ topTexture = Texture();
+ bottomTexture = Texture();
+
+ if (sourceTexturePath.empty()) {
+ lastError = "No file selected";
+ return;
+ }
+ if (!std::filesystem::exists(sourceTexturePath)) {
+ lastError = "File does not exist";
+ return;
+ }
+
+ // Load the source image using stb_image
+ int width = 0, height = 0, channels = 0;
+ unsigned char* sourceImage = stbi_load(sourceTexturePath.string().c_str(), &width, &height, &channels, 4); // Force RGBA
+ if (sourceImage == nullptr) {
+ lastError = "Failed to load image (unsupported format or corrupted file)";
+ return;
+ }
+
+ if (width <= 0 || height <= 0) {
+ stbi_image_free(sourceImage);
+ lastError = "Image has invalid dimensions";
+ return;
+ }
+
+ // Image should be twice the height (top + bottom halves)
+ if (height % 2 != 0) {
+ stbi_image_free(sourceImage);
+ lastError = "Image height must be even (for splitting into top/bottom)";
+ return;
+ }
+
+ int halfHeight = height / 2;
+ int stretchedWidth = static_cast(width * 1.5f);
+
+ if (stretchedWidth <= 0) {
+ stbi_image_free(sourceImage);
+ lastError = "Stretched width is invalid (check image width)";
+ return;
+ }
+
+ // Create buffers for stretched top and bottom halves
+ std::vector topPixels(stretchedWidth * halfHeight * 4);
+ std::vector bottomPixels(stretchedWidth * halfHeight * 4);
+
+ // Stretch and split the image using nearest-neighbor sampling
+ for (int y = 0; y < halfHeight; y++) {
+ for (int x = 0; x < stretchedWidth; x++) {
+ int srcX = static_cast(x / 1.5f);
+ if (srcX >= width) srcX = width - 1;
+
+ // Top half
+ int srcTopIdx = (y * width + srcX) * 4;
+ int dstTopIdx = (y * stretchedWidth + x) * 4;
+ topPixels[dstTopIdx + 0] = sourceImage[srcTopIdx + 0];
+ topPixels[dstTopIdx + 1] = sourceImage[srcTopIdx + 1];
+ topPixels[dstTopIdx + 2] = sourceImage[srcTopIdx + 2];
+ topPixels[dstTopIdx + 3] = sourceImage[srcTopIdx + 3];
+
+ // Bottom half
+ int srcBottomIdx = ((y + halfHeight) * width + srcX) * 4;
+ int dstBottomIdx = (y * stretchedWidth + x) * 4;
+ bottomPixels[dstBottomIdx + 0] = sourceImage[srcBottomIdx + 0];
+ bottomPixels[dstBottomIdx + 1] = sourceImage[srcBottomIdx + 1];
+ bottomPixels[dstBottomIdx + 2] = sourceImage[srcBottomIdx + 2];
+ bottomPixels[dstBottomIdx + 3] = sourceImage[srcBottomIdx + 3];
+ }
+ }
+
+ stbi_image_free(sourceImage);
+
+ topTexture = Texture::CreateFromPixelData(topPixels.data(), stretchedWidth, halfHeight);
+ bottomTexture = Texture::CreateFromPixelData(bottomPixels.data(), stretchedWidth, halfHeight);
+
+ hasTopTexture = !topTexture.IsEmpty();
+ hasBottomTexture = !bottomTexture.IsEmpty();
+
+ if (!hasTopTexture || !hasBottomTexture) {
+ lastError = "Failed to create minimap textures from image data";
+ }
+}
+
+bool MinimapConfig::IsReady() const
+{
+ return enabled && hasTopTexture && hasBottomTexture && lastError.empty();
+}
+
+std::vector MinimapConfig::GetTextures()
+{
+ std::vector textures;
+ if (hasTopTexture) { textures.push_back(&topTexture); }
+ if (hasBottomTexture) { textures.push_back(&bottomTexture); }
+ return textures;
+}
+
+void MinimapConfig::Clear()
+{
+ worldEndX = 0;
+ worldEndY = 0;
+ worldStartX = 0;
+ worldStartY = 0;
+ iconSizeX = 0;
+ iconSizeY = 0;
+ driverDotStartX = 450;
+ driverDotStartY = 180;
+ orientationMode = 0;
+ unk = 0;
+ sourceTexturePath.clear();
+ topTexture = Texture();
+ bottomTexture = Texture();
+ hasTopTexture = false;
+ hasBottomTexture = false;
+ enabled = false;
+ lastError.clear();
+}
+
+bool MinimapConfig::RenderUI(const std::vector& quadblocks)
+{
+ bool boundsChanged = false;
+
+ ImGui::Checkbox("Enable Minimap", &enabled);
+
+ if (!enabled) { return false; }
+
+ ImGui::Separator();
+ ImGui::Text("World Bounds:");
+
+ // Convert fixed-point to float for display (divide by 64)
+ float startX = static_cast(worldStartX) / static_cast(FP_ONE_GEO);
+ float startY = static_cast(worldStartY) / static_cast(FP_ONE_GEO);
+ float endX = static_cast(worldEndX) / static_cast(FP_ONE_GEO);
+ float endY = static_cast(worldEndY) / static_cast(FP_ONE_GEO);
+
+ if (ImGui::InputFloat("World Start X", &startX, 1.0f, 10.0f, "%.2f"))
+ {
+ worldStartX = static_cast(startX * static_cast(FP_ONE_GEO));
+ boundsChanged = true;
+ }
+ if (ImGui::InputFloat("World Start Y", &startY, 1.0f, 10.0f, "%.2f"))
+ {
+ worldStartY = static_cast(startY * static_cast(FP_ONE_GEO));
+ boundsChanged = true;
+ }
+ if (ImGui::InputFloat("World End X", &endX, 1.0f, 10.0f, "%.2f"))
+ {
+ worldEndX = static_cast(endX * static_cast(FP_ONE_GEO));
+ boundsChanged = true;
+ }
+ if (ImGui::InputFloat("World End Y", &endY, 1.0f, 10.0f, "%.2f"))
+ {
+ worldEndY = static_cast(endY * static_cast(FP_ONE_GEO));
+ boundsChanged = true;
+ }
+
+ if (ImGui::Button("Calculate from Quadblocks"))
+ {
+ CalculateWorldBoundsFromQuadblocks(quadblocks);
+ boundsChanged = true;
+ }
+ ImGui::SetItemTooltip("(Experimental) Automatically calculate world bounds from quadblocks with checkpoints");
+
+ ImGui::Separator();
+ ImGui::Text("Driver Icon Start Position (screen %d x %d):", PSX::SCREEN_WIDTH, PSX::SCREEN_HEIGHT);
+ if (ImGui::InputScalar("Icon Start X", ImGuiDataType_S16, &driverDotStartX)) {
+ if (driverDotStartX < 0) driverDotStartX = 0;
+ if (driverDotStartX > PSX::SCREEN_WIDTH) driverDotStartX = PSX::SCREEN_WIDTH;
+ }
+ if (ImGui::InputScalar("Icon Start Y", ImGuiDataType_S16, &driverDotStartY)) {
+ if (driverDotStartY < 0) driverDotStartY = 0;
+ if (driverDotStartY > PSX::SCREEN_HEIGHT) driverDotStartY = PSX::SCREEN_HEIGHT;
+ }
+
+ ImGui::Separator();
+ ImGui::Text("Minimap Orientation:");
+
+ // Orientation mode dropdown
+ const char* orientationModes[] = { "0°", "90°", "180°", "270°" };
+ int currentOrientation = orientationMode;
+ if (currentOrientation < 0 || currentOrientation > 3) { currentOrientation = 0; }
+ if (ImGui::Combo("Relative rotation", ¤tOrientation, orientationModes, 4))
+ {
+ orientationMode = static_cast(currentOrientation);
+ }
+ ImGui::SetItemTooltip("Determines minimap clockwise rotation relative to the world\n It doesnt affect texture orientation, it affects how the driver icon moves on the minimap");
+
+ ImGui::Separator();
+ ImGui::InputScalar("Unknown", ImGuiDataType_S16, &unk);
+ ImGui::SetItemTooltip("???");
+
+ ImGui::Separator();
+ ImGui::Text("Minimap Texture:");
+
+ // Single texture selection
+ std::string sourcePath = sourceTexturePath.empty() ? "(none)" : sourceTexturePath.filename().string();
+ ImGui::Text("Image:"); ImGui::SameLine();
+ ImGui::SetNextItemWidth(250.0f);
+ ImGui::BeginDisabled();
+ ImGui::InputText("##sourcetex", &sourcePath, ImGuiInputTextFlags_ReadOnly);
+ ImGui::EndDisabled();
+ ImGui::SameLine();
+ if (ImGui::Button("Browse##selectsource"))
+ {
+ auto selection = pfd::open_file("Select Minimap Texture (will be split into top/bottom)", ".",
+ {"Image Files", "*.png *.bmp *.jpg *.jpeg", "All Files", "*"}).result();
+ if (!selection.empty())
+ {
+ sourceTexturePath = selection.front();
+ LoadTextures(); // Process the image immediately
+ }
+ }
+ ImGui::SameLine();
+ if (ImGui::Button("Clear##clearsource"))
+ {
+ sourceTexturePath.clear();
+ topTexture = Texture();
+ bottomTexture = Texture();
+ hasTopTexture = false;
+ hasBottomTexture = false;
+ lastError.clear();
+ }
+
+ // Status display
+ ImGui::Separator();
+ if (IsReady())
+ {
+ ImGui::Text("Processed Size (each half): %dx%d pixels", topTexture.GetWidth(), topTexture.GetHeight());
+ ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "Minimap ready!");
+ }
+ else if (!sourceTexturePath.empty())
+ {
+ ImGui::TextColored(ImVec4(1.0f, 0.0f, 0.0f, 1.0f), "Error: %s", lastError.empty() ? "Unknown error" : lastError.c_str());
+ }
+ else
+ {
+ ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.0f, 1.0f), "No minimap texture loaded");
+ }
+
+ return boundsChanged;
+}
diff --git a/src/minimap.h b/src/minimap.h
new file mode 100644
index 0000000..8789285
--- /dev/null
+++ b/src/minimap.h
@@ -0,0 +1,77 @@
+#pragma once
+
+#include "geo.h"
+#include "psx_types.h"
+#include "texture.h"
+
+#include
+#include
+#include
+#include
+
+class Quadblock;
+
+struct MinimapConfig
+{
+ // World coordinate bounds (fixed-point, FP_ONE_GEO = 64)
+ int16_t worldEndX = 0;
+ int16_t worldEndY = 0;
+ int16_t worldStartX = 0;
+ int16_t worldStartY = 0;
+
+ // Icon size is automatically calculated from texture dimensions
+ // (stored for serialization, updated when textures are loaded)
+ int16_t iconSizeX = 0;
+ int16_t iconSizeY = 0;
+
+ // Screen position for driver dots (screen size is 512x252)
+ int16_t driverDotStartX = 450;
+ int16_t driverDotStartY = 180;
+
+ // Orientation mode - determines minimap orientation relative to the world
+ // 0 = Right, 1 = Down, 2 = Left, 3 = Up
+ int16_t orientationMode = 0;
+
+ // Unknown field - used for drawing, needed for some levels like Crash Cove
+ int16_t unk = 0;
+
+ // Texture paths - single source image that will be split and stretched
+ std::filesystem::path sourceTexturePath;
+
+ // Texture objects (generated from source)
+ Texture topTexture;
+ Texture bottomTexture;
+
+ // State flags
+ bool hasTopTexture = false;
+ bool hasBottomTexture = false;
+ bool enabled = false;
+
+ // Last error message from loading textures
+ std::string lastError;
+
+ // Calculate world bounds from all quadblocks in the level
+ void CalculateWorldBoundsFromQuadblocks(const std::vector& quadblocks);
+
+ // Serialize to PSX Map struct
+ PSX::Map Serialize() const;
+
+ // Deserialize from PSX Map struct
+ void Deserialize(const PSX::Map& map);
+
+ // Load textures from paths (call after loading from preset)
+ void LoadTextures();
+
+ // Check if minimap is ready for export
+ bool IsReady() const;
+
+ // Get textures for VRM packing
+ std::vector GetTextures();
+
+ // Render the ImGui UI for minimap configuration
+ // Returns true if world bounds were modified (for updating visualization)
+ bool RenderUI(const std::vector& quadblocks);
+
+ // Clear all minimap data
+ void Clear();
+};
diff --git a/src/psx_types.h b/src/psx_types.h
index 55d9efd..2d91fd4 100644
--- a/src/psx_types.h
+++ b/src/psx_types.h
@@ -238,6 +238,46 @@ namespace PSX
uint32_t offsets[LevelExtra::COUNT];
};
+ // Minimap struct
+ struct Map
+ {
+ int16_t worldEndX; // 0x0 - World coordinate bound
+ int16_t worldEndY; // 0x2
+ int16_t worldStartX; // 0x4
+ int16_t worldStartY; // 0x6
+ int16_t iconSizeX; // 0x8 - Size in pixels of minimap icon (width)
+ int16_t iconSizeY; // 0xA - Size in pixels of minimap icon (height)
+ int16_t driverDotStartX; // 0xC - Screen position for driver markers (512x252 screen)
+ int16_t driverDotStartY; // 0xE
+ int16_t orientationMode; // 0x10 - Orientation mode (0=0°, 1=90°, 2=180°, 3=270°)
+ int16_t unk; // 0x12 - Needed for some levels like Crash Cove (value different from 0 stops drawing top part)
+ };
+
+ // Icon struct for minimap textures
+ struct Icon
+ {
+ char name[16]; // 0x0 - Icon name
+ int32_t globalIconArrayIndex; // 0x10 - Index in global icon array (3=top, 4=bottom)
+ TextureLayout texLayout; // 0x14 - UV and texture page info
+ };
+
+ // LevelIconHeader - Icon pack header, pointed to by Level::levelIconHeader
+ struct LevelIconHeader
+ {
+ int32_t numIcon; // 0x0 - Number of icons (2 for minimap: top and bottom)
+ uint32_t offFirstIcon; // 0x4 - Pointer to first Icon struct
+ int32_t numIconGroup; // 0x8 - Number of icon groups
+ uint32_t offFirstIconGroupPtr; // 0xC - Pointer to IconGroup pointer array
+ };
+
+ // Global icon array indices for minimap
+ static constexpr int32_t ICON_INDEX_MAP_TOP = 3;
+ static constexpr int32_t ICON_INDEX_MAP_BOTTOM = 4;
+
+ // Screen size
+ static constexpr int32_t SCREEN_WIDTH = 512;
+ static constexpr int32_t SCREEN_HEIGHT = 252;
+
struct Vertex
{
PSX::Vec3 pos; // 0x0
diff --git a/src/texture.cpp b/src/texture.cpp
index 7af379b..cbf8cdc 100644
--- a/src/texture.cpp
+++ b/src/texture.cpp
@@ -22,6 +22,44 @@ Texture::Texture(const std::filesystem::path& path)
if (!CreateTexture()) { ClearTexture(); }
}
+Texture Texture::CreateFromPixelData(const unsigned char* pixels, int width, int height)
+{
+ Texture tex;
+ tex.m_width = width;
+ tex.m_height = height;
+ tex.m_blendMode = PSX::BlendMode::HALF_TRANSPARENT;
+
+ // Convert pixel data to texture format
+ std::vector colorIndexes;
+ for (int i = 0; i < width * height; i++)
+ {
+ int px = i * 4; // RGBA format
+ uint16_t color = tex.ConvertColor(pixels[px + 0], pixels[px + 1], pixels[px + 2], pixels[px + 3]);
+ bool foundColor = false;
+ size_t clutIndex = tex.m_clut.size();
+ for (size_t j = 0; j < tex.m_clut.size(); j++)
+ {
+ if (color == tex.m_clut[j]) { clutIndex = j; foundColor = true; break; }
+ }
+ if (!foundColor) { tex.m_clut.push_back(color); }
+ colorIndexes.push_back(clutIndex);
+ }
+
+ Texture::BPP bpp = tex.GetBPP();
+ if (tex.GetVRAMWidth() > TEXPAGE_WIDTH || tex.GetHeight() > TEXPAGE_HEIGHT)
+ {
+ tex.ClearTexture();
+ return tex;
+ }
+
+ if (bpp == Texture::BPP::BPP_4) { tex.ConvertPixels(colorIndexes, 4); }
+ else if (bpp == Texture::BPP::BPP_8) { tex.ConvertPixels(colorIndexes, 2); }
+ else { tex.m_image = tex.m_clut; }
+
+ tex.FillShapes(colorIndexes);
+ return tex;
+}
+
void Texture::UpdateTexture(const std::filesystem::path& path)
{
uint16_t blendMode = m_blendMode;
diff --git a/src/texture.h b/src/texture.h
index da5169a..f740637 100644
--- a/src/texture.h
+++ b/src/texture.h
@@ -21,6 +21,8 @@ class Texture
Texture() : m_width(0), m_height(0), m_imageX(0), m_imageY(0), m_clutX(0), m_clutY(0), m_blendMode(0) {};
Texture(const std::filesystem::path& path);
void UpdateTexture(const std::filesystem::path& path);
+ // Create texture from raw RGBA pixel data (for minimap processing)
+ static Texture CreateFromPixelData(const unsigned char* pixels, int width, int height);
Texture::BPP GetBPP() const;
int GetWidth() const;
int GetVRAMWidth() const;