diff --git a/CMakeSettings.json b/CMakeSettings.json new file mode 100644 index 0000000000..9204f06ebd --- /dev/null +++ b/CMakeSettings.json @@ -0,0 +1,15 @@ +{ + "configurations": [ + { + "name": "x64-Debug", + "generator": "Ninja", + "configurationType": "Debug", + "inheritEnvironments": [ "msvc_x64_x64" ], + "buildRoot": "${projectDir}\\out\\build\\${name}", + "installRoot": "${projectDir}\\out\\install\\${name}", + "cmakeCommandArgs": "", + "buildCommandArgs": "", + "ctestCommandArgs": "" + } + ] +} \ No newline at end of file diff --git a/apps/openmw/CMakeLists.txt b/apps/openmw/CMakeLists.txt index 8570d9df60..bc1723282d 100644 --- a/apps/openmw/CMakeLists.txt +++ b/apps/openmw/CMakeLists.txt @@ -1,6 +1,7 @@ set(OPENMW_SOURCES engine.cpp options.cpp + mapextractor.cpp ) set(OPENMW_RESOURCES diff --git a/apps/openmw/engine.cpp b/apps/openmw/engine.cpp index 8d8a0bc7b9..b58993939f 100644 --- a/apps/openmw/engine.cpp +++ b/apps/openmw/engine.cpp @@ -83,6 +83,7 @@ #include "mwstate/statemanagerimp.hpp" +#include "mapextractor.hpp" #include "profile.hpp" namespace @@ -365,19 +366,20 @@ OMW::Engine::Engine(Files::ConfigurationManager& configurationManager) , mSelectDepthFormatOperation(new SceneUtil::SelectDepthFormatOperation()) , mSelectColorFormatOperation(new SceneUtil::Color::SelectColorFormatOperation()) , mStereoManager(nullptr) - , mSkipMenu(false) - , mUseSound(true) + , mSkipMenu(true) + , mUseSound(false) , mCompileAll(false) , mCompileAllDialogue(false) , mWarningsMode(1) , mScriptConsoleMode(false) , mActivationDistanceOverride(-1) - , mGrab(true) + , mGrab(false) , mExportFonts(false) , mRandomSeed(0) , mNewGame(false) , mCfgMgr(configurationManager) , mGlMaxTextureImageUnits(0) + , mOverwriteMaps(false) { #if SDL_VERSION_ATLEAST(2, 24, 0) SDL_SetHint(SDL_HINT_MAC_OPENGL_ASYNC_DISPATCH, "1"); @@ -836,7 +838,8 @@ void OMW::Engine::prepareEngine() // Create the world mWorld = std::make_unique( - mResourceSystem.get(), mActivationDistanceOverride, mCellName, mCfgMgr.getUserDataPath()); + mResourceSystem.get(), mActivationDistanceOverride, mCellName, mCfgMgr.getUserDataPath(), + mWorldMapOutput, mLocalMapOutput, mOverwriteMaps); mEnvironment.setWorld(*mWorld); mEnvironment.setWorldModel(mWorld->getWorldModel()); mEnvironment.setESMStore(mWorld->getStore()); @@ -960,11 +963,11 @@ void OMW::Engine::go() prepareEngine(); -#ifdef _WIN32 + #ifdef _WIN32 const auto* statsFile = _wgetenv(L"OPENMW_OSG_STATS_FILE"); -#else + #else const auto* statsFile = std::getenv("OPENMW_OSG_STATS_FILE"); -#endif + #endif std::filesystem::path path; if (statsFile != nullptr) @@ -994,6 +997,11 @@ void OMW::Engine::go() if (stats.is_open()) Resource::collectStatistics(*mViewer); + + // Map extractor + std::unique_ptr mapExtractor; + + // Start the game if (!mSaveGameFile.empty()) { @@ -1043,6 +1051,18 @@ void OMW::Engine::go() std::this_thread::sleep_for(std::chrono::milliseconds(5)); continue; } + + // Update map extraction if active + if (mapExtractor) + { + mapExtractor->update(); + if (mapExtractor->isExtractionComplete()) + { + Log(Debug::Info) << "Extraction process completed."; + mapExtractor.reset(); + } + } + timeManager.updateIsPaused(); if (!timeManager.isPaused()) { @@ -1130,3 +1150,18 @@ void OMW::Engine::setRandomSeed(unsigned int seed) { mRandomSeed = seed; } + +void OMW::Engine::setWorldMapOutput(const std::string& path) +{ + mWorldMapOutput = path; +} + +void OMW::Engine::setLocalMapOutput(const std::string& path) +{ + mLocalMapOutput = path; +} + +void OMW::Engine::setOverwriteMaps(bool overwrite) +{ + mOverwriteMaps = overwrite; +} diff --git a/apps/openmw/engine.hpp b/apps/openmw/engine.hpp index 97b6a78ee9..89ab520cba 100644 --- a/apps/openmw/engine.hpp +++ b/apps/openmw/engine.hpp @@ -183,6 +183,10 @@ namespace OMW Translation::Storage mTranslationDataStorage; bool mNewGame; + std::string mWorldMapOutput; + std::string mLocalMapOutput; + bool mOverwriteMaps; + Files::ConfigurationManager& mCfgMgr; int mGlMaxTextureImageUnits; @@ -264,6 +268,14 @@ namespace OMW void setRandomSeed(unsigned int seed); + void setWorldMapOutput(const std::string& path); + + void setLocalMapOutput(const std::string& path); + + void setOverwriteMaps(bool overwrite); + + void setExtractMaps(bool extract); + void setRecastMaxLogLevel(Debug::Level value) { mMaxRecastLogLevel = value; } }; } diff --git a/apps/openmw/main.cpp b/apps/openmw/main.cpp index 70e48e0cfc..f50da3312c 100644 --- a/apps/openmw/main.cpp +++ b/apps/openmw/main.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -72,7 +73,7 @@ bool parseOptions(int argc, char** argv, OMW::Engine& engine, Files::Configurati MWGui::DebugWindow::startLogRecording(); - engine.setGrabMouse(!variables["no-grab"].as()); + engine.setGrabMouse(false); // Font encoding settings std::string encoding(variables["encoding"].as()); @@ -122,14 +123,20 @@ bool parseOptions(int argc, char** argv, OMW::Engine& engine, Files::Configurati for (auto& file : content) { + if (file.ends_with(".omwscripts")) + { + Log(Debug::Warning) << "Skipping omwscripts file in content list: " << file; + continue; + } engine.addContentFile(file); } - StringsVector groundcover = variables["groundcover"].as(); - for (auto& file : groundcover) - { - engine.addGroundcoverFile(file); - } + Log(Debug::Warning) << "Skipping groundcover files."; + //StringsVector groundcover = variables["groundcover"].as(); + //for (auto& file : groundcover) + //{ + // engine.addGroundcoverFile(file); + //} if (variables.count("lua-scripts")) { @@ -139,9 +146,7 @@ bool parseOptions(int argc, char** argv, OMW::Engine& engine, Files::Configurati // startup-settings engine.setCell(variables["start"].as()); - engine.setSkipMenu(variables["skip-menu"].as(), variables["new-game"].as()); - if (!variables["skip-menu"].as() && variables["new-game"].as()) - Log(Debug::Warning) << "Warning: new-game used without skip-menu -> ignoring it"; + engine.setSkipMenu(true, false); // scripts engine.setCompileAll(variables["script-all"].as()); @@ -153,11 +158,34 @@ bool parseOptions(int argc, char** argv, OMW::Engine& engine, Files::Configurati // other settings Fallback::Map::init(variables["fallback"].as().mMap); - engine.setSoundUsage(!variables["no-sound"].as()); + engine.setSoundUsage(false); engine.setActivationDistanceOverride(variables["activate-dist"].as()); engine.enableFontExport(variables["export-fonts"].as()); engine.setRandomSeed(variables["random-seed"].as()); + std::string worldMapOutput = variables["world-map-output"].as(); + std::string localMapOutput = variables["local-map-output"].as(); + + auto removeQuotes = [](std::string& str) { + if (str.size() >= 2 && ((str.front() == '"' && str.back() == '"') || (str.front() == '\'' && str.back() == '\''))) + { + str = str.substr(1, str.size() - 2); + } + }; + + removeQuotes(worldMapOutput); + removeQuotes(localMapOutput); + + if (worldMapOutput.empty()) + worldMapOutput = Files::pathToUnicodeString(std::filesystem::current_path() / "textures" / "advanced_world_map" / "custom"); + + if (localMapOutput.empty()) + localMapOutput = Files::pathToUnicodeString(std::filesystem::current_path() / "textures" / "advanced_world_map" / "local"); + + engine.setWorldMapOutput(worldMapOutput); + engine.setLocalMapOutput(localMapOutput); + engine.setOverwriteMaps(variables["overwrite-maps"].as()); + return true; } @@ -165,13 +193,28 @@ namespace { class OSGLogHandler : public osg::NotifyHandler { + int ignoreNext = 0; + void notify(osg::NotifySeverity severity, const char* msg) override { + if (ignoreNext > 0) + { + --ignoreNext; + return; + } + // Copy, because osg logging is not thread safe. std::string msgCopy(msg); if (msgCopy.empty()) return; + // Ignore because I don't know how to fix it + if (msgCopy.find("CullVisitor::apply(Geode&) detected NaN") != std::string::npos) + { + ignoreNext = 8; + return; + } + Debug::Level level; switch (severity) { diff --git a/apps/openmw/mapextractor.cpp b/apps/openmw/mapextractor.cpp new file mode 100644 index 0000000000..ef44a15d45 --- /dev/null +++ b/apps/openmw/mapextractor.cpp @@ -0,0 +1,685 @@ +#include "mapextractor.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "mwbase/environment.hpp" +#include "mwbase/mechanicsmanager.hpp" +#include "mwbase/windowmanager.hpp" +#include "mwbase/world.hpp" +#include "mwrender/globalmap.hpp" +#include "mwrender/localmap.hpp" +#include "mwrender/renderingmanager.hpp" +#include "mwrender/vismask.hpp" +#include "mwworld/cellstore.hpp" +#include "mwworld/esmstore.hpp" +#include "mwworld/worldimp.hpp" +#include "mwworld/worldmodel.hpp" + +namespace OMW +{ + MapExtractor::MapExtractor(const std::string& worldMapOutput, const std::string& localMapOutput, + bool forceOverwrite, MWRender::RenderingManager* renderingManager, const MWWorld::ESMStore* store) + : mWorldMapOutputDir(worldMapOutput) + , mLocalMapOutputDir(localMapOutput) + , mRenderingManager(renderingManager) + , mStore(store) + , mLocalMap(nullptr) + , mForceOverwrite(forceOverwrite) + { + // Only create directories if paths are not empty + if (!mWorldMapOutputDir.empty()) + std::filesystem::create_directories(mWorldMapOutputDir); + if (!mLocalMapOutputDir.empty()) + std::filesystem::create_directories(mLocalMapOutputDir); + + // Create GlobalMap instance + if (!mRenderingManager) + { + Log(Debug::Error) << "RenderingManager is null in MapExtractor constructor"; + throw std::runtime_error("RenderingManager is null"); + } + + osg::Group* lightRoot = mRenderingManager->getLightRoot(); + if (!lightRoot) + { + Log(Debug::Error) << "LightRoot is null in MapExtractor constructor"; + throw std::runtime_error("LightRoot is null"); + } + + osg::Group* root = lightRoot->getParent(0) ? lightRoot->getParent(0)->asGroup() : nullptr; + if (!root) + { + Log(Debug::Error) << "Root node is null in MapExtractor constructor"; + throw std::runtime_error("Root node is null"); + } + + SceneUtil::WorkQueue* workQueue = mRenderingManager->getWorkQueue(); + if (!workQueue) + { + Log(Debug::Error) << "WorkQueue is null in MapExtractor constructor"; + throw std::runtime_error("WorkQueue is null"); + } + + try + { + mGlobalMap = std::make_unique(root, workQueue); + // LocalMap will be set later via setLocalMap() method + mLocalMap = nullptr; + } + catch (const std::exception& e) + { + Log(Debug::Error) << "Failed to create map objects: " << e.what(); + throw; + } + } + + MapExtractor::~MapExtractor() = default; + + void MapExtractor::extractWorldMap() + { + Log(Debug::Info) << "Extracting world map..."; + + if (mWorldMapOutputDir.empty()) + { + Log(Debug::Warning) << "World map output directory is not set, skipping world map extraction"; + return; + } + + // Create output directory if it doesn't exist + std::filesystem::create_directories(mWorldMapOutputDir); + + if (!mGlobalMap) + { + Log(Debug::Error) << "Global map not initialized"; + throw std::runtime_error("Global map not initialized"); + } + + // Temporarily set cell size to 32 pixels for extraction + const int originalCellSize = Settings::map().mGlobalMapCellSize; + Settings::map().mGlobalMapCellSize.set(32); + + mGlobalMap->render(); + mGlobalMap->ensureLoaded(); + + saveWorldMapTexture(); + saveWorldMapInfo(); + + // Restore original cell size + Settings::map().mGlobalMapCellSize.set(originalCellSize); + + Log(Debug::Info) << "World map extraction complete"; + } + + void MapExtractor::saveWorldMapTexture() + { + osg::ref_ptr baseTexture = mGlobalMap->getBaseTexture(); + if (!baseTexture || !baseTexture->getImage()) + { + Log(Debug::Error) << "Failed to get world map base texture"; + return; + } + + osg::Image* image = baseTexture->getImage(); + std::filesystem::path outputPath = mWorldMapOutputDir / "map.png"; + + if (!osgDB::writeImageFile(*image, outputPath.string())) + { + Log(Debug::Error) << "Failed to write world map texture to " << outputPath; + return; + } + + Log(Debug::Info) << "Saved world map texture: " << outputPath; + } + + void MapExtractor::saveWorldMapInfo() + { + int width = mGlobalMap->getWidth(); + int height = mGlobalMap->getHeight(); + + int minX = std::numeric_limits::max(); + int maxX = std::numeric_limits::min(); + int minY = std::numeric_limits::max(); + int maxY = std::numeric_limits::min(); + + MWWorld::Store::iterator it = mStore->get().extBegin(); + for (; it != mStore->get().extEnd(); ++it) + { + if (it->getGridX() < minX) + minX = it->getGridX(); + if (it->getGridX() > maxX) + maxX = it->getGridX(); + if (it->getGridY() < minY) + minY = it->getGridY(); + if (it->getGridY() > maxY) + maxY = it->getGridY(); + } + + std::filesystem::path infoPath = mWorldMapOutputDir / "mapInfo.yaml"; + std::ofstream file(infoPath); + + if (!file) + { + Log(Debug::Error) << "Failed to create world map info file: " << infoPath; + return; + } + + file << "width: " << width << "\n"; + file << "height: " << height << "\n"; + file << "pixelsPerCell: 32\n"; + file << "gridX:\n"; + file << " min: " << minX << "\n"; + file << " max: " << maxX << "\n"; + file << "gridY:\n"; + file << " min: " << minY << "\n"; + file << " max: " << maxY << "\n"; + file << "file: \"map.png\"\n"; + + file.close(); + + Log(Debug::Info) << "Saved world map info: " << infoPath; + } + + void MapExtractor::extractLocalMaps(const std::vector& activeCells) + { + Log(Debug::Info) << "Extracting active local maps..."; + + if (mLocalMapOutputDir.empty()) + { + Log(Debug::Warning) << "Local map output directory is not set, skipping local map extraction"; + return; + } + + // Create output directory if it doesn't exist + std::filesystem::create_directories(mLocalMapOutputDir); + + if (!mLocalMap) + { + Log(Debug::Error) << "Local map not initialized - cannot extract local maps"; + return; + } + + Log(Debug::Info) << "LocalMap instance is available, starting extraction"; + + mFramesToWait = 10; // Wait 10 frames before checking (increased from 3) + + startExtraction(activeCells); + } + + void MapExtractor::startExtraction(const std::vector& activeCells) + { + // Enable extraction mode to prevent automatic camera cleanup + if (mLocalMap) + { + mLocalMap->setExtractionMode(true); + } + + Log(Debug::Info) << "Processing " << activeCells.size() << " currently active cells..."; + + mPendingExtractions.clear(); + + for (const MWWorld::CellStore* cellStore : activeCells) + { + if (cellStore->getCell()->isExterior()) + { + int x = cellStore->getCell()->getGridX(); + int y = cellStore->getCell()->getGridY(); + + std::ostringstream filename; + filename << "(" << x << "," << y << ").png"; + std::filesystem::path outputPath = mLocalMapOutputDir / filename.str(); + + if (!mForceOverwrite && std::filesystem::exists(outputPath)) + { + Log(Debug::Info) << "Skipping cell (" << x << "," << y << ") - file already exists"; + continue; + } + + PendingExtraction extraction; + extraction.cellStore = cellStore; + extraction.isExterior = true; + extraction.outputPath = outputPath; + extraction.framesWaited = 0; + mPendingExtractions.push_back(extraction); + } + else + { + ESM::RefId cellId = cellStore->getCell()->getId(); + std::string cellName(cellStore->getCell()->getNameId()); + + std::string lowerCaseId = cellId.toDebugString(); + std::transform(lowerCaseId.begin(), lowerCaseId.end(), lowerCaseId.begin(), ::tolower); + + const std::string invalidChars = "/\\:*?\"<>|"; + lowerCaseId.erase(std::remove_if(lowerCaseId.begin(), lowerCaseId.end(), + [&invalidChars](char c) { return invalidChars.find(c) != std::string::npos; }), + lowerCaseId.end() + ); + + if (lowerCaseId.empty()) + { + lowerCaseId = "_unnamed_cell_"; + } + + std::filesystem::path texturePath = mLocalMapOutputDir / (lowerCaseId + ".png"); + std::filesystem::path yamlPath = mLocalMapOutputDir / (lowerCaseId + ".yaml"); + + if (!mForceOverwrite && std::filesystem::exists(yamlPath)) + { + Log(Debug::Info) << "Skipping interior cell: " << cellName << " - files already exist"; + continue; + } + + PendingExtraction extraction; + extraction.cellStore = cellStore; + extraction.isExterior = false; + extraction.outputPath = texturePath; + extraction.framesWaited = 0; + mPendingExtractions.push_back(extraction); + } + } + + if (!mPendingExtractions.empty()) + { + Log(Debug::Info) << "Queued " << mPendingExtractions.size() << " cells for extraction"; + processNextCell(); + } + else + { + Log(Debug::Info) << "No cells to extract"; + // Clean up any cameras that may have been created + if (mLocalMap) + { + mLocalMap->cleanupCameras(); + mLocalMap->setExtractionMode(false); + } + } + } + + void MapExtractor::update() + { + if (mPendingExtractions.empty()) + return; + + PendingExtraction& current = mPendingExtractions.front(); + current.framesWaited++; + + // Wait for the required number of frames before checking + if (current.framesWaited >= mFramesToWait) + { + // Check if the texture is ready before trying to save + bool textureReady = false; + + if (current.isExterior) + { + int x = current.cellStore->getCell()->getGridX(); + int y = current.cellStore->getCell()->getGridY(); + + // Check if the rendered image is ready + osg::ref_ptr image = mLocalMap->getMapImage(x, y); + + if (image && image->s() > 0 && image->t() > 0 && image->data() != nullptr) + { + textureReady = true; + } + else if (current.framesWaited <= 120) + { + return; + } + } + else + { + // For interior cells, check if at least one segment has a valid rendered image + MyGUI::IntRect grid = mLocalMap->getInteriorGrid(); + for (int x = grid.left; x < grid.right && !textureReady; ++x) + { + for (int y = grid.top; y < grid.bottom && !textureReady; ++y) + { + osg::ref_ptr image = mLocalMap->getMapImage(x, y); + if (image && image->s() > 0 && image->t() > 0 && image->data() != nullptr) + { + textureReady = true; + } + } + } + + + if (!textureReady && current.framesWaited <= 120) + { + return; + } + } + + if (textureReady) + { + savePendingExtraction(current); + + mPendingExtractions.erase(mPendingExtractions.begin()); + + // Clean up the camera for the current cell immediately after saving + // This prevents memory buildup when processing many cells + if (mLocalMap) + mLocalMap->cleanupCameras(); + + if (!mPendingExtractions.empty()) + { + processNextCell(); + } + else + { + // Disable extraction mode + mLocalMap->setExtractionMode(false); + Log(Debug::Info) << "Extraction of active local maps complete"; + } + } + else if (current.framesWaited > 120) + { + // If we've waited too long (120 frames = ~2 seconds at 60 fps), skip this cell + if (current.isExterior) + { + int x = current.cellStore->getCell()->getGridX(); + int y = current.cellStore->getCell()->getGridY(); + Log(Debug::Warning) << "Timeout waiting for texture for cell (" << x << "," << y << "), skipping"; + } + else + { + std::string cellName(current.cellStore->getCell()->getNameId()); + Log(Debug::Warning) << "Timeout waiting for texture for interior cell: " << cellName << ", skipping"; + } + + mPendingExtractions.erase(mPendingExtractions.begin()); + + // Clean up cameras even after timeout + if (mLocalMap) + mLocalMap->cleanupCameras(); + + if (!mPendingExtractions.empty()) + { + processNextCell(); + } + else + { + // Disable extraction mode + mLocalMap->setExtractionMode(false); + Log(Debug::Info) << "Extraction of active local maps complete"; + } + } + // Otherwise keep waiting for the texture to be ready + } + } + + bool MapExtractor::isExtractionComplete() const + { + return mPendingExtractions.empty(); + } + + void MapExtractor::processNextCell() + { + if (mPendingExtractions.empty()) + return; + + const PendingExtraction& extraction = mPendingExtractions.front(); + + if (extraction.isExterior) + { + int x = extraction.cellStore->getCell()->getGridX(); + int y = extraction.cellStore->getCell()->getGridY(); + mLocalMap->clearCellCache(x, y); + } + + mLocalMap->requestMap(const_cast(extraction.cellStore)); + } + + bool MapExtractor::savePendingExtraction(const PendingExtraction& extraction) + { + if (extraction.isExterior) + { + return extractExteriorCell(extraction.cellStore, mForceOverwrite); + } + else + { + return extractInteriorCell(extraction.cellStore, mForceOverwrite); + } + } + + bool MapExtractor::extractExteriorCell(const MWWorld::CellStore* cellStore, bool forceOverwrite) + { + int x = cellStore->getCell()->getGridX(); + int y = cellStore->getCell()->getGridY(); + + std::ostringstream filename; + filename << "(" << x << "," << y << ").png"; + std::filesystem::path outputPath = mLocalMapOutputDir / filename.str(); + + osg::ref_ptr image = mLocalMap->getMapImage(x, y); + + if (!image) + { + Log(Debug::Warning) << "Texture for cell (" << x << "," << y << ") has no image data attached"; + return false; + } + + if (image->s() == 0 || image->t() == 0) + { + Log(Debug::Warning) << "Empty image for cell (" << x << "," << y << ") - size: " + << image->s() << "x" << image->t(); + return false; + } + + osg::ref_ptr outputImage = new osg::Image(*image, osg::CopyOp::DEEP_COPY_ALL); + + if (outputImage->s() != 256 || outputImage->t() != 256) + { + osg::ref_ptr resized = new osg::Image; + resized->allocateImage(256, 256, 1, outputImage->getPixelFormat(), outputImage->getDataType()); + outputImage->scaleImage(256, 256, 1); + outputImage = resized; + } + if (osgDB::writeImageFile(*outputImage, outputPath.string())) + { + Log(Debug::Info) << "Successfully saved local map for cell (" << x << "," << y << ") to " << outputPath; + return true; + } + else + { + Log(Debug::Warning) << "Failed to write texture for cell (" << x << "," << y << ") to " << outputPath; + return false; + } + } + + bool MapExtractor::extractInteriorCell(const MWWorld::CellStore* cellStore, bool forceOverwrite) + { + ESM::RefId cellId = cellStore->getCell()->getId(); + std::string cellName(cellStore->getCell()->getNameId()); + + Log(Debug::Info) << "Saving interior cell: " << cellName; + + saveInteriorCellTextures(cellId, cellName); + return true; + } + + void MapExtractor::saveInteriorCellTextures(const ESM::RefId& cellId, const std::string& cellName) + { + MyGUI::IntRect grid = mLocalMap->getInteriorGrid(); + + std::string lowerCaseId = cellId.toDebugString(); + std::transform(lowerCaseId.begin(), lowerCaseId.end(), lowerCaseId.begin(), ::tolower); + + const std::string invalidChars = "/\\:*?\"<>|"; + lowerCaseId.erase(std::remove_if(lowerCaseId.begin(), lowerCaseId.end(), + [&invalidChars](char c) { return invalidChars.find(c) != std::string::npos; }), + lowerCaseId.end() + ); + + if (lowerCaseId.empty()) + { + lowerCaseId = "_unnamed_cell_"; + } + + int minX = grid.right; + int maxX = grid.left; + int minY = grid.bottom; + int maxY = grid.top; + + // Cache images and find bounds + std::map, osg::ref_ptr> imageCache; + + for (int x = grid.left; x < grid.right; ++x) + { + for (int y = grid.top; y < grid.bottom; ++y) + { + // Get the rendered image directly from camera attachment + osg::ref_ptr image = mLocalMap->getMapImage(x, y); + if (image && image->s() > 0 && image->t() > 0 && image->data() != nullptr) + { + imageCache[{x, y}] = image; + minX = std::min(minX, x); + maxX = std::max(maxX, x); + minY = std::min(minY, y); + maxY = std::max(maxY, y); + } + } + } + + if (minX > maxX || minY > maxY) + { + Log(Debug::Warning) << "No valid image segments found for interior cell: " << cellName; + return; + } + + int segmentsX = maxX - minX + 1; + int segmentsY = maxY - minY + 1; + + if (segmentsX <= 0 || segmentsY <= 0) + { + Log(Debug::Warning) << "Invalid segment dimensions for interior cell: " << cellName; + return; + } + + int totalWidth = segmentsX * 256; + int totalHeight = segmentsY * 256; + + osg::ref_ptr combinedImage = new osg::Image; + combinedImage->allocateImage(totalWidth, totalHeight, 1, GL_RGB, GL_UNSIGNED_BYTE); + + unsigned char* data = combinedImage->data(); + memset(data, 0, totalWidth * totalHeight * 3); + + for (int x = minX; x <= maxX; ++x) + { + for (int y = minY; y <= maxY; ++y) + { + auto it = imageCache.find({x, y}); + if (it == imageCache.end()) + continue; + + osg::ref_ptr segmentImage = it->second; + int segWidth = segmentImage->s(); + int segHeight = segmentImage->t(); + + int destX = (x - minX) * 256; + int destY = (y - minY) * 256; + + for (int sy = 0; sy < std::min(segHeight, 256); ++sy) + { + for (int sx = 0; sx < std::min(segWidth, 256); ++sx) + { + unsigned char* srcPixel = segmentImage->data(sx, sy); + int dx = destX + sx; + int dy = destY + sy; + + if (dx < totalWidth && dy < totalHeight) + { + unsigned char* destPixel = data + ((dy * totalWidth + dx) * 3); + destPixel[0] = srcPixel[0]; + destPixel[1] = srcPixel[1]; + destPixel[2] = srcPixel[2]; + } + } + } + } + } + + std::filesystem::path texturePath = mLocalMapOutputDir / (lowerCaseId + ".png"); + + if (osgDB::writeImageFile(*combinedImage, texturePath.string())) + { + Log(Debug::Info) << "Successfully saved interior map to " << texturePath; + } + else + { + Log(Debug::Error) << "Failed to write interior map to " << texturePath; + return; + } + + saveInteriorMapInfo(cellId, lowerCaseId, segmentsX, segmentsY); + } + + void MapExtractor::saveInteriorMapInfo(const ESM::RefId& cellId, const std::string& lowerCaseId, + int segmentsX, int segmentsY) + { + // Get the bounds, center and angle that LocalMap actually used for rendering + const osg::BoundingBox& bounds = mLocalMap->getInteriorBounds(); + const osg::Vec2f& center = mLocalMap->getInteriorCenter(); + const float nA = mLocalMap->getInteriorAngle(); + + osg::Vec2f min(bounds.xMin(), bounds.yMin()); + + const float mapWorldSize = Constants::CellSizeInUnits; + + // Calculate position of world origin (0,0) on the rotated map + osg::Vec2f toOrigin(0.0f - center.x(), 0.0f - center.y()); + float rotatedX = toOrigin.x() * std::cos(nA) - toOrigin.y() * std::sin(nA) + center.x(); + float rotatedY = toOrigin.x() * std::sin(nA) + toOrigin.y() * std::cos(nA) + center.y(); + + // Convert to texture coordinates (pixels from bottom-left corner) + float oX = (rotatedX - min.x()) / mapWorldSize * 256.0f; + float oY = (rotatedY - min.y()) / mapWorldSize * 256.0f; + + float totalHeight = segmentsY * 256.0f; + + std::filesystem::path yamlPath = mLocalMapOutputDir / (lowerCaseId + ".yaml"); + std::ofstream file(yamlPath); + + if (!file) + { + Log(Debug::Error) << "Failed to create interior map info file: " << yamlPath; + return; + } + + // TODO: Replace hardcoded padding with a constant from LocalMap + // Padding value taken from the implementation in localmap.cpp. + // Used to shrink the bounds to match the one used during cell rendering. + const float padding = 500.0f; + + file << "v: 2\n"; + file << "nA: " << nA << "\n"; + file << "oX: " << oX << "\n"; + file << "oY: " << oY << "\n"; + file << "wT: " << segmentsX << "\n"; + file << "hT: " << segmentsY << "\n"; + file << "mBnds:\n"; + file << " min: [" << bounds.xMin() + padding << ", " << bounds.yMin() + padding << ", " << bounds.zMin() << "]\n"; + file << " max: [" << bounds.xMax() - padding << ", " << bounds.yMax() - padding << ", " << bounds.zMax() << "]\n"; + + file.close(); + } +} diff --git a/apps/openmw/mapextractor.hpp b/apps/openmw/mapextractor.hpp new file mode 100644 index 0000000000..9be1fcbfc4 --- /dev/null +++ b/apps/openmw/mapextractor.hpp @@ -0,0 +1,103 @@ +#ifndef OPENMW_APPS_OPENMW_MAPEXTRACTOR_HPP +#define OPENMW_APPS_OPENMW_MAPEXTRACTOR_HPP + +#include +#include +#include +#include +#include + +#include + +namespace osg +{ + class Group; +} + +namespace osgViewer +{ + class Viewer; +} + +namespace SceneUtil +{ + class WorkQueue; +} + +namespace MWRender +{ + class GlobalMap; + class LocalMap; + class RenderingManager; +} + +namespace MWWorld +{ + class World; + class CellStore; + class ESMStore; +} + +namespace MWBase +{ + class WindowManager; +} + +namespace OMW +{ + class MapExtractor + { + public: + MapExtractor(const std::string& worldMapOutput, const std::string& localMapOutput, bool forceOverwrite, + MWRender::RenderingManager* renderingManager, const MWWorld::ESMStore* store); + ~MapExtractor(); + + void setLocalMap(MWRender::LocalMap* localMap) { mLocalMap = localMap; } + + void extractWorldMap(); + void extractLocalMaps(const std::vector& activeCells); + + // Called every frame to process pending extractions + void update(); + + // Check if extraction is complete + bool isExtractionComplete() const; + + private: + struct PendingExtraction + { + const MWWorld::CellStore* cellStore; + bool isExterior; + std::filesystem::path outputPath; + int framesWaited; + std::function completionCallback; + }; + + std::filesystem::path mWorldMapOutputDir; + std::filesystem::path mLocalMapOutputDir; + MWRender::RenderingManager* mRenderingManager; + const MWWorld::ESMStore* mStore; + + std::unique_ptr mGlobalMap; + MWRender::LocalMap* mLocalMap; + + std::vector mPendingExtractions; + int mFramesToWait; + bool mForceOverwrite; + + void saveWorldMapTexture(); + void saveWorldMapInfo(); + + void startExtraction(const std::vector& activeCells); + void processNextCell(); + bool savePendingExtraction(const PendingExtraction& extraction); + + bool extractExteriorCell(const MWWorld::CellStore* cellStore, bool forceOverwrite); + bool extractInteriorCell(const MWWorld::CellStore* cellStore, bool forceOverwrite); + void saveInteriorCellTextures(const ESM::RefId& cellId, const std::string& cellName); + void saveInteriorMapInfo(const ESM::RefId& cellId, const std::string& lowerCaseId, + int segmentsX, int segmentsY); + }; +} + +#endif diff --git a/apps/openmw/mwbase/windowmanager.hpp b/apps/openmw/mwbase/windowmanager.hpp index 8385e85b5f..b902e278a6 100644 --- a/apps/openmw/mwbase/windowmanager.hpp +++ b/apps/openmw/mwbase/windowmanager.hpp @@ -91,6 +91,11 @@ namespace MWGui struct TextColours; } +namespace MWRender +{ + class LocalMap; +} + namespace SFO { class CursorManager; @@ -406,6 +411,9 @@ namespace MWBase virtual bool isWindowVisible(std::string_view windowId) const = 0; virtual std::vector getAllWindowIds() const = 0; virtual std::vector getAllowedWindowIds(MWGui::GuiMode mode) const = 0; + + // For map extraction + virtual MWRender::LocalMap* getLocalMapRender() = 0; }; } diff --git a/apps/openmw/mwbase/world.hpp b/apps/openmw/mwbase/world.hpp index 23fb98063f..531921f1c8 100644 --- a/apps/openmw/mwbase/world.hpp +++ b/apps/openmw/mwbase/world.hpp @@ -606,6 +606,24 @@ namespace MWBase virtual MWWorld::DateTimeManager* getTimeManager() = 0; virtual void setActorActive(const MWWorld::Ptr& ptr, bool value) = 0; + + virtual std::string getWorldMapOutputPath() const = 0; + ///< Get the world map output path from options or default + + virtual std::string getLocalMapOutputPath() const = 0; + ///< Get the local map output path from options or default + + virtual bool getOverwriteMaps() const = 0; + ///< Get the overwrite maps flag + + virtual void extractWorldMap() = 0; + ///< Extract world map using path from options or default + + virtual void extractLocalMaps() = 0; + ///< Extract local maps using path from options or default + + virtual bool isMapExtractionActive() const = 0; + ///< Check if map extraction is currently in progress }; } diff --git a/apps/openmw/mwgui/mapwindow.cpp b/apps/openmw/mwgui/mapwindow.cpp index 637cb7e343..0f5c83be2b 100644 --- a/apps/openmw/mwgui/mapwindow.cpp +++ b/apps/openmw/mwgui/mapwindow.cpp @@ -1159,7 +1159,9 @@ namespace MWGui void MapWindow::cellExplored(int x, int y) { - mGlobalMapRender->cleanupCameras(); + // Note: Don't cleanup cameras here! This is called frequently during gameplay + // and would interfere with map extraction which batches multiple camera renders + // mGlobalMapRender->cleanupCameras(); mGlobalMapRender->exploreCell(x, y, mLocalMapRender->getMapTexture(x, y)); } @@ -1167,6 +1169,10 @@ namespace MWGui { LocalMapBase::onFrame(dt); NoDrop::onFrame(dt); + + // Note: Don't cleanup cameras here during normal gameplay + // Cameras are cleaned up only when explicitly requested (e.g., after map extraction) + // For global map overlay updates, cleanup happens in cellExplored() when needed } void MapWindow::setGlobalMapMarkerTooltip(MyGUI::Widget* markerWidget, int x, int y) diff --git a/apps/openmw/mwgui/windowmanagerimp.cpp b/apps/openmw/mwgui/windowmanagerimp.cpp index ffc0705b03..a1f63dd9d8 100644 --- a/apps/openmw/mwgui/windowmanagerimp.cpp +++ b/apps/openmw/mwgui/windowmanagerimp.cpp @@ -1040,8 +1040,8 @@ namespace MWGui mToolTips->onFrame(frameDuration); - if (mLocalMapRender) - mLocalMapRender->cleanupCameras(); + //if (mLocalMapRender) + // mLocalMapRender->cleanupCameras(); mDebugWindow->onFrame(frameDuration); @@ -2697,4 +2697,9 @@ namespace MWGui else mInventoryTabsOverlay->setVisible(false); } + + MWRender::LocalMap* WindowManager::getLocalMapRender() + { + return mLocalMapRender.get(); + } } diff --git a/apps/openmw/mwgui/windowmanagerimp.hpp b/apps/openmw/mwgui/windowmanagerimp.hpp index 03f0ea5572..fb9e8a8e80 100644 --- a/apps/openmw/mwgui/windowmanagerimp.hpp +++ b/apps/openmw/mwgui/windowmanagerimp.hpp @@ -409,6 +409,9 @@ namespace MWGui std::vector getAllWindowIds() const override; std::vector getAllowedWindowIds(GuiMode mode) const override; + // For map extraction + MWRender::LocalMap* getLocalMapRender() override; + private: unsigned int mOldUpdateMask; unsigned int mOldCullMask; diff --git a/apps/openmw/mwlua/worldbindings.cpp b/apps/openmw/mwlua/worldbindings.cpp index ae7ede69d2..4c577b1a69 100644 --- a/apps/openmw/mwlua/worldbindings.cpp +++ b/apps/openmw/mwlua/worldbindings.cpp @@ -1,5 +1,7 @@ #include "worldbindings.hpp" +#include + #include #include #include @@ -247,6 +249,103 @@ namespace MWLua api["vfx"] = initWorldVfxBindings(context); + api["extractWorldMap"] = [context, lua = context.mLua]() { + checkGameInitialized(lua); + context.mLuaManager->addAction( + [] { + MWBase::Environment::get().getWorld()->extractWorldMap(); + }, + "extractWorldMapAction"); + }; + + api["extractLocalMaps"] = [context, lua = context.mLua]() { + checkGameInitialized(lua); + context.mLuaManager->addAction( + [] { + MWBase::Environment::get().getWorld()->extractLocalMaps(); + }, + "extractLocalMapsAction"); + }; + + api["enableExtractionMode"] = [context, lua = context.mLua]() { + checkGameInitialized(lua); + context.mLuaManager->addAction( + [] { + auto world = MWBase::Environment::get().getWorld(); + world->toggleCollisionMode(); + MWBase::Environment::get().getMechanicsManager()->toggleAI(); + world->toggleScripts(); + world->toggleGodMode(); + world->toggleVanityMode(false); + }, + "enableExtractionModeAction"); + }; + + api["disableExtractionMode"] = [context, lua = context.mLua]() { + checkGameInitialized(lua); + context.mLuaManager->addAction( + [] { + auto world = MWBase::Environment::get().getWorld(); + if (!world->getGodModeState()) + world->toggleGodMode(); + if (!world->getScriptsEnabled()) + world->toggleScripts(); + if (!MWBase::Environment::get().getMechanicsManager()->isAIActive()) + MWBase::Environment::get().getMechanicsManager()->toggleAI(); + world->toggleCollisionMode(); + world->toggleVanityMode(false); + }, + "disableExtractionModeAction"); + }; + + api["isMapExtractionActive"] = [lua = context.mLua]() -> bool { + checkGameInitialized(lua); + return MWBase::Environment::get().getWorld()->isMapExtractionActive(); + }; + + api["getOverwriteFlag"] = [lua = context.mLua]() -> bool { + checkGameInitialized(lua); + return MWBase::Environment::get().getWorld()->getOverwriteMaps(); + }; + + api["getExistingLocalMapIds"] = [lua = context.mLua](sol::this_state luaState) -> sol::table { + checkGameInitialized(lua); + sol::state_view state(luaState); + sol::table result = state.create_table(); + + std::string localMapPath = MWBase::Environment::get().getWorld()->getLocalMapOutputPath(); + std::filesystem::path dir(localMapPath); + + if (!std::filesystem::exists(dir) || !std::filesystem::is_directory(dir)) + return result; + + // Use set to store unique filenames (without extension) + std::set uniqueNames; + + for (const auto& entry : std::filesystem::directory_iterator(dir)) + { + if (entry.is_regular_file()) + { + std::string ext = entry.path().extension().string(); + // Check for .yaml, .png, or .tga extensions + if (ext == ".yaml" || ext == ".png" || ext == ".tga") + { + std::string filename = entry.path().stem().string(); + uniqueNames.insert(filename); + } + } + } + + // Convert set to lua table + int index = 1; + for (const auto& name : uniqueNames) + { + result[index++] = name; + } + + return result; + }; + return LuaUtil::makeReadOnly(api); } } diff --git a/apps/openmw/mwrender/globalmap.cpp b/apps/openmw/mwrender/globalmap.cpp index 9606730e7f..20128ff837 100644 --- a/apps/openmw/mwrender/globalmap.cpp +++ b/apps/openmw/mwrender/globalmap.cpp @@ -154,30 +154,59 @@ namespace MWRender { const ESM::Land* land = mLandStore.search(x, y); - for (int cellY = 0; cellY < mCellSize; ++cellY) + if (land != nullptr && (land->mDataTypes & ESM::Land::DATA_VHGT)) { - for (int cellX = 0; cellX < mCellSize; ++cellX) + land->loadData(ESM::Land::DATA_VHGT); + const ESM::Land::LandData* data = land->getLandData(ESM::Land::DATA_VHGT); + + if (data) { - int vertexX = (cellX * 9) / mCellSize; // 0..8 - int vertexY = (cellY * 9) / mCellSize; // 0..8 + const int vhgtSize = 65; - int texelX = (x - mMinX) * mCellSize + cellX; - int texelY = (y - mMinY) * mCellSize + cellY; + for (int cellY = 0; cellY < mCellSize; ++cellY) + { + for (int cellX = 0; cellX < mCellSize; ++cellX) + { + int vx = (cellX * (vhgtSize - 1)) / mCellSize; + int vy = (cellY * (vhgtSize - 1)) / mCellSize; - int lutIndex = 0; - // Converting [-128; 127] WNAM range to [0; 255] index - if (land != nullptr && (land->mDataTypes & ESM::Land::DATA_WNAM)) - lutIndex = static_cast(land->mWnam[vertexY * 9 + vertexX]) + 128; + float height = data->mHeights[vy * vhgtSize + vx] / 128.0f; - // Use getColor to handle all pixel format conversions automatically - osg::Vec4 color = mColorLut->getColor(lutIndex, 0); + // Convert height to LUT index using the same method as WNAM generation + // Normalize height: positive heights divided by 128, negative by 16 + float normalizedHeight = height / (height > 0.0f ? 128.0f : 16.0f); + // Clamp to [-1, 1] range and convert to [0, 255] index + int lutIndex = static_cast(std::clamp(normalizedHeight, -1.0f, 1.0f) * 127.0f) + 128; - // Use setColor to write to output images - image->setColor(color, texelX, texelY); + int texelX = (x - mMinX) * mCellSize + cellX; + int texelY = (y - mMinY) * mCellSize + cellY; - // Set alpha based on lutIndex threshold - osg::Vec4 alpha(0.0f, 0.0f, 0.0f, lutIndex < 128 ? 0.0f : 1.0f); - alphaImage->setColor(alpha, texelX, texelY); + osg::Vec4 color = mColorLut->getColor(lutIndex, 0); + image->setColor(color, texelX, texelY); + + osg::Vec4 alpha(0.0f, 0.0f, 0.0f, lutIndex < 128 ? 0.0f : 1.0f); + alphaImage->setColor(alpha, texelX, texelY); + } + } + } + } + else + { + for (int cellY = 0; cellY < mCellSize; ++cellY) + { + for (int cellX = 0; cellX < mCellSize; ++cellX) + { + int texelX = (x - mMinX) * mCellSize + cellX; + int texelY = (y - mMinY) * mCellSize + cellY; + + int lutIndex = 0; + osg::Vec4 color = mColorLut->getColor(lutIndex, 0); + image->setColor(color, texelX, texelY); + + // Set alpha based on lutIndex threshold + osg::Vec4 alpha(0.0f, 0.0f, 0.0f, lutIndex < 128 ? 0.0f : 1.0f); + alphaImage->setColor(alpha, texelX, texelY); + } } } } diff --git a/apps/openmw/mwrender/localmap.cpp b/apps/openmw/mwrender/localmap.cpp index 39d088084f..28abe98c4d 100644 --- a/apps/openmw/mwrender/localmap.cpp +++ b/apps/openmw/mwrender/localmap.cpp @@ -169,14 +169,15 @@ namespace MWRender void LocalMap::setupRenderToTexture( int segmentX, int segmentY, float left, float top, const osg::Vec3d& upVector, float zmin, float zmax) { - mLocalMapRTTs.emplace_back( - new LocalMapRenderToTexture(mSceneRoot, mMapResolution, mMapWorldSize, left, top, upVector, zmin, zmax)); + auto rttNode = new LocalMapRenderToTexture(mSceneRoot, mMapResolution, mMapWorldSize, left, top, upVector, zmin, zmax); + mLocalMapRTTs.emplace_back(rttNode); mRoot->addChild(mLocalMapRTTs.back()); MapSegment& segment = mInterior ? mInteriorSegments[std::make_pair(segmentX, segmentY)] : mExteriorSegments[std::make_pair(segmentX, segmentY)]; segment.mMapTexture = static_cast(mLocalMapRTTs.back()->getColorTexture(nullptr)); + segment.mRTT = rttNode; // Store reference to RTT node } void LocalMap::requestMap(const MWWorld::CellStore* cell) @@ -239,6 +240,36 @@ namespace MWRender return found->second.mFogOfWarTexture; } + osg::ref_ptr LocalMap::getMapImage(int x, int y) + { + auto& segments(mInterior ? mInteriorSegments : mExteriorSegments); + SegmentMap::iterator found = segments.find(std::make_pair(x, y)); + if (found == segments.end()) + return osg::ref_ptr(); + + MapSegment& segment = found->second; + + if (!segment.mRTT) + return osg::ref_ptr(); + + osg::Camera* camera = segment.mRTT->getCamera(nullptr); + if (!camera) + return osg::ref_ptr(); + + const osg::Camera::BufferAttachmentMap& attachments = camera->getBufferAttachmentMap(); + auto it = attachments.find(osg::Camera::COLOR_BUFFER); + if (it != attachments.end()) + { + osg::Image* img = it->second._image.get(); + if (img && img->s() > 0 && img->t() > 0 && img->data() != nullptr) + { + return img; + } + } + + return osg::ref_ptr(); + } + void LocalMap::cleanupCameras() { auto it = mLocalMapRTTs.begin(); @@ -475,6 +506,21 @@ namespace MWRender return mRoot; } + void LocalMap::setExtractionMode(bool enabled) + { + mExtractionMode = enabled; + } + + void LocalMap::clearCellCache(int x, int y) + { + auto it = mExteriorSegments.find(std::make_pair(x, y)); + if (it != mExteriorSegments.end()) + { + // Reset the render flags to force re-rendering + it->second.mLastRenderNeighbourFlags = 0; + } + } + void LocalMap::updatePlayer(const osg::Vec3f& position, const osg::Quat& orientation, float& u, float& v, int& x, int& y, osg::Vec3f& direction) { @@ -774,6 +820,21 @@ namespace MWRender camera->addChild(lightSource); camera->addChild(mSceneRoot); + + // CRITICAL: Attach an Image to COLOR_BUFFER to enable pixel readback from FBO + // This is required for map extraction functionality + // The image MUST be pre-allocated before attaching to the camera + osg::ref_ptr image = new osg::Image; + + // Get the texture size from the camera's viewport + const osg::Viewport* vp = camera->getViewport(); + int width = vp ? vp->width() : 512; + int height = vp ? vp->height() : 512; + + // Allocate the image with the same format as the color buffer + image->allocateImage(width, height, 1, GL_RGB, GL_UNSIGNED_BYTE); + + camera->attach(osg::Camera::COLOR_BUFFER, image); } void CameraLocalUpdateCallback::operator()(LocalMapRenderToTexture* node, osg::NodeVisitor* nv) diff --git a/apps/openmw/mwrender/localmap.hpp b/apps/openmw/mwrender/localmap.hpp index c5b63766b7..f7201bf6df 100644 --- a/apps/openmw/mwrender/localmap.hpp +++ b/apps/openmw/mwrender/localmap.hpp @@ -62,6 +62,12 @@ namespace MWRender osg::ref_ptr getMapTexture(int x, int y); osg::ref_ptr getFogOfWarTexture(int x, int y); + + /** + * Get the rendered map image for a cell (for extraction purposes) + * Returns the osg::Image that contains the rendered pixel data + */ + osg::ref_ptr getMapImage(int x, int y); /** * Removes cameras that have already been rendered. Should be called every frame to ensure that @@ -97,9 +103,36 @@ namespace MWRender */ bool isPositionExplored(float nX, float nY, int x, int y); + /** + * Clear the render cache for a specific exterior cell, forcing it to be re-rendered on next request + */ + void clearCellCache(int x, int y); + osg::Group* getRoot(); MyGUI::IntRect getInteriorGrid() const; + + /** + * Enable/disable extraction mode. When enabled, cameras won't be automatically cleaned up + * after rendering, allowing batch extraction of multiple maps. + */ + void setExtractionMode(bool enabled); + bool isExtractionMode() const { return mExtractionMode; } + + /** + * Get interior map bounds (with padding applied) - for map extraction + */ + const osg::BoundingBox& getInteriorBounds() const { return mBounds; } + + /** + * Get interior map center after rotation - for map extraction + */ + const osg::Vec2f& getInteriorCenter() const { return mCenter; } + + /** + * Get interior map rotation angle - for map extraction + */ + float getInteriorAngle() const { return mAngle; } private: osg::ref_ptr mRoot; @@ -132,6 +165,7 @@ namespace MWRender osg::ref_ptr mMapTexture; osg::ref_ptr mFogOfWarTexture; osg::ref_ptr mFogOfWarImage; + osg::ref_ptr mRTT; // Reference to the RTT node for this segment }; typedef std::map, MapSegment> SegmentMap; @@ -160,6 +194,7 @@ namespace MWRender osg::BoundingBox mBounds; osg::Vec2f mCenter; bool mInterior; + bool mExtractionMode = false; std::uint8_t getExteriorNeighbourFlags(int cellX, int cellY) const; }; diff --git a/apps/openmw/mwrender/renderingmanager.hpp b/apps/openmw/mwrender/renderingmanager.hpp index f9ff9f5945..6392ec8289 100644 --- a/apps/openmw/mwrender/renderingmanager.hpp +++ b/apps/openmw/mwrender/renderingmanager.hpp @@ -237,6 +237,7 @@ namespace MWRender // camera stuff Camera* getCamera() { return mCamera.get(); } + osgViewer::Viewer* getViewer() { return mViewer.get(); } /// temporarily override the field of view with given value. void overrideFieldOfView(float val); diff --git a/apps/openmw/mwworld/worldimp.cpp b/apps/openmw/mwworld/worldimp.cpp index fcb479db45..89932b5d72 100644 --- a/apps/openmw/mwworld/worldimp.cpp +++ b/apps/openmw/mwworld/worldimp.cpp @@ -93,6 +93,8 @@ #include "../mwsound/constants.hpp" +#include "../mapextractor.hpp" + #include "actionteleport.hpp" #include "cellstore.hpp" #include "containerstore.hpp" @@ -249,7 +251,7 @@ namespace MWWorld } World::World(Resource::ResourceSystem* resourceSystem, int activationDistanceOverride, const std::string& startCell, - const std::filesystem::path& userDataPath) + const std::filesystem::path& userDataPath, const std::string& worldMapOutputPath, const std::string& localMapOutputPath, bool overwriteMaps) : mResourceSystem(resourceSystem) , mLocalScripts(mStore) , mWorldModel(mStore, mReaders) @@ -261,6 +263,9 @@ namespace MWWorld , mUserDataPath(userDataPath) , mActivationDistanceOverride(activationDistanceOverride) , mStartCell(startCell) + , mWorldMapOutputPath(worldMapOutputPath) + , mLocalMapOutputPath(localMapOutputPath) + , mOverwriteMaps(overwriteMaps) , mSwimHeightScale(0.f) , mDistanceToFocusObject(-1.f) , mTeleportEnabled(true) @@ -1658,6 +1663,16 @@ namespace MWWorld if (mGoToJail && !paused) goToJail(); + // Update map extraction if active + if (mMapExtractor) + { + mMapExtractor->update(); + if (mMapExtractor->isExtractionComplete()) + { + mMapExtractor.reset(); + } + } + // Reset "traveling" flag - there was a frame to detect traveling. mPlayerTraveling = false; @@ -3899,4 +3914,36 @@ namespace MWWorld if (MWPhysics::Actor* const actor = mPhysics->getActor(ptr)) actor->setActive(value); } + + void World::extractWorldMap() + { + if (!mMapExtractor) + { + mMapExtractor = std::make_unique( + mWorldMapOutputPath, mLocalMapOutputPath, mOverwriteMaps, mRendering.get(), &mStore); + } + mMapExtractor->extractWorldMap(); + } + + void World::extractLocalMaps() + { + if (!mMapExtractor) + { + mMapExtractor = std::make_unique( + mWorldMapOutputPath, mLocalMapOutputPath, mOverwriteMaps, mRendering.get(), &mStore); + } + // Set LocalMap from WindowManager + if (auto* localMap = MWBase::Environment::get().getWindowManager()->getLocalMapRender()) + { + mMapExtractor->setLocalMap(localMap); + } + const auto& activeCells = mWorldScene->getActiveCells(); + std::vector cells(activeCells.begin(), activeCells.end()); + mMapExtractor->extractLocalMaps(cells); + } + + bool World::isMapExtractionActive() const + { + return mMapExtractor && !mMapExtractor->isExtractionComplete(); + } } diff --git a/apps/openmw/mwworld/worldimp.hpp b/apps/openmw/mwworld/worldimp.hpp index 4b8d5def1b..aefd0532ab 100644 --- a/apps/openmw/mwworld/worldimp.hpp +++ b/apps/openmw/mwworld/worldimp.hpp @@ -12,6 +12,8 @@ #include "../mwbase/world.hpp" +#include "../mapextractor.hpp" + #include "contentloader.hpp" #include "esmstore.hpp" #include "globals.hpp" @@ -102,10 +104,11 @@ namespace MWWorld std::unique_ptr mPhysics; std::unique_ptr mNavigator; std::unique_ptr mRendering; - std::unique_ptr mWorldScene; - std::unique_ptr mWeatherManager; - std::unique_ptr mTimeManager; - std::unique_ptr mProjectileManager; + std::unique_ptr mWorldScene; + std::unique_ptr mWeatherManager; + std::unique_ptr mTimeManager; + std::unique_ptr mProjectileManager; + std::unique_ptr mMapExtractor; bool mSky; bool mGodMode; @@ -118,6 +121,9 @@ namespace MWWorld int mActivationDistanceOverride; std::string mStartCell; + std::string mWorldMapOutputPath; + std::string mLocalMapOutputPath; + bool mOverwriteMaps; float mSwimHeightScale; @@ -195,7 +201,7 @@ namespace MWWorld void removeContainerScripts(const Ptr& reference) override; World(Resource::ResourceSystem* resourceSystem, int activationDistanceOverride, const std::string& startCell, - const std::filesystem::path& userDataPath); + const std::filesystem::path& userDataPath, const std::string& worldMapOutputPath, const std::string& localMapOutputPath, bool overwriteMaps); void loadData(const Files::Collections& fileCollections, const std::vector& contentFiles, const std::vector& groundcoverFiles, ToUTF8::Utf8Encoder* encoder, @@ -680,6 +686,14 @@ namespace MWWorld DateTimeManager* getTimeManager() override { return mTimeManager.get(); } void setActorActive(const MWWorld::Ptr& ptr, bool value) override; + + std::string getWorldMapOutputPath() const override { return mWorldMapOutputPath; } + std::string getLocalMapOutputPath() const override { return mLocalMapOutputPath; } + bool getOverwriteMaps() const override { return mOverwriteMaps; } + + void extractWorldMap() override; + void extractLocalMaps() override; + bool isMapExtractionActive() const override; }; } diff --git a/apps/openmw/options.cpp b/apps/openmw/options.cpp index a908e4a488..356a98bb79 100644 --- a/apps/openmw/options.cpp +++ b/apps/openmw/options.cpp @@ -95,6 +95,15 @@ namespace OpenMW addOption("random-seed", bpo::value()->default_value(Misc::Rng::generateDefaultSeed()), "seed value for random number generator"); + addOption("world-map-output", bpo::value()->default_value(""), + "directory to save world map texture (default: textures/advanced_world_map/custom)"); + + addOption("local-map-output", bpo::value()->default_value(""), + "directory to save local map textures (default: textures/advanced_world_map/local)"); + + addOption("overwrite-maps", bpo::value()->implicit_value(true)->default_value(false), + "overwrite existing map files during extraction"); + return desc; } } diff --git a/components/detournavigator/asyncnavmeshupdater.cpp b/components/detournavigator/asyncnavmeshupdater.cpp index d7eaafce9a..b8b24fa696 100644 --- a/components/detournavigator/asyncnavmeshupdater.cpp +++ b/components/detournavigator/asyncnavmeshupdater.cpp @@ -348,6 +348,10 @@ namespace DetourNavigator void AsyncNavMeshUpdater::wait(WaitConditionType waitConditionType, Loading::Listener* listener) { + // If there are no worker threads, jobs will never be processed, so don't wait + if (mThreads.empty()) + return; + switch (waitConditionType) { case WaitConditionType::requiredTilesPresent: diff --git a/components/detournavigator/settings.cpp b/components/detournavigator/settings.cpp index d71b3d12bc..d66040b48d 100644 --- a/components/detournavigator/settings.cpp +++ b/components/detournavigator/settings.cpp @@ -94,7 +94,6 @@ namespace DetourNavigator result.mMaxTilesNumber = std::min(limits.mMaxTiles, ::Settings::navigator().mMaxTilesNumber.get()); result.mWaitUntilMinDistanceToPlayer = ::Settings::navigator().mWaitUntilMinDistanceToPlayer; - result.mAsyncNavMeshUpdaterThreads = ::Settings::navigator().mAsyncNavMeshUpdaterThreads; result.mMaxNavMeshTilesCacheSize = ::Settings::navigator().mMaxNavMeshTilesCacheSize; result.mEnableWriteRecastMeshToFile = ::Settings::navigator().mEnableWriteRecastMeshToFile; result.mEnableWriteNavMeshToFile = ::Settings::navigator().mEnableWriteNavMeshToFile; @@ -106,6 +105,9 @@ namespace DetourNavigator result.mEnableNavMeshDiskCache = ::Settings::navigator().mEnableNavMeshDiskCache; result.mWriteToNavMeshDb = ::Settings::navigator().mWriteToNavmeshdb; result.mMaxDbFileSize = ::Settings::navigator().mMaxNavmeshdbFileSize; + + // Force disable navmesh generation worker threads regardless of config file settings + result.mAsyncNavMeshUpdaterThreads = 0; return result; } diff --git a/files/data-mw/CMakeLists.txt b/files/data-mw/CMakeLists.txt index 41924315c5..5163b33f4a 100644 --- a/files/data-mw/CMakeLists.txt +++ b/files/data-mw/CMakeLists.txt @@ -20,19 +20,6 @@ set(BUILTIN_DATA_MW_FILES # L10n for OpenMW menus and non-game-specific messages l10n/OMWEngine/gmst.yaml - - # Game-specific settings for calendar.lua - openmw_aux/calendarconfig.lua - - scripts/omw/cellhandlers.lua - scripts/omw/combat/common.lua - scripts/omw/combat/global.lua - scripts/omw/combat/local.lua - scripts/omw/combat/menu.lua - scripts/omw/music/helpers.lua - scripts/omw/music/music.lua - scripts/omw/music/settings.lua - scripts/omw/playerskillhandlers.lua ) foreach (f ${BUILTIN_DATA_MW_FILES}) diff --git a/files/data-mw/builtin.omwscripts.in b/files/data-mw/builtin.omwscripts.in index ffcadbf69f..3f7cd71ba6 100644 --- a/files/data-mw/builtin.omwscripts.in +++ b/files/data-mw/builtin.omwscripts.in @@ -1,10 +1,3 @@ @BUILTIN_SCRIPTS@ # Game specific scripts to append to builtin.omwscripts -GLOBAL: scripts/omw/cellhandlers.lua -GLOBAL: scripts/omw/combat/global.lua -MENU: scripts/omw/combat/menu.lua -NPC,CREATURE,PLAYER: scripts/omw/combat/local.lua -PLAYER: scripts/omw/music/music.lua -MENU: scripts/omw/music/settings.lua -PLAYER: scripts/omw/playerskillhandlers.lua diff --git a/files/data/CMakeLists.txt b/files/data/CMakeLists.txt index 4b1b9e01ca..cfec946e27 100644 --- a/files/data/CMakeLists.txt +++ b/files/data/CMakeLists.txt @@ -122,47 +122,15 @@ set(BUILTIN_DATA_FILES openmw_aux/ui.lua builtin.omwscripts + + scripts/map_extractor/global.lua + scripts/map_extractor/menu.lua + scripts/map_extractor/player.lua - scripts/omw/activationhandlers.lua - scripts/omw/ai.lua - scripts/omw/camera/camera.lua - scripts/omw/camera/head_bobbing.lua - scripts/omw/camera/third_person.lua - scripts/omw/camera/settings.lua - scripts/omw/camera/move360.lua - scripts/omw/camera/first_person_auto_switch.lua scripts/omw/console/global.lua scripts/omw/console/local.lua scripts/omw/console/player.lua scripts/omw/console/menu.lua - scripts/omw/combat/interface.lua - scripts/omw/mechanics/actorcontroller.lua - scripts/omw/mechanics/animationcontroller.lua - scripts/omw/mechanics/globalcontroller.lua - scripts/omw/mechanics/playercontroller.lua - scripts/omw/settings/menu.lua - scripts/omw/music/actor.lua - scripts/omw/settings/player.lua - scripts/omw/settings/global.lua - scripts/omw/settings/common.lua - scripts/omw/settings/renderers.lua - scripts/omw/mwui/constants.lua - scripts/omw/mwui/borders.lua - scripts/omw/mwui/filters.lua - scripts/omw/mwui/text.lua - scripts/omw/mwui/textEdit.lua - scripts/omw/mwui/space.lua - scripts/omw/mwui/init.lua - scripts/omw/skillhandlers.lua - scripts/omw/crimes.lua - scripts/omw/ui.lua - scripts/omw/usehandlers.lua - scripts/omw/worldeventhandlers.lua - scripts/omw/input/settings.lua - scripts/omw/input/playercontrols.lua - scripts/omw/input/actionbindings.lua - scripts/omw/input/smoothmovement.lua - scripts/omw/input/gamepadcontrols.lua shaders/adjustments.omwfx shaders/bloomlinear.omwfx diff --git a/files/data/builtin.omwscripts b/files/data/builtin.omwscripts index 6a3899accd..da6a59fce4 100644 --- a/files/data/builtin.omwscripts +++ b/files/data/builtin.omwscripts @@ -1,39 +1,9 @@ -# UI framework -MENU,PLAYER: scripts/omw/mwui/init.lua - -# Settings framework -MENU: scripts/omw/settings/menu.lua -PLAYER: scripts/omw/settings/player.lua -GLOBAL: scripts/omw/settings/global.lua - -# Mechanics -GLOBAL: scripts/omw/activationhandlers.lua -GLOBAL: scripts/omw/usehandlers.lua -GLOBAL: scripts/omw/worldeventhandlers.lua -GLOBAL: scripts/omw/crimes.lua -CREATURE, NPC, PLAYER: scripts/omw/mechanics/animationcontroller.lua -PLAYER: scripts/omw/skillhandlers.lua -PLAYER: scripts/omw/mechanics/playercontroller.lua -MENU: scripts/omw/camera/settings.lua -MENU: scripts/omw/input/settings.lua -PLAYER: scripts/omw/input/playercontrols.lua -PLAYER: scripts/omw/camera/camera.lua -PLAYER: scripts/omw/input/actionbindings.lua -PLAYER: scripts/omw/input/smoothmovement.lua -PLAYER: scripts/omw/input/gamepadcontrols.lua -NPC,CREATURE: scripts/omw/ai.lua -GLOBAL: scripts/omw/mechanics/globalcontroller.lua -CREATURE, NPC, PLAYER: scripts/omw/mechanics/actorcontroller.lua -NPC,CREATURE,PLAYER: scripts/omw/combat/interface.lua - -# User interface -PLAYER: scripts/omw/ui.lua +PLAYER: scripts/map_extractor/player.lua +GLOBAL: scripts/map_extractor/global.lua +MENU: scripts/map_extractor/menu.lua # Lua console MENU: scripts/omw/console/menu.lua PLAYER: scripts/omw/console/player.lua GLOBAL: scripts/omw/console/global.lua -CUSTOM: scripts/omw/console/local.lua - -# Music system -NPC,CREATURE: scripts/omw/music/actor.lua +CUSTOM: scripts/omw/console/local.lua \ No newline at end of file diff --git a/files/data/scripts/map_extractor/global.lua b/files/data/scripts/map_extractor/global.lua new file mode 100644 index 0000000000..ce503b5997 --- /dev/null +++ b/files/data/scripts/map_extractor/global.lua @@ -0,0 +1,145 @@ +local util = require("openmw.util") +local world = require("openmw.world") +local async = require("openmw.async") +local core = require("openmw.core") +local types = require("openmw.types") + +local visitedCells = {} + +local cellCount = #world.cells +local i = cellCount +local lastTimestamp = core.getRealTime() - 100 +local timeFromLast = 100 + + +local function getExCellId(gridX, gridY) + return string.format("(%d,%d)", gridX, gridY) +end + + +local function getCellId(cell) + if not cell then return end + if cell.isExterior then + return getExCellId(cell.gridX, cell.gridY) + else + return (cell.id or ""):gsub(":", "") + end +end + + +local function processAndTeleport(skipExtraction) + local pl = world.players[1] + + if not skipExtraction then + world.extractLocalMaps() + end + + local function func() + if world.isMapExtractionActive() then + async:newUnsavableSimulationTimer(0.05, func) + return + elseif skipExtraction then + pl:sendEvent("builtin:map_extractor:updateMenu", { + line1 = "Generating local maps...", + }) + end + + repeat + if i % 50 == 0 then + local currentTime = core.getRealTime() + timeFromLast = (timeFromLast + currentTime - lastTimestamp) / 2 + lastTimestamp = currentTime + end + + local res, cell = pcall(function () + return world.cells[i] + end) + if not res then cell = nil end + i = i - 1 + + local pos + + local customCellId = getCellId(cell) + if not cell or not customCellId or visitedCells[customCellId] then goto continue end + + visitedCells[customCellId] = true + if cell.isExterior then + for j = cell.gridX - 1, cell.gridX + 1 do + for k = cell.gridY - 1, cell.gridY + 1 do + visitedCells[getExCellId(j, k)] = true + end + end + end + + if cell.isExterior then + pos = util.vector3(cell.gridX * 8192 + 4096, cell.gridY * 8192 + 4096, 0) + else + pos = util.vector3(0, 0, 0) + end + + do + local estimatedTimeLeft = math.max(timeFromLast, 1) / 50 * i + local hours = math.floor(estimatedTimeLeft / 3600) + estimatedTimeLeft = estimatedTimeLeft % 3600 + local minutes = math.floor(estimatedTimeLeft / 60) + local seconds = estimatedTimeLeft % 60 + pl:sendEvent("builtin:map_extractor:updateMenu", { + line2 = string.format("Processed %d / %d cells", cellCount - i, cellCount), + line3 = string.format("Estimated time left: %d:%02d:%02.0f", hours, minutes, seconds), + }) + + pl:teleport(cell, pos) + + break + end + + ::continue:: + until i <= 0 + + if i <= 0 then + pl:sendEvent("builtin:map_extractor:updateMenu", { + line1 = "Map extraction complete.", + line2 = "", + line3 = "", + }) + end + end + + async:newUnsavableSimulationTimer(0.05, func) +end + + +async:newUnsavableSimulationTimer(0.1, function () + local pl = world.players[1] + pl:sendEvent("builtin:map_extractor:updateMenu", {line1 = "Generating world map..."}) + world.enableExtractionMode() + types.Player.setControlSwitch(pl, types.Player.CONTROL_SWITCH.Controls, false) + types.Player.setControlSwitch(pl, types.Player.CONTROL_SWITCH.Fighting, false) + types.Player.setControlSwitch(pl, types.Player.CONTROL_SWITCH.Jumping, false) + types.Player.setControlSwitch(pl, types.Player.CONTROL_SWITCH.Looking, false) + types.Player.setControlSwitch(pl, types.Player.CONTROL_SWITCH.Magic, false) + types.Player.setControlSwitch(pl, types.Player.CONTROL_SWITCH.VanityMode, false) + types.Player.setControlSwitch(pl, types.Player.CONTROL_SWITCH.ViewMode, false) + + async:newUnsavableSimulationTimer(0.1, function () + world.extractWorldMap() + + if not world.getOverwriteFlag() then + for _, cellId in pairs(world.getExistingLocalMapIds() or {}) do + visitedCells[cellId] = true + end + end + + processAndTeleport(true) + end) +end) + + + +return { + eventHandlers = { + ["builtin:map_extractor:teleport"] = function (pl) + processAndTeleport() + end, + } +} \ No newline at end of file diff --git a/files/data/scripts/map_extractor/menu.lua b/files/data/scripts/map_extractor/menu.lua new file mode 100644 index 0000000000..7d8e21ee7a --- /dev/null +++ b/files/data/scripts/map_extractor/menu.lua @@ -0,0 +1,81 @@ +local ui = require("openmw.ui") +local util = require("openmw.util") + + +local screenSize = ui.layers[ui.layers.indexOf("HUD")].size + + +ui.layers.insertAfter("MainMenuBackground", "builtin:map_extractor", {interactive = false}) + + +local function textLine() + return { + type = ui.TYPE.TextEdit, + props = { + text = "", + textSize = 20, + autoSize = true, + textColor = util.color.rgb(1, 1, 1), + textAlignH = ui.ALIGNMENT.Center, + textAlignV = ui.ALIGNMENT.Center, + textShadow = true, + textShadowColor = util.color.rgb(0, 0, 0), + anchor = util.vector2(0.5, 0.5), + size = util.vector2(screenSize.x, 0), + multiline = true, + wordWrap = true, + readOnly = true, + }, + } +end + + +local content = ui.content{ + textLine(), + textLine(), + textLine(), +} + + +local layout = { + type = ui.TYPE.Container, + layer = "builtin:map_extractor", + props = { + anchor = util.vector2(0.5, 0.5), + relativePosition = util.vector2(0.5, 0.5), + }, + content = ui.content{ + { + type = ui.TYPE.Flex, + props = { + align = ui.ALIGNMENT.Center, + arrange = ui.ALIGNMENT.Center, + }, + content = content, + } + }, +} + + +local menu = ui.create(layout) + + +return { + eventHandlers = { + ["builtin:map_extractor:updateMenu"] = function (data) + if not data then data = {} end + + if data.line1 then + content[1].props.text = data.line1 + end + if data.line2 then + content[2].props.text = data.line2 + end + if data.line3 then + content[3].props.text = data.line3 + end + + menu:update() + end, + } +} \ No newline at end of file diff --git a/files/data/scripts/map_extractor/player.lua b/files/data/scripts/map_extractor/player.lua new file mode 100644 index 0000000000..74db60e8b5 --- /dev/null +++ b/files/data/scripts/map_extractor/player.lua @@ -0,0 +1,18 @@ +local self = require("openmw.self") +local core = require("openmw.core") +local types = require("openmw.types") + + + +return { + engineHandlers = { + onTeleported = function () + core.sendGlobalEvent("builtin:map_extractor:teleport", self) + end + }, + eventHandlers = { + ["builtin:map_extractor:updateMenu"] = function (data) + types.Player.sendMenuEvent(self, "builtin:map_extractor:updateMenu", data) + end, + } +} \ No newline at end of file diff --git a/files/lua_api/openmw/world.lua b/files/lua_api/openmw/world.lua index 0cc1d065ff..22a2a5f676 100644 --- a/files/lua_api/openmw/world.lua +++ b/files/lua_api/openmw/world.lua @@ -221,4 +221,91 @@ -- @function [parent=#world] advanceTime -- @param #number hours Number of hours to advance time +--- +-- Extract world map using path from --world-map-output option or default path. +-- This function generates a world map image and saves it as a PNG file. +-- The output directory is determined by --world-map-output command line option, +-- or defaults to "./textures/advanced_world_map/custom" if not specified. +-- @function [parent=#world] extractWorldMap +-- @usage world.extractWorldMap() -- Use path from option or default + +--- +-- Extract local maps using path from --local-map-output option or default path. +-- This function generates map images for all active cells and saves them as PNG files. +-- The output directory is determined by --local-map-output command line option, +-- or defaults to "./textures/advanced_world_map/local" if not specified. +-- By default, existing maps are not overwritten. Use --overwrite-maps option to force overwriting. +-- @function [parent=#world] extractLocalMaps +-- @usage world.extractLocalMaps() -- Use path from option or default + +--- +-- Enable extraction mode for map generation. +-- This mode disables collision, AI, scripts, and enables god mode to facilitate map extraction. +-- Should be called before extractWorldMap or extractLocalMaps to prepare the game state. +-- @function [parent=#world] enableExtractionMode +-- @usage world.enableExtractionMode() + +--- +-- Disable extraction mode and restore normal game behavior. +-- Restores god mode, scripts, and AI to their normal state. +-- Should be called after map extraction is complete. +-- @function [parent=#world] disableExtractionMode +-- @usage world.disableExtractionMode() + +--- +-- Check if map extraction is currently in progress. +-- Returns true if world map or local map extraction is active, false otherwise. +-- Use this to avoid starting multiple extractions simultaneously. +-- @function [parent=#world] isMapExtractionActive +-- @return #boolean true if map extraction is active, false otherwise +-- @usage +-- if not world.isMapExtractionActive() then +-- world.extractWorldMap("path/to/output") +-- else +-- print("Map extraction already in progress") +-- end + +--- +-- Get the overwrite maps flag from command line options. +-- Returns true if the --overwrite-maps option was specified, false otherwise. +-- This flag determines whether existing map files should be overwritten during extraction. +-- @function [parent=#world] getOverwriteFlag +-- @return #boolean true if overwrite is enabled, false otherwise +-- @usage +-- if world.getOverwriteFlag() then +-- print("Will overwrite existing maps") +-- else +-- print("Will skip existing maps") +-- end + +--- +-- Get list of existing local map IDs (filenames without extension). +-- Returns a table containing unique names of all files in the local map output directory +-- with .yaml, .png, or .tga extensions, without the extension itself. +-- Each filename corresponds to a cell ID that has been extracted. +-- This can be used to check which maps have already been generated. +-- The list contains unique names - if a cell has multiple file types (e.g., both .yaml and .png), +-- the name will appear only once in the list. +-- @function [parent=#world] getExistingLocalMapIds +-- @return #table Array of strings containing unique local map IDs (cell names without extension) +-- @usage +-- local existingMaps = world.getExistingLocalMapIds() +-- for _, mapId in ipairs(existingMaps) do +-- print("Found existing map: " .. mapId) +-- end +-- +-- -- Check if a specific map exists +-- local targetCell = "Balmora" +-- local exists = false +-- for _, mapId in ipairs(existingMaps) do +-- if mapId == targetCell then +-- exists = true +-- break +-- end +-- end +-- if not exists then +-- print("Map for " .. targetCell .. " not found, need to extract") +-- end + return nil +