The entire system revolves around three coordinate spaces:
| Space | Description | Key Functions |
|---|---|---|
| Tile index | Linear 0..39999 (200×200 grid, kGridSize=40000) |
— |
| Tile coord (X,Y) | Isometric tile-space coordinates | GetTileCoord() |
| Pixel offset (X,Y) | Screen-space pixel positions (16×12 pixel per tile) | GetTileCoordOffset(), GetCoordFromOffset() |
tile 0..39999
x = tile % 200 // column
y = tile / 200 + x / 2 // row (sheared)
outX = 2*x - y // isometric X
outY = y // isometric Y
Pixel offsets are the key space for edge rectangles. A tile maps to its center-top pixel coordinate:
x = tile % 200
y = (tile / 200 + x / 2) & ~1 // forced to even row
outX = 16 * (2*x + 200 - y)
outY = 12 * y
Reverse (pixel → tile coord):
y = inY / 24
x = (inX / 32) + y - 100
outX = x
outY = 2*y - x/2
Square grid is 100×100 (square_width × square_length). Each square is a render unit for floor/roof tiles.
| Space | Range | What it controls | Stored in |
|---|---|---|---|
| Tile index | 0..39999 |
Scroll boundary definition | tileRect |
| Pixel offset | varies | Border computation, centering | borderRect, rect_2, center |
| Square coord | 0..99 per axis |
Floor/roof tile render culling | squareRect |
File path: maps/<mapname>.edg
Offset Size Field
------ ---- -----
0 4 Magic — 'EDGE' (0x45474445)
4 4 Version — 1 or 2
8 4 MapLevel — must be 0 when written, checked at load
[Version 2 only:]
12 16 SquareRect — 4 × DWORD: left, top, right, bottom
28 4 ClipData — bitmask (see below)
[Then, per map level 0..2:]
16 TileRect — 4 × DWORD: left, top, right, bottom (tile indices)
4 NextMarker — DWORD:
= same value as current level → another TileRect follows (chained)
= different value → end of this level's data
= EOF → end (only allowed for level 2)
- No global header length field. The version-2 square data is read once per level and shared by all chained
TileRects on that level. - Chained edges: When
NextMarker == currentLevel, a newTileRectis read and linked vianextEdgeData. This allows a map level to have multiple disjoint boundary rectangles. - Level count: Always 3 levels (elevations 0, 1, 2). Each level is read sequentially even if it has zero rectangles — the binary format must still advance past the
TileRect+NextMarker.
| Bits | Side | Accessed as |
|---|---|---|
| 0-7 | Bottom | (clipData >> 0) & 1 |
| 8-15 | Right | (clipData >> 8) & 1 |
| 16-23 | Top | (clipData >> 16) & 1 |
| 24-31 | Left | (clipData >> 24) & 1 |
Only bit 0 of each 8-bit group is used — the surrounding bits are alignment padding. The effective storage is 4 independent 1-bit flags.
Interpretation: ClipData does NOT say "clip this side." Instead it controls a two-pass filler render in square_obj_render():
square_obj_render(rect, tag) — called twice:
tag=0 (pre-roof pass): fills squares where clipData bit == 0
tag=1 (post-roof pass): fills squares where clipData bit == 1
For each square outside squareRect, if (clipData_bit_for_this_side) == tag, a floor tile is drawn as filler.
Why two passes? To layer filler tiles correctly in the render order — pre-roof filler sits under roofs, post-roof filler sits over them. The map author picks which sides get filler in which pass. Common setups:
| clipData | Pre-roof (tag=0) fills | Post-roof (tag=1) fills |
|---|---|---|
0 |
All sides | None |
0x01010101 |
None | All sides |
0x00010001 |
Top + Left | Bottom + Right |
These two rectangles operate at completely different levels of the pipeline:
| Aspect | tileRect → borderRect |
squareRect |
|---|---|---|
| Coordinate space | Tile index → Pixel offset | Square coord (0..99) |
| What it controls | Scroll boundary — where the camera can move | Render culling — which floor/roof tiles draw |
| When it applies | In tile_set_center_ → GetCenterTile/CheckBorder |
In square_rect_render_floor/roof and square_obj_render |
| Effect | Blocks/redirects the scroll command before it executes | Skips draw calls for squares outside the rect |
| Data source | Always from the .edg TileRect fields |
From .edg SquareRect (v2) or hardcoded (v1) |
In short: tileRect prevents the camera from scrolling past the map edge; squareRect prevents tiles outside the intended visible area from being rendered (avoids drawing the void beyond the map).
struct Edge {
POINT center; // pixel-offset center, aligned to 32×24
RECT borderRect; // pixel-offset boundary (left > right! inverted X)
RECT rect_2; // borderRect shrunk by window half-size (for multi-edge selection)
RECT tileRect; // source tile-index rectangle (from .edg)
RECT squareRect; // square-coord clip rect for floor/roof rendering
long clipData; // side clip bitmask
Edge* prevEdgeData; // (unused)
Edge* nextEdgeData; // linked list for multiple rects on one level
};Three Edge[3] are pre-allocated (one per elevation, indexed by mapLevel). Additional edges on the same level are heap-allocated and chained via nextEdgeData.
When multiple TileRects exist on one elevation:
- Each has its own
borderRect,rect_2,squareRect, andclipData(square data is copied from the primary rect). - The
rect_2zones are designed to be disjoint — they represent separate scrollable regions (e.g., disconnected rooms on the same map elevation). Selection picks the first zone containing the target pixel. - If zones do overlap in pixel space, the first one in the linked list wins (not necessarily correct behavior).
- If the target falls outside all
rect_2zones, the last edge in the chain is used as fallback.
On load, CalcEdgeData() derives derived fields from tileRect:
// Convert tileRECT corners to pixel offsets
borderRect.left = GetTileCoordOffset(tileRect.left)
borderRect.right = GetTileCoordOffset(tileRect.right)
borderRect.top = GetTileCoordOffset(tileRect.top)
borderRect.bottom = GetTileCoordOffset(tileRect.bottom)
// Shrink by window half-size → rect_2 (for edge selection)
mapWinW = (buf_width_2 / 2) - 1
mapWinH = (buf_length_2 / 2) - 1
rect_2.left = borderRect.left - mapWinW
rect_2.right = borderRect.right - mapWinW
rect_2.top = borderRect.top + mapWinH
rect_2.bottom = borderRect.bottom + mapWinH
// Expand borderRect outward by half its own width/height
// (creates a "scroll cushion" zone)
rectW = (borderRect.left - borderRect.right) / 2
rectW aligned to 32px
borderRect.left += rectW (or w, the window half-width)
borderRect.right -= rectW (or w)
rectH = (borderRect.bottom - borderRect.top) / 2
rectH aligned to 24px
borderRect.top -= rectH (or h)
borderRect.bottom += rectH (or h)
// Inverted-X guard: if left <= right (or diff == 32), flatten to a line
if borderRect.left <= borderRect.right → set left = right
// Center point
center.x = borderRect.right + ((left - right) / 2), aligned to 32
center.y = borderRect.top + ((bottom - top) / 2), aligned to 24
The pixel-offset X axis for tiles is inverted: tile 0 is at the far right, tile 199 at the far left. tileRect.left (smaller index → higher X pixel) becomes the rightmost visual edge, and tileRect.right (larger index → lower X pixel) becomes the leftmost. The computed borderRect preserves this inverted convention.
tile_set_center(tile, modeFlags) is __fastcall — ecx=tile, edx=modeFlags.
| Bit | Mask | Effect |
|---|---|---|
| 0 | 0x1 |
After setting center, call tile_refresh_display() and return -1 (redraw is mandatory for correct rendering after this call) |
| 1 | 0x2 |
Skip both scroll-limiting and edge-blocking checks (raw scroll, no clamping) |
| 2+ | — | Reserved / unused |
Any nonzero modeFlags also enables GetCenterTile() edge-aware centering (step 1).
| Caller | modeFlags | Meaning |
|---|---|---|
Engine scroll path (tile_set_center_) |
varies (0, 1, or other from game code) | Normal scroll with optional redraw |
Engine scroll-to (tile_scroll_to_) |
3 (0x1 | 0x2) | Programmatic scroll: skip all checks, force redraw |
CheckBorder (step 3) |
sets bit 0 when border boundary is hit | Forces redraw on edge-aligned scroll |
If modeFlags == 0, GetCenterTile() is skipped entirely — the tile is used as-is. The scroll-limiting and edge-blocking checks still apply (unless bit 1 is set). This path is used for direct pixel-level scroll deltas where the caller already manages positioning.
tile_set_center(tile, modeFlags)
│
├─ 1. If modeFlags != 0:
│ tile = GetCenterTile(tile, mapElevation) // clamp to edge boundaries
│
├─ 2. If scroll_limiting_on AND bit 1 of modeFlags is clear:
│ Check distance from player to target tile
│ If distance >= SCROLL_DIST_X/Y AND center tile is closer → block (-1)
│
├─ 3. If scroll_blocking_on AND bit 1 of modeFlags is clear:
│ result = CheckBorder(tile)
│ If result == 0 → block (-1)
│ If result == 1 → modeFlags |= 1 (force redraw after setting center)
│
└─ 4. Update tile_center_tile, tile_x/y, tile_offx/y, square_rect
If modeFlags & 1 → tile_refresh_display(), return -1
SCROLL_DIST_X(default 480, min 320) — max horizontal pixel distance from playerSCROLL_DIST_Y(default 400, min 240) — max vertical pixel distance from player- Compares target tile vs player tile and target vs current center tile
- If target is farther than
SCROLL_DISTfrom player AND current center is closer → block - Config:
ViewMap::IGNORE_PLAYER_SCROLL_LIMITSdisables this entirely
CheckBorder(tile):
GetTileCoordOffset(tile) → (x, y)
If x > borderRect.left OR // too far right
x < borderRect.right OR // too far left
y < borderRect.top OR // too high
y > borderRect.bottom // too low
→ return 0 (BLOCK)
// On boundary → adjust mapModWidth/Height for sub-pixel alignment
If x == borderRect.left: mapModWidth = -mapWidthModSize
If x == borderRect.right: mapModWidth = mapWidthModSize
If y == borderRect.top: mapModHeight = -mapHeightModSize
If y == borderRect.bottom:mapModHeight = mapHeightModSize
Return 1 if mod values changed (redraw needed), else -1
Called when modeFlags != 0. First converts the target tile to pixel offset, then applies edge clamping.
Each elevation can have multiple TileRect entries chained via nextEdgeData. They form disjoint scrollable zones — there is no union, no intersection test. The algorithm picks the zone whose rect_2 contains the target pixel position:
rect_2 maps to the "inner zone" of each edge —
rect_2 = borderRect shrunk by half the window dimensions
Zone selection:
Start with the first edge (primary TileRect for this level).
While target (tX, tY) is OUTSIDE current edge's rect_2:
Advance to nextEdgeData.
If no more edges → stay on the last one.
If the target falls unambiguously inside one edge's rect_2, that edge is selected. If none contains it (target is in no-man's-land), the last edge is kept — effectively the closest or last-resort zone.
The edges' rect_2 zones should not overlap in practice — overlapping zones would create ambiguous selection (first-match wins, which may not be intended).
Once the zone is chosen, currentMapEdge is updated so all subsequent scroll/check/clip operations use this edge's data until the next GetCenterTile call.
GetTileCoordOffset(tile) → (tX, tY)
// Multi-edge zone selection:
// Only walks if linked list has entries (nextEdgeData != null).
// Uses rect_2 expanded by half window as the hit-test zone.
while (edge has next) AND (tX, tY) is outside edge's rect_2 zone:
advance to next edge
// Clamp center to borderRect
If tX > borderRect.left: center.x = borderRect.left
If tX < borderRect.right: center.x = borderRect.right
Else: center.x = tX (inside bounds)
If tY > borderRect.bottom: center.y = borderRect.bottom
If tY < borderRect.top: center.y = borderRect.top
Else: center.y = tY (inside bounds)
// Set mapModWidth/Height based on which edge we're touching
(mapModWidth = -mapWidthModSize if at left edge, +mapWidthModSize if at right)
(mapModHeight = -mapHeightModSize if at top, +mapHeightModSize if at bottom)
// Clear display buffer (for EDGE_CLIPPING)
memset(display_buf, 0, buf_size) // only when EDGE_CLIPPING is active
// Convert center (pixel offset) back to tile
GetCoordFromOffset(center.x, center.y)
return centerX + centerY * 200
During rect_inside_bound_clip (called from render and scroll paths):
// Center tile → pixel offset
GetTileCoordOffset(tile_center_tile) → (cX, cY)
cX += mapModWidth
cY -= mapModHeight
// Subtract edge's rect_2 from center
mapVisibleArea.left = cX - currentEdge.rect_2.left
mapVisibleArea.right = cX - currentEdge.rect_2.right
mapVisibleArea.top = currentEdge.rect_2.top - cY
mapVisibleArea.bottom = currentEdge.rect_2.bottom - cY
This creates a screen-space rectangle representing the visible map area bounded by the edge.
| Hook | What it does |
|---|---|
refresh_game_hook_rect_inside_bound |
During map redraw: clips all render rectangles to mapVisibleArea. If EDGE_CLIPPING_ON, fills out-of-bounds rects with black (clears them). |
map_scroll_refresh_game_hook_rect_inside_bound |
Same as above but during scroll refresh — also clears before clipping. |
gmouse_check_scrolling_hack |
Mouse-edge scrolling: if mouse is in mapVisibleArea, allow scroll; if outside but over display_win, return fake tile 40000 to anchor. |
obj_render_post_roof_hook_rect_inside_bound |
Post-roof object rendering: shrinks visible area by 1px on each side to prevent leftover red pixels from hex cursor at clip boundary. |
gmouse_scr_offy_map_limit / gmouse_scr_offx_map_limit |
Limits action-icon list and mouse-over-object detection to mapVisibleArea bounds. |
These two operate at different layers of the input pipeline:
| Component | Layer | Intercepts |
|---|---|---|
tile_set_center_ replacement |
Scroll command execution | After a scroll target has been computed |
gmouse_check_scrolling_hack |
Mouse input polling | Before a scroll target is computed |
The hack is a pre-filter at the gmouse input stage: when the user moves the mouse to the screen edge, the engine calls gmouse_check_scrolling_ to decide if scrolling should start. The hook checks whether the mouse position falls within mapVisibleArea:
- If the mouse is inside
mapVisibleArea→ normal scroll processing continues (eventually reachingtile_set_center_). - If the mouse is outside
mapVisibleAreabut still over thedisplay_win→ returns a fake tile index40000(one past the valid 0..39999 range), which anchors the scroll calculation to produce no movement. - If
EDGE_CLIPPING_ONis disabled → always returns 0 (no interception).
Is it necessary? No — tile_set_center_ would block the same scroll via CheckBorder(). But without it, the engine would attempt a scroll each frame while the mouse is in the clipped-out black area, causing wasted processing and potential visual jitter (the map tries to scroll, gets blocked, tries again next frame). The hack short-circuits this at the input level.
Three hooks handle square-level draw/no-draw decisions:
square_rect_render_floor(y, id, x):
If edgeVersion > 0 AND (x, y) outside edge.squareRect → return -1 (skip draw)
Else return id (proceed)
square_rect_render_roof(y, id, x):
Same but squareRect expanded by (+2,+3) for roof tiles
sqaure_obj_render(rect, tag) fills clipped areas with floor tiles using clipData bitmask to determine which sides to fill.
┌──────────────────────────────────┐
│ tile_set_center_(tile, modeFlags)│
└──────────┬───────────────────────┘
│
modeFlags != 0 ──┤
│
┌──────────▼──────────┐
│ GetCenterTile() │ ← Edge clamp + multi-edge zone select
│ → clamps tile │ (chooses which Edge's borderRect)
│ to borderRect │
└──────────┬──────────┘
│
┌────────────────────┤
│ modeFlags & 2 == 0 │
▼ │
┌───────────────┐ │
│ Scroll dist │ │
│ limit check │ │
│ → block if │ │
│ too far │ │
└───────┬───────┘ │
│ modeFlags & 2 == 0│
▼ │
┌───────────────┐ │
│ CheckBorder() │ │
│ → block if │ │
│ outside │ │
│ → set mapMod* │ │
│ → set bit 0 │ │
└───────┬───────┘ │
└────────┬──────────┘
▼
┌──────────────────┐
│ Update engine │
│ tile_center_tile │
│ tile_x/y, │
│ tile_offx/y, │
│ square_rect │
└────────┬─────────┘
│
modeFlags & 1 ──► tile_refresh_display()
→ EdgeClipping clips
render to mapVisibleArea
| Config field | Effect |
|---|---|
IGNORE_PLAYER_SCROLL_LIMITS |
Sets scroll_limiting_on = 0 after game init — disables distance-based blocking |
IGNORE_MAP_EDGES |
Sets scroll_blocking_on = 0 after game init — disables all .edg border blocking |
EDGE_CLIPPING_ON |
Enables visual clip of rendered content at edge boundaries (black fill outside) |
| Version | squareRect source | clipData source | Notes |
|---|---|---|---|
| 1 (obsolete) | Hardcoded {99, 0, 0, 99} |
Always 0 | No angel clipping |
| 2 (current) | Read from .edg file per level |
Read from .edg file |
Full clipping support |
If no .edg file exists for a map, SetDefaultEdgeData() creates a full-map boundary:
tileRect={199, 0, 39800, 39999}— covers the entire 200×200 gridsquareRect={99, 0, 0, 99}— full square gridclipData= 0edgeVersion= 0 (obsolete — no angel clipping)