// The explored-area border on the large map (M) as a continuous line along // the fog's edge. // // Request (2026-10-26): "the red outline on the mega map is choppy and feels // weird"; replace it, following the explored area (option A). // // Measured (disassembly): cUI_Megamap::DrawVisitedBorder (0x6c8580, called by // the map's render) draws one 2x2 px red dot (0xffff0000) per bit of a 100x100 // byte grid (world +0xb73c), at a fixed offset of 10 / 20 px from the sector's // origin towards each neighbour. The grid marks unvisited sectors next to // visited ones (sector flag 0x20000, set when the hero's sector becomes the // centre of the active 3x3, 0x626603; rebuilt on load by 0x633230 and per // sector change by 0x6332d0). So the line was dots in 64-tile steps, and it // followed the sectors stood in, not the explored mask the fog shows (since // the 10x map reveal, far inside the cleared area). // // The mod replaces the draw: the edges between explored and unexplored cells // (2x2 tiles, game/engine/explored.h) of the map's 100x100 sectors are chained // into polylines, simplified to within a pixel and drawn as 2 px lines in the // game's red. The lines are rebuilt while the map is open whenever the masks // changed (checked every 250 ms). Cells of a missing sector count as neither, // so no line runs along the edge of the world. #include #include #include #include #include #include "game/engine/explored.h" #include "game/ui/draw.h" #include "game/ui/megamap.h" #include "mods/widescreen.h" #include "re/hook.h" #include "re/log.h" namespace mods::mapborder { namespace { // The map's sector grid (World_RebuildVisitedBorder 0x633230: 100x100). constexpr int kMapSectors = 100; constexpr int kMapCells = kMapSectors * game::kSectorCells; constexpr uint32_t kColour = 0xffff0000; // the game's border colour (0x6c870e) constexpr float kLineWidth = 2.0f; // the game's dots: 2x2 px constexpr double kTolerance = 0.75; // map pixels a simplified line may deviate constexpr DWORD kCheckIntervalMs = 250; constexpr int kScreenHeight = 768; enum class Cell : uint8_t { None, Unexplored, Explored }; struct Grid { const uint8_t* masks[kMapSectors][kMapSectors]; void Load() { for (int y = 0; y < kMapSectors; ++y) { for (int x = 0; x < kMapSectors; ++x) { masks[y][x] = game::SectorExploredMask(x, y); } } } Cell At(int x, int y) const { if (x < 0 || y < 0 || x >= kMapCells || y >= kMapCells) { return Cell::None; } const uint8_t* mask = masks[y / game::kSectorCells][x / game::kSectorCells]; if (!mask) { return Cell::None; } return game::MaskCellExplored(mask, x % game::kSectorCells, y % game::kSectorCells) ? Cell::Explored : Cell::Unexplored; } // FNV-1a over the masks and which sectors exist. uint64_t Hash() const { uint64_t hash = 0xcbf29ce484222325ull; const auto add = [&](uint8_t byte) { hash = (hash ^ byte) * 0x100000001b3ull; }; for (const auto& row : masks) { for (const uint8_t* mask : row) { add(mask != nullptr); for (size_t i = 0; mask && i < 128; ++i) { add(mask[i]); } } } return hash; } }; struct Point { double x, y; }; // Cell corner -> map pixel: WorldToMapPixel is linear, so three points give // it for the whole grid. struct Projection { Point origin, stepX, stepY; static Point Corner(uint8_t* megamap, int cellX, int cellY) { const float unitsPerCell = game::kExploredCellTiles * game::kWorldUnitsPerTile; const game::Vec3 pixel = game::WorldToMapPixel(megamap, {cellX * unitsPerCell, cellY * unitsPerCell, 0}); return {pixel.x, pixel.y}; } explicit Projection(uint8_t* megamap) { origin = Corner(megamap, 0, 0); const Point right = Corner(megamap, kMapCells, 0); const Point down = Corner(megamap, 0, kMapCells); stepX = {(right.x - origin.x) / kMapCells, (right.y - origin.y) / kMapCells}; stepY = {(down.x - origin.x) / kMapCells, (down.y - origin.y) / kMapCells}; } Point operator()(int cellX, int cellY) const { return {origin.x + cellX * stepX.x + cellY * stepY.x, origin.y + cellX * stepX.y + cellY * stepY.y}; } }; // Corners on the cell grid, (kMapCells + 1)^2 of them. uint32_t Key(int x, int y) { return static_cast(y) * (kMapCells + 1) + static_cast(x); } struct Edge { int x0, y0, x1, y1; }; // Every side between an explored and an unexplored cell, clockwise around // the explored cell (screen orientation, y down), so the edges of a border // chain head to tail. std::vector BorderEdges(const Grid& grid) { std::vector edges; for (int sy = 0; sy < kMapSectors; ++sy) { for (int sx = 0; sx < kMapSectors; ++sx) { const uint8_t* mask = grid.masks[sy][sx]; bool any = false; for (int i = 0; mask && i < 128 && !any; ++i) { any = mask[i] != 0; } if (!any) { continue; } for (int ly = 0; ly < game::kSectorCells; ++ly) { for (int lx = 0; lx < game::kSectorCells; ++lx) { if (!game::MaskCellExplored(mask, lx, ly)) { continue; } const int x = sx * game::kSectorCells + lx; const int y = sy * game::kSectorCells + ly; if (grid.At(x, y - 1) == Cell::Unexplored) { edges.push_back({x, y, x + 1, y}); } if (grid.At(x + 1, y) == Cell::Unexplored) { edges.push_back({x + 1, y, x + 1, y + 1}); } if (grid.At(x, y + 1) == Cell::Unexplored) { edges.push_back({x + 1, y + 1, x, y + 1}); } if (grid.At(x - 1, y) == Cell::Unexplored) { edges.push_back({x, y + 1, x, y}); } } } } } return edges; } using Chain = std::vector>; // Edges chained into polylines: open ones (from a corner no edge ends at, // at a missing sector) first, then the closed loops. Where two cells touch // only at a corner, the chain turns right, staying around its own cell. std::vector ChainEdges(const std::vector& edges) { std::unordered_map> starting; std::unordered_map ending; starting.reserve(edges.size()); ending.reserve(edges.size()); for (size_t i = 0; i < edges.size(); ++i) { starting[Key(edges[i].x0, edges[i].y0)].push_back(i); ++ending[Key(edges[i].x1, edges[i].y1)]; } std::vector used(edges.size(), false); std::vector chains; const auto follow = [&](size_t first) { Chain chain = {{edges[first].x0, edges[first].y0}}; size_t current = first; for (;;) { used[current] = true; const Edge& e = edges[current]; chain.emplace_back(e.x1, e.y1); const int dx = e.x1 - e.x0; const int dy = e.y1 - e.y0; size_t next = SIZE_MAX; int bestTurn = 3; for (size_t candidate : starting[Key(e.x1, e.y1)]) { if (used[candidate]) { continue; } const int cx = edges[candidate].x1 - edges[candidate].x0; const int cy = edges[candidate].y1 - edges[candidate].y0; // 0 right (-dy, dx), 1 straight, 2 left. const int turn = (cx == -dy && cy == dx) ? 0 : (cx == dx && cy == dy) ? 1 : 2; if (turn < bestTurn) { bestTurn = turn; next = candidate; } } if (next == SIZE_MAX) { break; } current = next; } chains.push_back(std::move(chain)); }; for (size_t i = 0; i < edges.size(); ++i) { if (!used[i] && ending.find(Key(edges[i].x0, edges[i].y0)) == ending.end()) { follow(i); } } for (size_t i = 0; i < edges.size(); ++i) { if (!used[i]) { follow(i); } } return chains; } double DistanceToSegment(const Point& p, const Point& a, const Point& b) { const double dx = b.x - a.x; const double dy = b.y - a.y; const double length2 = dx * dx + dy * dy; double t = length2 > 0 ? ((p.x - a.x) * dx + (p.y - a.y) * dy) / length2 : 0; t = t < 0 ? 0 : t > 1 ? 1 : t; return std::hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); } // Douglas-Peucker: keeps first and last, and every point needed to stay // within kTolerance of the original. void Simplify(const std::vector& points, size_t first, size_t last, std::vector& out) { double farthest = 0; size_t index = first; for (size_t i = first + 1; i < last; ++i) { const double d = DistanceToSegment(points[i], points[first], points[last]); if (d > farthest) { farthest = d; index = i; } } if (farthest > kTolerance) { Simplify(points, first, index, out); Simplify(points, index, last, out); } else { out.push_back(points[last]); } } std::vector SimplifiedLine(const Chain& chain, const Projection& project) { std::vector points; points.reserve(chain.size()); for (const auto& [x, y] : chain) { points.push_back(project(x, y)); } std::vector out = {points.front()}; if (points.size() < 2) { return out; } // A closed loop is split at its point farthest from the start, so both // halves have distinct ends. size_t split = points.size() - 1; if (chain.front() == chain.back()) { double farthest = -1; for (size_t i = 1; i + 1 < points.size(); ++i) { const double d = std::hypot(points[i].x - points[0].x, points[i].y - points[0].y); if (d > farthest) { farthest = d; split = i; } } } Simplify(points, 0, split, out); if (split < points.size() - 1) { Simplify(points, split, points.size() - 1, out); } return out; } // Map-pixel line segments of the current border. struct Segment { float x0, y0, x1, y1; }; std::vector g_segments; uint64_t g_hash = 0; DWORD g_lastCheck = 0; bool g_built = false; void Rebuild(uint8_t* megamap) { static Grid grid; grid.Load(); const uint64_t hash = grid.Hash(); if (g_built && hash == g_hash) { return; } const DWORD start = GetTickCount(); const Projection project(megamap); const std::vector edges = BorderEdges(grid); const std::vector chains = ChainEdges(edges); g_segments.clear(); for (const Chain& chain : chains) { const std::vector line = SimplifiedLine(chain, project); for (size_t i = 1; i < line.size(); ++i) { g_segments.push_back({static_cast(line[i - 1].x), static_cast(line[i - 1].y), static_cast(line[i].x), static_cast(line[i].y)}); } } if (!g_built) { re::Log("map border: %zu edges, %zu lines, %zu segments, %lu ms", edges.size(), chains.size(), g_segments.size(), GetTickCount() - start); } g_hash = hash; g_built = true; } void Draw(uint8_t* megamap, void* device) { const DWORD now = GetTickCount(); if (!g_built || now - g_lastCheck >= kCheckIntervalMs) { g_lastCheck = now; Rebuild(megamap); } const float scrollX = static_cast(game::MegamapScrollX(megamap)); const float scrollY = static_cast(game::MegamapScrollY(megamap)); const float width = static_cast(mods::widescreen::RenderWidth()); const float half = kLineWidth / 2; static std::vector vertices; vertices.clear(); for (const Segment& s : g_segments) { const float x0 = s.x0 - scrollX, y0 = s.y0 - scrollY; const float x1 = s.x1 - scrollX, y1 = s.y1 - scrollY; if ((x0 < -half && x1 < -half) || (x0 > width + half && x1 > width + half) || (y0 < -half && y1 < -half) || (y0 > kScreenHeight + half && y1 > kScreenHeight + half)) { continue; } const float length = std::hypot(x1 - x0, y1 - y0); if (length <= 0) { continue; } // Along and across the segment, half the width each; the ends reach // half the width further, closing the joints. const float ax = (x1 - x0) / length * half, ay = (y1 - y0) / length * half; const float nx = -ay, ny = ax; const game::ScreenVertex a = {x0 - ax + nx, y0 - ay + ny, 0, 1, kColour, 0, 0, 0}; const game::ScreenVertex b = {x0 - ax - nx, y0 - ay - ny, 0, 1, kColour, 0, 0, 0}; const game::ScreenVertex c = {x1 + ax + nx, y1 + ay + ny, 0, 1, kColour, 0, 0, 0}; const game::ScreenVertex d = {x1 + ax - nx, y1 + ay - ny, 0, 1, kColour, 0, 0, 0}; vertices.insert(vertices.end(), {a, b, c, c, b, d}); } if (!vertices.empty()) { game::FillTriangles(device, vertices.data(), vertices.size()); } } } // namespace } // namespace mods::mapborder namespace game { // cUI_Megamap::DrawVisitedBorder (0x6c8580, thiscall (device), ret 4). void __fastcall UiMegamap_DrawVisitedBorder(uint8_t* self, void* edx, void* device); RE_HOOK(0x006c8580, UiMegamap_DrawVisitedBorder); void __fastcall UiMegamap_DrawVisitedBorder(uint8_t* self, void*, void* device) { if (self && device) { mods::mapborder::Draw(self, device); } } } // namespace game