Compare commits

...
Author SHA1 Message Date
phobos2077 651bdff37e Update sfall compatibility 2026-05-25 22:23:54 +02:00
phobos2077 e3e976dd82 EDG: square mask code readability 2026-05-25 22:20:38 +02:00
phobos2077 541af8f543 EDG: square mask initial port 2026-05-25 20:29:42 +02:00
phobos2077 09abb57cab EDG: update docs, clarify that squareRect is not implemented 2026-05-25 20:01:54 +02:00
phobos2077 67f0dc5d36 EDG: get rid of rect2 and simplify algebra 2026-05-25 18:44:44 +02:00
github-actions[bot] 7809dc0806 chore: auto-format with clang-format 2026-05-25 12:30:33 +00:00
phobos2077 63d54f3259 EDG: deobfuscate code 2026-05-25 14:29:59 +02:00
phobos2077 11e726ba31 EDG: option to disable 2026-05-25 00:46:18 +02:00
github-actions[bot] b8a091b7f5 chore: auto-format with clang-format 2026-05-15 00:22:11 +00:00
phobos2077 31fedf1567 EDG: show arrow cursor and block events when over clipped area 2026-05-15 02:21:39 +02:00
phobos2077 09de0c1845 EDG: fix clamping 2026-05-15 01:40:07 +02:00
github-actions[bot] 51edb44be9 chore: auto-format with clang-format 2026-05-14 22:28:43 +00:00
phobos2077 8691a99722 EDG: code review fixes and refactoring 2026-05-15 00:28:15 +02:00
github-actions[bot] ba8cdf47ba chore: auto-format with clang-format 2026-05-13 22:53:10 +00:00
phobos2077 03bb4f2e9c EDG: proper edge clipping matching HRP logic 2026-05-14 00:52:34 +02:00
phobos2077 223471acaf EDG: review fixes 2026-05-13 23:21:48 +02:00
phobos2077 6f3b42fe34 EDG: fix scrolling past pixel-space bounds with sub-hex map edge mod 2026-05-13 03:13:07 +02:00
github-actions[bot] f8070fd58a chore: auto-format with clang-format 2026-05-12 22:32:00 +00:00
phobos2077 07adef4622 EDG: proper scroll blocking based on pre-calculated rects, exactly as in HRP 2026-05-13 00:31:33 +02:00
github-actions[bot] d12a59202f chore: auto-format with clang-format 2026-05-12 16:38:39 +00:00
phobos2077 b7324afc4e Update docs and fix bug 2026-05-12 18:37:42 +02:00
phobos2077 0afa3ed255 HRP's edg file support (WIP) 2026-05-12 18:37:42 +02:00
16 changed files with 810 additions and 44 deletions
+2
View File
@@ -161,6 +161,8 @@ set(FALLOUT_ENGINE_SOURCES
"src/map_defs.h"
"src/map.cc"
"src/map.h"
"src/map_edge.cc"
"src/map_edge.h"
"src/memory_defs.h"
"src/memory_manager.cc"
"src/memory_manager.h"
+12
View File
@@ -4,6 +4,18 @@ This document tracks Fallout 2 CE compatibility with sfall. This is for modders
For now, this covers opcodes/metarules, and hooks. In the future, it will include other ways of modifying the engine (like ini files), and other Sfall-specific behaviour.
## HRP EDG Scroll-Blocker Support
CE supports the `.edg` file format from the HRP (High Resolution Patch), which defines per-map scroll boundaries and square-level render clipping.
**How it works in CE:**
- On map load, `maps/<mapname>.edg` is read if present. Missing file = silent fallback to the scroll-blocker object system.
- The `.edg` file defines per-elevation rectangle boundary zones. Multiple chained zones per elevation are supported.
- When loaded, these zones are used for both scroll blocking and visible area clipping (black bars), replacing both vanilla scroll blocking and CE hi-res stencil system.
- v2 EDG files also contain a `SquareRect` that defines "Angled edges", or square-grid stencil. This is also supported.
## Settings (ddraw.ini → fallout2.cfg / game.cfg)
Settings previously read from `ddraw.ini` have been moved into standard CE config files.
+2
View File
@@ -110,6 +110,8 @@ iface_bar_mode=0
iface_bar_side_art=2
iface_bar_sides_ori=0
iface_bar_width=800
;Whether to load EDG files (HRP format) when loading maps. If loaded, they override default edge clipping and scroll blocking behavior.
edg_support=1
ignore_map_edges=0
;Set to 1 to display numbered dialogue options
numbers_in_dialogue=0
+9
View File
@@ -24,6 +24,7 @@
#include "interface.h"
#include "item.h"
#include "kb.h"
#include "map_edge.h"
#include "mouse.h"
#include "object.h"
#include "proto.h"
@@ -931,6 +932,9 @@ void _gmouse_handle_event(int mouseX, int mouseY, int mouseState)
if (gameMouseClickOnInterfaceBar()) {
return;
}
if (mapEdgeIsOverClippedArea(mouseX, mouseY)) {
return;
}
// Check if we should block mouse button up events for inventoryOpenUseItemOn inventory window
if (gBlockMouseUpEvent && (mouseState & MOUSE_EVENT_LEFT_BUTTON_UP) != 0) {
@@ -2447,6 +2451,11 @@ int gameMouseHandleScrolling(int x, int y, int cursor)
}
if (dx == 0 && dy == 0) {
if (mapEdgeIsOverClippedArea(x, y)) {
gameMouseObjectsHide();
gameMouseSetCursor(MOUSE_CURSOR_ARROW);
return 0;
}
return -1;
}
+34 -8
View File
@@ -26,6 +26,7 @@
#include "item.h"
#include "light.h"
#include "loadsave.h"
#include "map_edge.h"
#include "memory.h"
#include "object.h"
#include "palette.h"
@@ -225,7 +226,9 @@ int isoInit()
colorCycleInit();
debugPrint(">cycle_init\t\t");
tileScrollBlockingEnable();
if (!settings.ui.ignore_map_edges) {
tileScrollBlockingEnable();
}
tileScrollLimitingEnable();
if (interfaceInit() != 0) {
@@ -734,6 +737,7 @@ int mapSetEnteringLocation(int elevation, int tile_num, int orientation)
// 0x482938
void mapNewMap()
{
mapEdgeFree();
mapSetElevation(0);
tileSetCenter(20100, TILE_SET_CENTER_FLAG_IGNORE_SCROLL_RESTRICTIONS);
memset(&gMapTransition, 0, sizeof(gMapTransition));
@@ -931,6 +935,10 @@ static int mapLoad(File* stream)
goto err;
}
if (settings.ui.edg_support) {
mapEdgeLoad(gMapHeader.name);
}
error = "Error setting tile center";
if (tileSetCenter(gEnteringTile, TILE_SET_CENTER_FLAG_IGNORE_SCROLL_RESTRICTIONS) != 0) {
goto err;
@@ -1061,7 +1069,7 @@ err:
// NOTE: Uninline.
mapSetEnteringLocation(-1, -1, -1);
tile_hires_stencil_init();
tile_hires_stencil_on_map_load();
gameMovieFadeOut();
@@ -1532,7 +1540,7 @@ static void isoWindowRefreshRect(Rect* rect)
windowRefreshRect(gIsoWindow, rect);
}
// 0x483EE4
// 0x483EE4 map_scroll_refresh_game
static void isoWindowRefreshRectGame(Rect* rect)
{
Rect rectToUpdate;
@@ -1540,23 +1548,31 @@ static void isoWindowRefreshRectGame(Rect* rect)
return;
}
// CE: Clear dirty rect to prevent most of the visual artifacts near map
// edges.
Rect visArea;
bool hasVisArea = mapEdgeComputeVisibleArea(gElevation, &visArea);
// HRP rect_inside_bound_scroll_clip: always clear srcRect, then clip to mapVisibleArea.
bufferFill(gIsoWindowBuffer + rectToUpdate.top * rectGetWidth(&gIsoWindowRect) + rectToUpdate.left,
rectGetWidth(&rectToUpdate),
rectGetHeight(&rectToUpdate),
rectGetWidth(&gIsoWindowRect),
0);
if (hasVisArea && rectIntersection(&rectToUpdate, &visArea, &rectToUpdate) == -1) {
return;
}
tileRenderFloorsInRect(&rectToUpdate, gElevation);
_obj_render_pre_roof(&rectToUpdate, gElevation);
tileRenderRoofsInRect(&rectToUpdate, gElevation);
_obj_render_post_roof(&rectToUpdate, gElevation);
tile_hires_stencil_draw(&rectToUpdate, gIsoWindowBuffer, rectGetWidth(&gIsoWindowRect), rectGetHeight(&gIsoWindowRect));
if (!hasVisArea) {
tile_hires_stencil_draw(&rectToUpdate, gIsoWindowBuffer, rectGetWidth(&gIsoWindowRect), rectGetHeight(&gIsoWindowRect));
}
}
// 0x483F44
// 0x483F44 map_scroll_refresh_mapper
static void isoWindowRefreshRectMapper(Rect* rect)
{
Rect rectToUpdate;
@@ -1564,19 +1580,29 @@ static void isoWindowRefreshRectMapper(Rect* rect)
return;
}
Rect visArea;
bool hasVisArea = mapEdgeComputeVisibleArea(gElevation, &visArea);
// HRP rect_inside_bound_scroll_clip: always clear srcRect, then clip to mapVisibleArea.
bufferFill(gIsoWindowBuffer + rectToUpdate.top * rectGetWidth(&gIsoWindowRect) + rectToUpdate.left,
rectGetWidth(&rectToUpdate),
rectGetHeight(&rectToUpdate),
rectGetWidth(&gIsoWindowRect),
0);
if (hasVisArea && rectIntersection(&rectToUpdate, &visArea, &rectToUpdate) == -1) {
return;
}
tileRenderFloorsInRect(&rectToUpdate, gElevation);
_grid_render(&rectToUpdate, gElevation);
_obj_render_pre_roof(&rectToUpdate, gElevation);
tileRenderRoofsInRect(&rectToUpdate, gElevation);
_obj_render_post_roof(&rectToUpdate, gElevation);
tile_hires_stencil_draw(&rectToUpdate, gIsoWindowBuffer, rectGetWidth(&gIsoWindowRect), rectGetHeight(&gIsoWindowRect));
if (!hasVisArea) {
tile_hires_stencil_draw(&rectToUpdate, gIsoWindowBuffer, rectGetWidth(&gIsoWindowRect), rectGetHeight(&gIsoWindowRect));
}
}
// NOTE: Inlined.
+462
View File
@@ -0,0 +1,462 @@
#include "map_edge.h"
#include "db.h"
#include "debug.h"
#include "geometry.h"
#include "map.h"
#include "map_defs.h"
#include "platform_compat.h"
#include "svga.h"
#include "tile.h"
#include "window_manager.h"
#include <cassert>
#include <memory>
namespace fallout {
// Tile size in pixels
constexpr int kTileWidth = 32;
constexpr int kTileHeight = 24;
static std::unique_ptr<EdgeZone> gEdgeZones[ELEVATION_COUNT];
static bool gEdgeDataLoaded = false;
static bool gEdgeVersion2 = false;
static EdgeZone* gCurrentEdgeZone = nullptr;
// Boundary alignment mods (sfall mapModWidth/Height), set by clamp/check functions.
static int currentTileXAlignment = 0;
static int currentTileYAlignment = 0;
static int maxTileXAlignment = 0;
static int maxTileYAlignment = 0;
static Rect gMapVisibleArea;
// Convert tile index to pixel-offset coordinates.
// Equivalent to sfall ViewMap::GetTileCoordOffset.
void tileToPixelOffset(int tile, int& outX, int& outY)
{
int x = tile % HEX_GRID_WIDTH;
int y = (tile / HEX_GRID_WIDTH) + (x / 2);
y &= ~1; // force even row
x = (2 * x) + 200 - y;
outY = kTileHeight / 2 * y;
outX = kTileWidth / 2 * x;
}
// Convert pixel-offset to tile coord (in-place, like sfall GetCoordFromOffset).
void pixelToTileCoord(int& inOutX, int& inOutY)
{
int y = inOutY / kTileHeight;
int x = (inOutX / kTileWidth) + y - 100;
inOutX = x;
inOutY = (2 * y) - (x / 2);
}
// Convert pixel-offset to tile number.
static int pixelToTile(int px, int py)
{
pixelToTileCoord(px, py);
return px + py * HEX_GRID_WIDTH;
}
static Size getIsoWindowSize()
{
Rect winRect;
assert(windowGetRect(gIsoWindow, &winRect) != -1);
return { rectGetWidth(&winRect), rectGetHeight(&winRect) };
}
// Fill pixel-space fields of a zone from its stored tileRect corners.
// Screen-size dependent — must be re-run if resolution changes.
static void calcEdgeData(EdgeZone* zone)
{
const auto [winWidth, winHeight] = getIsoWindowSize();
const int winHalfWidth = winWidth / 2;
const int winHalfHeight = winHeight / 2;
// Compute sub-tile alignment sizes (sfall mapWidthModSize / mapHeightModSize).
maxTileXAlignment = winHalfWidth % kTileWidth;
maxTileYAlignment = winHalfHeight % kTileHeight;
// Truncated half sizes for border contraction (matching sfall ViewMap::GetWinMapHalfSize).
const int winHalfWidthSnapped = winHalfWidth - maxTileXAlignment;
const int winHalfHeightSnapped = winHalfHeight - maxTileYAlignment;
// Convert tileRect corners to pixel offsets.
// pixelRect = raw pixel-space rect before any contraction.
int px, py;
tileToPixelOffset(zone->tileRect.left, px, py);
zone->pixelRect.left = zone->scrollBorderRect.left = px;
tileToPixelOffset(zone->tileRect.top, px, py);
zone->pixelRect.top = zone->scrollBorderRect.top = py;
tileToPixelOffset(zone->tileRect.right, px, py);
zone->pixelRect.right = zone->scrollBorderRect.right = px;
tileToPixelOffset(zone->tileRect.bottom, px, py);
zone->pixelRect.bottom = zone->scrollBorderRect.bottom = py;
// Contract scrollBorderRect inward by window half-size (or half its own size snapped to hex grid, if it's smaller than the window).
// Narrows the scrollable area, with guards below collapsing to a point if it crosses.
{
long rectHalfWidth = (zone->scrollBorderRect.left - zone->scrollBorderRect.right) / 2;
if (rectHalfWidth < winHalfWidthSnapped) {
long remainder = rectHalfWidth % kTileWidth;
long rectHalfWidthSnapped = rectHalfWidth - remainder;
zone->scrollBorderRect.left -= rectHalfWidthSnapped;
zone->scrollBorderRect.right += rectHalfWidthSnapped + (remainder ? kTileWidth : 0);
} else {
zone->scrollBorderRect.left -= winHalfWidthSnapped;
zone->scrollBorderRect.right += winHalfWidthSnapped;
}
}
{
long rectHalfHeight = (zone->scrollBorderRect.bottom - zone->scrollBorderRect.top) / 2;
if (rectHalfHeight < winHalfHeightSnapped) {
long remainder = rectHalfHeight % kTileHeight;
long rectHalfHeightSnapped = rectHalfHeight - remainder;
zone->scrollBorderRect.top += rectHalfHeightSnapped + (remainder ? kTileHeight : 0);
zone->scrollBorderRect.bottom -= rectHalfHeightSnapped;
} else {
zone->scrollBorderRect.top += winHalfHeightSnapped;
zone->scrollBorderRect.bottom -= winHalfHeightSnapped;
}
}
// Inverted-X guard: collapse to vertical line if left/right cross or nearly touch.
if ((zone->scrollBorderRect.left < zone->scrollBorderRect.right) || (zone->scrollBorderRect.left - zone->scrollBorderRect.right) == kTileWidth) {
zone->scrollBorderRect.left = zone->scrollBorderRect.right;
}
// Inverted-Y guard
if ((zone->scrollBorderRect.bottom < zone->scrollBorderRect.top) || (zone->scrollBorderRect.bottom - zone->scrollBorderRect.top) == kTileHeight) {
zone->scrollBorderRect.bottom = zone->scrollBorderRect.top;
}
debugPrint("EDG[%p] tileRect=(%d,%d,%d,%d) win=(%d,%d) half size snap=(%d,%d) pixelRect=(%d,%d,%d,%d) scrollBorderRect=(%d,%d,%d,%d)\n",
static_cast<void*>(zone), zone->tileRect.left, zone->tileRect.top,
zone->tileRect.right, zone->tileRect.bottom, winWidth, winHeight, winHalfWidthSnapped, winHalfHeightSnapped,
zone->pixelRect.left, zone->pixelRect.top,
zone->pixelRect.right, zone->pixelRect.bottom,
zone->scrollBorderRect.left, zone->scrollBorderRect.top,
zone->scrollBorderRect.right, zone->scrollBorderRect.bottom);
}
// Multi-edge zone selection: pick the zone whose scrollBorderRect contains the pixel position.
// Matches GetCenterTile logic: advance while target is outside current zone, use last if
// none contains it.
static EdgeZone* findZoneByPixel(int px, int py, int elevation)
{
EdgeZone* zone = gEdgeZones[elevation].get();
if (zone == nullptr) {
return nullptr;
}
// Multi-edge: advance while target is outside current zone.
// width/height in original are half-window values (winHalfWidth-1, winHalfHeight+1),
// so window size cancels out of the condition — only pixelRect ± small constants remain.
constexpr int kZoneMarginY = 2;
while (zone->next != nullptr) {
// Point is inside current zone.
if (px < zone->pixelRect.left && px > zone->pixelRect.right
&& py > zone->pixelRect.top - kZoneMarginY && py < zone->pixelRect.bottom - kZoneMarginY) {
break;
}
zone = zone->next.get();
}
return zone;
}
// Load EDG file, populate gEdgeZones, and compute pixel-space fields.
// EDG files use big-endian byte order (like all Fallout 2 files).
static bool mapEdgeLoadFromStream(File* stream)
{
int magic;
if (fileReadInt32(stream, &magic) == -1 || magic != 'EDGE') return false;
int version;
if (fileReadInt32(stream, &version) == -1 || (version != 1 && version != 2)) return false;
int reserved;
if (fileReadInt32(stream, &reserved) == -1 || reserved != 0) return false;
gEdgeVersion2 = (version == 2);
int levelIndicator = 0;
for (int elev = 0; elev < ELEVATION_COUNT; elev++) {
int sqLeft = 99, sqTop = 0, sqRight = 0, sqBottom = 99;
int sqClipData = 0;
if (gEdgeVersion2) {
int sqRect[4];
if (fileReadInt32List(stream, sqRect, 4) == -1
|| fileReadInt32(stream, &sqClipData) == -1) {
return false;
}
sqLeft = sqRect[0];
sqTop = sqRect[1];
sqRight = sqRect[2];
sqBottom = sqRect[3];
}
if (levelIndicator != elev) {
continue; // no tileRect data for this elevation
}
auto tail = &gEdgeZones[elev];
bool isFirstZone = true;
while (true) {
int tileRect[4];
if (fileReadInt32List(stream, tileRect, 4) == -1) {
return elev == ELEVATION_COUNT - 1;
}
auto zone = std::make_unique<EdgeZone>();
// File stores RECT order: [0]=left, [1]=top, [2]=right, [3]=bottom.
zone->tileRect.left = tileRect[0];
zone->tileRect.top = tileRect[1];
zone->tileRect.right = tileRect[2];
zone->tileRect.bottom = tileRect[3];
zone->squareRect.left = sqLeft;
zone->squareRect.top = sqTop;
zone->squareRect.right = sqRight;
zone->squareRect.bottom = sqBottom;
int rawClip = isFirstZone ? sqClipData : 0;
zone->clipSides.bottom = (rawClip & 1) != 0;
zone->clipSides.right = ((rawClip >> 8) & 1) != 0;
zone->clipSides.top = ((rawClip >> 16) & 1) != 0;
zone->clipSides.left = ((rawClip >> 24) & 1) != 0;
zone->next = nullptr;
calcEdgeData(zone.get());
*tail = std::move(zone);
tail = &(*tail)->next;
isFirstZone = false;
if (fileReadInt32(stream, &levelIndicator) == -1) {
return elev == ELEVATION_COUNT - 1;
}
if (levelIndicator != elev) {
break; // next elevation begins
}
}
}
return true;
}
void mapEdgeLoad(const char* mapName)
{
mapEdgeFree();
char fname[COMPAT_MAX_FNAME];
compat_splitpath(mapName, nullptr, nullptr, fname, nullptr);
char edgPath[COMPAT_MAX_PATH];
snprintf(edgPath, sizeof(edgPath), "MAPS\\%s.EDG", fname);
File* stream = fileOpen(edgPath, "rb");
if (stream == nullptr) {
return;
}
bool ok = mapEdgeLoadFromStream(stream);
fileClose(stream);
if (ok) {
gEdgeDataLoaded = true;
debugPrint("mapEdgeLoad: loaded %s\n", edgPath);
} else {
debugPrint("mapEdgeLoad: failed to parse %s\n", edgPath);
mapEdgeFree();
}
}
void mapEdgeFree()
{
for (auto& gEdgeZone : gEdgeZones) {
gEdgeZone.reset();
}
gEdgeDataLoaded = false;
gCurrentEdgeZone = nullptr;
currentTileXAlignment = 0;
currentTileYAlignment = 0;
maxTileXAlignment = 0;
maxTileYAlignment = 0;
gMapVisibleArea = {};
}
bool mapEdgeIsLoaded()
{
return gEdgeDataLoaded;
}
bool mapEdgeZoneIsSelected()
{
return gCurrentEdgeZone != nullptr;
}
// Shared helper: set currentTileXAlignment/Height if the pixel is on a scrollBorderRect edge.
static void updateTileAlignment(const EdgeZone* zone, int px, int py);
int mapEdgeSelectZoneAndClamp(int tile, int elevation)
{
int px, py;
tileToPixelOffset(tile, px, py);
// Set current zone for subsequent scroll blocking checks.
EdgeZone* zone = gCurrentEdgeZone = findZoneByPixel(px, py, elevation);
if (zone == nullptr) {
return tile;
}
// Clamp pixel position to scrollBorderRect (matching sfall GetCenterTile).
// Note: X is inverted (left > right), so the valid range is [right, left].
int cx = (px <= zone->scrollBorderRect.left)
? ((px >= zone->scrollBorderRect.right) ? px : zone->scrollBorderRect.right)
: zone->scrollBorderRect.left;
int cy = (py <= zone->scrollBorderRect.bottom)
? ((py >= zone->scrollBorderRect.top) ? py : zone->scrollBorderRect.top)
: zone->scrollBorderRect.bottom;
updateTileAlignment(zone, cx, cy);
return pixelToTile(cx, cy);
}
bool mapEdgeTileInBounds(int tile)
{
int px, py;
tileToPixelOffset(tile, px, py);
EdgeZone* zone = gCurrentEdgeZone;
if (zone == nullptr) {
return false;
}
// Note: X is inverted (left > right), so check px in [right, left].
return px >= zone->scrollBorderRect.right && px <= zone->scrollBorderRect.left
&& py >= zone->scrollBorderRect.top && py <= zone->scrollBorderRect.bottom;
}
// Shared helper: set currentTileXAlignment if the pixel is on a scrollBorderRect edge.
static void updateTileAlignment(const EdgeZone* zone, int px, int py)
{
currentTileXAlignment = 0;
currentTileYAlignment = 0;
if (px == zone->scrollBorderRect.left) {
currentTileXAlignment = -maxTileXAlignment;
} else if (px == zone->scrollBorderRect.right) {
currentTileXAlignment = maxTileXAlignment;
}
if (py == zone->scrollBorderRect.top) {
currentTileYAlignment = -maxTileYAlignment;
} else if (py == zone->scrollBorderRect.bottom) {
currentTileYAlignment = maxTileYAlignment;
}
}
bool mapEdgeSetBoundaryMods(int tile)
{
int px, py;
tileToPixelOffset(tile, px, py);
EdgeZone* zone = gCurrentEdgeZone;
if (zone == nullptr) {
currentTileXAlignment = 0;
currentTileYAlignment = 0;
return false;
}
const int oldModW = currentTileXAlignment;
const int oldModH = currentTileYAlignment;
updateTileAlignment(zone, px, py);
return currentTileXAlignment != oldModW || currentTileYAlignment != oldModH;
}
int mapEdgeGetTileXAlignment() { return currentTileXAlignment; }
int mapEdgeGetTileYAlignment() { return currentTileYAlignment; }
bool mapEdgeHasSquareRect(int elevation)
{
const auto& zone = gEdgeZones[elevation];
return gEdgeVersion2 && zone != nullptr && zone->squareRect.left >= 0;
}
void mapEdgeGetSquareRect(int elevation, Rect* outRect)
{
const auto& zone = gEdgeZones[elevation];
*outRect = zone->squareRect;
}
EdgeZone::ClipSides mapEdgeGetClipSides(int elevation)
{
const auto& zone = gEdgeZones[elevation];
return zone ? zone->clipSides : EdgeZone::ClipSides{};
}
void mapEdgeRecalc()
{
for (auto& gEdgeZone : gEdgeZones) {
const auto* zone = &gEdgeZone;
while (zone != nullptr) {
calcEdgeData(zone->get());
zone = &zone->get()->next;
}
}
}
bool mapEdgeComputeVisibleArea(int elevation, Rect* outRect)
{
if (!gEdgeDataLoaded) return false;
int px, py;
tileToPixelOffset(gCenterTile, px, py);
px += currentTileXAlignment;
py -= currentTileYAlignment;
EdgeZone* zone = findZoneByPixel(px, py, elevation);
if (zone == nullptr) return false;
// Convert pixel-offset space to screen space via screen origin.
// screenOrigin = pixel-space coord of the screen's top-left corner.
// X is inverted in pixel-space: screenX = screenOriginX - pixelX.
// Matches sfall EdgeClipping::rect_inside_bound_clip mapVisibleArea computation.
const auto [winWidth, winHeight] = getIsoWindowSize();
const int screenOriginX = px + winWidth / 2 - 1;
const int screenOriginY = py - (winHeight / 2 - 1);
outRect->left = screenOriginX - zone->pixelRect.left;
outRect->right = screenOriginX - zone->pixelRect.right;
outRect->top = zone->pixelRect.top - screenOriginY;
outRect->bottom = zone->pixelRect.bottom - screenOriginY;
gMapVisibleArea = *outRect;
return true;
}
bool mapEdgeIsOverClippedArea(int screenX, int screenY)
{
if (!gEdgeDataLoaded) return false;
if (screenX >= gMapVisibleArea.left && screenX <= gMapVisibleArea.right && screenY >= gMapVisibleArea.top && screenY < gMapVisibleArea.bottom) return false;
return windowGetAtPoint(screenX, screenY) == gIsoWindow;
}
} // namespace fallout
+103
View File
@@ -0,0 +1,103 @@
#ifndef MAP_EDGE_H
#define MAP_EDGE_H
#include <memory>
#include "geometry.h"
namespace fallout {
struct EdgeZone {
struct ClipSides {
bool bottom = false;
bool right = false;
bool top = false;
bool left = false;
};
// Raw tile indices from EDG file (tileRect in tile space, 0-39999).
Rect tileRect;
// Pixel-space rect from tileRect corner conversion (before contraction).
Rect pixelRect;
// Pixel-space boundary for center-tile scroll blocking (screen-size dependent).
// Derived from pixelRect by contracting inward by window half-size and snapping to hex grid.
// X axis is inverted: left > right (smaller tile index → larger pixel X).
// Y axis is normal: bottom > top.
Rect scrollBorderRect;
// Square-grid clip rect for floor/roof rendering (v2 EDG only).
// Valid when left >= 0.
Rect squareRect;
// Per-side clip flags unpacked from EDG v2. True means the black square overlay
// for that side is drawn on top of (after) non-flat objects.
ClipSides clipSides;
std::unique_ptr<EdgeZone> next;
};
// Load EDG file for a map. mapName is the raw map filename e.g. "ARROYO.MAP".
// Silently does nothing if no .edg file is found.
void mapEdgeLoad(const char* mapName);
// Free all loaded EDG data. Safe to call when nothing is loaded.
void mapEdgeFree();
// Returns true if EDG data was successfully loaded for the current map.
bool mapEdgeIsLoaded();
// Returns true if a zone was selected on last tileSetCenter call.
bool mapEdgeZoneIsSelected();
// Clamps tile to the EDG boundary for the given elevation.
// Returns the clamped tile (may equal tile if already in bounds).
int mapEdgeSelectZoneAndClamp(int tile, int elevation);
// Returns true if tile is within the EDG boundary for the given elevation.
// Used by the stencil flood-fill as a traversal gate.
bool mapEdgeTileInBounds(int tile);
// Returns true if squareRect data (v2 EDG) is available for this elevation.
bool mapEdgeHasSquareRect(int elevation);
// Convert tile index to pixel-offset coordinates.
// Equivalent to sfall ViewMap::GetTileCoordOffset.
void tileToPixelOffset(int tile, int& outX, int& outY);
// Convert pixel-offset to tile coord (in-place, like sfall GetCoordFromOffset).
void pixelToTileCoord(int& inOutX, int& inOutY);
// Fills the squareRect for the given elevation.
// Only valid when mapEdgeHasSquareRect(elevation) returns true.
void mapEdgeGetSquareRect(int elevation, Rect* outRect);
// Returns the clipSides for the given elevation (default-constructed if no EDG data).
EdgeZone::ClipSides mapEdgeGetClipSides(int elevation);
// Recalculate all pixel-space fields using current screen dimensions.
// Call when resolution changes while a map is loaded.
void mapEdgeRecalc();
// Pixel-offset adjustments for sub-tile boundary alignment (sfall mapModWidth/Height).
// Used when camera is exactly at one of the scroll border edges.
int mapEdgeGetTileXAlignment();
int mapEdgeGetTileYAlignment();
// For the blocking (flags == 0) scroll path: sets boundary mod values if the given
// tile's pixel position is exactly on a scrollBorderRect edge.
// Returns true if the tile is on a boundary edge (mods changed) — caller should set
// TILE_SET_CENTER_REFRESH_WINDOW for full redraw, matching sfall CheckBorder result==1.
bool mapEdgeSetBoundaryMods(int tile);
// Computes the screen-space visible area rectangle.
// Equivalent to sfall EdgeClipping's mapVisibleArea. Returns false if no EDG loaded.
bool mapEdgeComputeVisibleArea(int elevation, Rect* outRect);
// Returns true if the screen coordinate is over the map window but outside the EDG-visible area.
bool mapEdgeIsOverClippedArea(int screenX, int screenY);
} // namespace fallout
#endif /* MAP_EDGE_H */
+2 -2
View File
@@ -590,12 +590,12 @@ static void mp_run_placement_loop(PlaceFn place)
}
switch (keyCode) {
case 327: // Home
case KEY_HOME: // Home
if (gDude != nullptr) {
if (gElevation != gDude->elevation) {
mapSetElevation(gDude->elevation);
}
tileSetCenter(gDude->tile, 1);
tileSetCenter(gDude->tile, TILE_SET_CENTER_REFRESH_WINDOW);
}
break;
case KEY_ARROW_UP:
+2 -2
View File
@@ -2749,9 +2749,9 @@ static void mapper_enter_play_mode(Object** pHlObj1)
}
scriptsExecMapEnterProc();
scriptsExecMapUpdateProc();
tileSetCenter(gDude->tile, 1);
tileSetCenter(gDude->tile, TILE_SET_CENTER_REFRESH_WINDOW);
} else {
tileSetCenter(savedCenterTile, 1);
tileSetCenter(savedCenterTile, TILE_SET_CENTER_REFRESH_WINDOW);
}
isoEnable();
+4
View File
@@ -826,6 +826,8 @@ void _obj_render_pre_roof(Rect* rect, int elevation)
}
}
tileRenderEdgeBlackSquares(&updatedRect, elevation, false);
for (int i = 0; i < renderCount; i++) {
int lightIntensity;
@@ -856,6 +858,8 @@ void _obj_render_pre_roof(Rect* rect, int elevation)
objectListNode = objectListNode->next;
}
}
tileRenderEdgeBlackSquares(&updatedRect, elevation, true);
}
// 0x4897EC obj_render_post_roof
+1
View File
@@ -153,6 +153,7 @@ void initSettingsRegistry(bool isMapper)
SETTING_P(iface_bar_side_art, clamp(0, 999));
SETTING(iface_bar_sides_ori);
SETTING_P(splash_screen_size, clamp(0, 2));
SETTING(edg_support);
SETTING(ignore_map_edges);
SETTING(quick_toolbar_visible);
SETTING_P(anim_speed, clamp(0.1, 100.0));
+4
View File
@@ -56,6 +56,10 @@ struct UISettings {
int splash_screen_size = 0;
// Whether to load EDG files (HRP format) when loading maps. If loaded, they override default edge clipping and scroll blocking behavior.
bool edg_support = true;
// Disables map edges, including vanilla scroll blockers and CE hi-res stencil.
bool ignore_map_edges = false;
// iOS quick-actions toolbar above the interface bar. No-op on other platforms.
+156 -18
View File
@@ -14,6 +14,7 @@
#include "game_mouse.h"
#include "light.h"
#include "map.h"
#include "map_edge.h"
#include "object.h"
#include "platform_compat.h"
#include "settings.h"
@@ -440,6 +441,8 @@ int tileInit(TileData** squareGrid, int squareGridWidth, int squareGridHeight, i
gTileWindowWidth = ORIGINAL_ISO_WINDOW_WIDTH;
gTileWindowHeight = ORIGINAL_ISO_WINDOW_HEIGHT;
tile_hires_stencil_init();
tileSetCenter(hexGridWidth * (hexGridHeight / 2) + hexGridWidth / 2, TILE_SET_CENTER_FLAG_IGNORE_SCROLL_RESTRICTIONS);
tileSetBorder(windowWidth, windowHeight, hexGridWidth, hexGridHeight);
@@ -540,6 +543,30 @@ int tileSetCenter(int tile, int flags)
return -1;
}
bool boundaryModsSet = false;
if (mapEdgeIsLoaded() && !settings.ui.ignore_map_edges) {
bool isScroll = flags == 0;
if (!isScroll) {
// Forced positioning (teleport, initial load, etc.): clamp to edge boundary.
tile = mapEdgeSelectZoneAndClamp(tile, gElevation);
if (!tileIsValid(tile)) return -1;
} else if (mapEdgeZoneIsSelected()) {
// Normal scroll: block if tile is outside boundary (matching sfall CheckBorder).
// Clamping here would move the center slightly and cause mapScroll's buffer
// copy to produce visual artifacts; blocking preserves the current view.
if (!mapEdgeTileInBounds(tile)) {
return -1;
}
// Tile is in bounds; set sub-tile boundary mods if tile is on the edge.
// If the tile is exactly on a boundary edge, force a full redraw
// (matching sfall's CheckBorder returning 1 → modeFlags |= 1).
if (mapEdgeSetBoundaryMods(tile)) {
boundaryModsSet = true;
flags |= TILE_SET_CENTER_REFRESH_WINDOW;
}
}
}
if ((flags & TILE_SET_CENTER_FLAG_IGNORE_SCROLL_RESTRICTIONS) == 0) {
if (gTileScrollLimitingEnabled) {
int tileScreenX;
@@ -561,7 +588,9 @@ int tileSetCenter(int tile, int flags)
}
}
if (gTileScrollBlockingEnabled && !settings.ui.ignore_map_edges) {
// Scroll-blocker object check only runs when no EDG is loaded.
// EDG clamping above already enforces the boundary.
if ((!mapEdgeIsLoaded() || !mapEdgeZoneIsSelected()) && gTileScrollBlockingEnabled) {
if (_obj_scroll_blocking_at(tile, gElevation) == 0) {
return -1;
}
@@ -571,16 +600,17 @@ int tileSetCenter(int tile, int flags)
int tile_x = gHexGridWidth - 1 - tile % gHexGridWidth;
int tile_y = tile / gHexGridWidth;
if (gTileBorderInitialized && !settings.ui.ignore_map_edges) {
// Global tile borders are always checked, unless scroll blocking is disabled.
if (gTileBorderInitialized && gTileScrollBlockingEnabled) {
if (tile_x <= gTileBorderMinX || tile_x >= gTileBorderMaxX || tile_y <= gTileBorderMinY || tile_y >= gTileBorderMaxY) {
return -1;
}
}
_tile_y = tile_y;
_tile_offx = (gTileWindowWidth - 32) / 2;
_tile_offx = mapEdgeGetTileXAlignment() + (gTileWindowWidth - 32) / 2;
_tile_x = tile_x;
_tile_offy = (gTileWindowHeight - 16) / 2;
_tile_offy = mapEdgeGetTileYAlignment() + (gTileWindowHeight - 16) / 2;
if (tile_x & 1) {
_tile_x -= 1;
@@ -604,11 +634,49 @@ int tileSetCenter(int tile, int flags)
if ((flags & TILE_SET_CENTER_REFRESH_WINDOW) != 0) {
// NOTE: Uninline.
tileWindowRefresh();
if (boundaryModsSet)
return -1; // necessary for the correct rendering of the map
}
return 0;
}
// Port of HRP EdgeClipping::CheckRect.
// Returns true if any corner of the screen-space rect maps to a tile outside the 200x200 grid.
bool checkRectNeedsClear(const Rect* rect, int elevation)
{
(void)elevation;
int cX, cY;
tileToPixelOffset(gCenterTile, cX, cY);
const int halfW = gTileWindowWidth / 2;
const int halfH = gTileWindowHeight / 2;
// Convert screen-space rect corners to pixel-offset space (HRP formula).
// xLeft = (cX + width) - rect->left, yTop = (cY + rect->top) - height
// xRight = (cX + width) - rect->right, yBottom = (cY + rect->bottom) - height
struct {
int x, y;
} corners[4] = {
{ (cX + halfW) - rect->left, (cY + rect->top) - halfH },
{ (cX + halfW) - rect->right, (cY + rect->top) - halfH },
{ (cX + halfW) - rect->left, (cY + rect->bottom) - halfH },
{ (cX + halfW) - rect->right, (cY + rect->bottom) - halfH }
};
for (int i = 0; i < 4; i++) {
int x = corners[i].x;
int y = corners[i].y;
pixelToTileCoord(x, y);
if (x < 0 || x >= HEX_GRID_WIDTH || y < 0 || y >= HEX_GRID_HEIGHT) {
return true;
}
}
return false;
}
// TODO: these two functions are exact copies of isoWindowRefreshRect*. gTileWindowBuffer == gIsoWindowBuffer, these are the same window!
// 0x4B1554 refresh_mapper
static void tileRefreshMapper(Rect* rect, int elevation)
{
@@ -618,11 +686,21 @@ static void tileRefreshMapper(Rect* rect, int elevation)
return;
}
bufferFill(gTileWindowBuffer + gTileWindowPitch * rectToUpdate.top + rectToUpdate.left,
rectToUpdate.right - rectToUpdate.left + 1,
rectToUpdate.bottom - rectToUpdate.top + 1,
gTileWindowPitch,
0);
Rect visArea;
bool hasVisArea = mapEdgeComputeVisibleArea(elevation, &visArea);
// HRP EdgeClipping: when clipped, clear only if CheckRect; otherwise always clear.
if (!hasVisArea || checkRectNeedsClear(&rectToUpdate, elevation)) {
bufferFill(gTileWindowBuffer + gTileWindowPitch * rectToUpdate.top + rectToUpdate.left,
rectGetWidth(&rectToUpdate),
rectGetHeight(&rectToUpdate),
gTileWindowPitch,
0);
}
if (hasVisArea && rectIntersection(&rectToUpdate, &visArea, &rectToUpdate) == -1) {
return;
}
tileRenderFloorsInRect(&rectToUpdate, elevation);
_grid_render(&rectToUpdate, elevation);
@@ -630,7 +708,9 @@ static void tileRefreshMapper(Rect* rect, int elevation)
tileRenderRoofsInRect(&rectToUpdate, elevation);
_obj_render_post_roof(&rectToUpdate, elevation);
tile_hires_stencil_draw(&rectToUpdate, gTileWindowBuffer, gTileWindowWidth, gTileWindowHeight);
if (!hasVisArea) {
tile_hires_stencil_draw(&rectToUpdate, gTileWindowBuffer, gTileWindowWidth, gTileWindowHeight);
}
gTileWindowRefreshProc(&rectToUpdate);
}
@@ -644,20 +724,30 @@ static void tileRefreshGame(Rect* rect, int elevation)
return;
}
// CE: Clear dirty rect to prevent most of the visual artifacts near map
// edges.
bufferFill(gTileWindowBuffer + rectToUpdate.top * gTileWindowPitch + rectToUpdate.left,
rectGetWidth(&rectToUpdate),
rectGetHeight(&rectToUpdate),
gTileWindowPitch,
0);
Rect visArea;
bool hasVisArea = mapEdgeComputeVisibleArea(elevation, &visArea);
// HRP EdgeClipping: when clipped, clear only if CheckRect; otherwise always clear.
if (!hasVisArea || checkRectNeedsClear(&rectToUpdate, elevation)) {
bufferFill(gTileWindowBuffer + rectToUpdate.top * gTileWindowPitch + rectToUpdate.left,
rectGetWidth(&rectToUpdate),
rectGetHeight(&rectToUpdate),
gTileWindowPitch,
0);
}
if (hasVisArea && rectIntersection(&rectToUpdate, &visArea, &rectToUpdate) == -1) {
return;
}
tileRenderFloorsInRect(&rectToUpdate, elevation);
_obj_render_pre_roof(&rectToUpdate, elevation);
tileRenderRoofsInRect(&rectToUpdate, elevation);
_obj_render_post_roof(&rectToUpdate, elevation);
tile_hires_stencil_draw(&rectToUpdate, gTileWindowBuffer, gTileWindowWidth, gTileWindowHeight);
if (!hasVisArea) {
tile_hires_stencil_draw(&rectToUpdate, gTileWindowBuffer, gTileWindowWidth, gTileWindowHeight);
}
gTileWindowRefreshProc(&rectToUpdate);
}
@@ -1437,6 +1527,54 @@ void tileRenderFloorsInRect(Rect* rect, int elevation)
}
}
// Port of sfall HRP ViewMap::square_obj_render
void tileRenderEdgeBlackSquares(Rect* rect, int elevation, bool drawOnTop)
{
if (!mapEdgeHasSquareRect(elevation)) {
return;
}
Rect squareRect;
mapEdgeGetSquareRect(elevation, &squareRect);
EdgeZone::ClipSides clipSides = mapEdgeGetClipSides(elevation);
int maxX, minY, minX, maxY, temp;
squareTileScreenToCoord(rect->left, rect->bottom, elevation, &maxX, &temp);
squareTileScreenToCoord(rect->left, rect->top, elevation, &temp, &minY);
squareTileScreenToCoord(rect->right, rect->top, elevation, &minX, &temp);
squareTileScreenToCoord(rect->right, rect->bottom, elevation, &temp, &maxY);
if (++maxX > gSquareGridWidth) maxX = gSquareGridWidth - 1;
if (--minX < 0) minX = 0;
if (--minY < 0) minY = 0;
if (++maxY > gSquareGridHeight) maxY = gSquareGridHeight - 1;
if (minY >= maxY || minX >= maxX) return;
bool drawLeft = clipSides.left == drawOnTop;
bool drawTop = clipSides.top == drawOnTop;
bool drawRight = clipSides.right == drawOnTop;
bool drawBottom = clipSides.bottom == drawOnTop;
const int kEdgeFid = buildFid(OBJ_TYPE_TILE, 1, 0, 0, 0);
int baseSquareTile = gSquareGridWidth * minY;
for (int y = minY; y < maxY; y++) {
for (int x = minX; x < maxX; x++) {
if ((drawLeft && x > squareRect.left)
|| (drawTop && y < squareRect.top)
|| (drawRight && x < squareRect.right)
|| (drawBottom && y > squareRect.bottom)) {
int sx, sy;
squareTileToScreenXY(baseSquareTile + x, &sx, &sy, elevation);
tileRenderFloor(kEdgeFid, sx, sy, rect);
}
}
baseSquareTile += gSquareGridWidth;
}
}
// 0x4B2B10 square_roof_intersect
bool _square_roof_intersect(int x, int y, int elevation)
{
+5
View File
@@ -58,6 +58,7 @@ void squareTileScreenToCoordRoof(int screenX, int screenY, int elevation, int* c
void tileRenderRoofsInRect(Rect* rect, int elevation);
void tile_fill_roof(int x, int y, int elevation, bool on);
void tileRenderFloorsInRect(Rect* rect, int elevation);
void tileRenderEdgeBlackSquares(Rect* rect, int elevation, bool drawOnTop);
bool _square_roof_intersect(int x, int y, int elevation);
void _grid_render(Rect* rect, int elevation);
int _tile_scroll_to(int tile, int flags);
@@ -67,6 +68,10 @@ static bool tileIsValid(int tile)
return tile >= 0 && tile < gHexGridSize;
}
// Returns true if the rect's screen-space corners map to tiles outside the 200x200 grid.
// Port of HRP EdgeClipping::CheckRect — used to decide whether to clear (blacken) a rect.
bool checkRectNeedsClear(const Rect* rect, int elevation);
} // namespace fallout
#endif /* TILE_H */
+10 -14
View File
@@ -2,6 +2,7 @@
#include "debug.h"
#include "draw.h"
#include "geometry.h"
#include "map_edge.h"
#include "settings.h"
#include "stdio.h"
#include "tile.h"
@@ -210,20 +211,11 @@ void tile_hires_stencil_on_center_tile_or_elevation_change()
if (!gIsTileHiresStencilEnabled) {
return;
}
if (!gTileBorderInitialized) {
// With EDG loaded, the EdgeClipping path handles blackening via ClearRect/CheckRect.
// The stencil used as a backup when no EDG is present.
if (mapEdgeIsLoaded() || !gTileBorderInitialized || visited_tiles[gElevation][gCenterTile]) {
return;
};
if (visited_tiles[gElevation][gCenterTile]) {
// debugPrint("tile_hires_stencil_on_center_tile_or_elevation_change tile was visited gElevation=%i gCenterTile=%i so doing nothing\n",
// gElevation, gCenterTile);
return;
};
// debugPrint("tile_hires_stencil_on_center_tile_or_elevation_change non-visited tile gElevation=%i gCenterTile=%i\n",
// gElevation, gCenterTile);
}
clean_cache_for_elevation(gElevation);
@@ -256,7 +248,6 @@ void tile_hires_stencil_on_center_tile_or_elevation_change()
if (_obj_scroll_blocking_at(tileInfo.tile, gElevation) == 0) {
continue;
}
// TODO: Maybe create new function in tile.cc and use it here
int tile_x = HEX_GRID_WIDTH - 1 - tileInfo.tile % HEX_GRID_WIDTH;
int tile_y = tileInfo.tile / HEX_GRID_WIDTH;
@@ -421,9 +412,14 @@ void tile_hires_stencil_init()
}
debugPrint("tile_hires_stencil_init\n");
}
void tile_hires_stencil_on_map_load()
{
clean_cache();
tile_hires_stencil_on_center_tile_or_elevation_change();
tileWindowRefresh();
debugPrint("tile_hires_stencil_on_map_load\n");
}
} // namespace fallout
+2
View File
@@ -7,6 +7,8 @@ namespace fallout {
void tile_hires_stencil_init();
void tile_hires_stencil_on_map_load();
void tile_hires_stencil_on_center_tile_or_elevation_change();
void tile_hires_stencil_draw(Rect* rect, unsigned char* buffer, int windowWidth, int windowHeight);