Skip to content

Instantly share code, notes, and snippets.

@phobos2077
Last active April 28, 2026 12:03
Show Gist options
  • Select an option

  • Save phobos2077/0e6e64147d34747daae4560a54a1952f to your computer and use it in GitHub Desktop.

Select an option

Save phobos2077/0e6e64147d34747daae4560a54a1952f to your computer and use it in GitHub Desktop.

EDG File Format & Scroll Blocker System

Coordinate Systems

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 ↔ Tile Coord

tile 0..39999
x = tile % 200                          // column
y = tile / 200 + x / 2                  // row (sheared)
outX = 2*x - y                          // isometric X
outY = y                                // isometric Y

Tile Coord ↔ Pixel Offset

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 Coordinates (Rendering Clip)

Square grid is 100×100 (square_width × square_length). Each square is a render unit for floor/roof tiles.

Three Coordinate Spaces at a Glance

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

EDG File Format (Binary, Little-Endian)

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)

Data Structure Notes

  • 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 new TileRect is read and linked via nextEdgeData. 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.

ClipData Bitmask

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

Separation of Concerns: tileRect vs squareRect

These two rectangles operate at completely different levels of the pipeline:

Aspect tileRectborderRect 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).


Edge Runtime Data Structure

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.

Multi-Edge Relationships

When multiple TileRects exist on one elevation:

  • Each has its own borderRect, rect_2, squareRect, and clipData (square data is copied from the primary rect).
  • The rect_2 zones 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_2 zones, the last edge in the chain is used as fallback.

Field Computations

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

Why borderRect.left > borderRect.right

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.


modeFlags

tile_set_center(tile, modeFlags) is __fastcallecx=tile, edx=modeFlags.

Bit layout

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).

Callers

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

When modeFlags == 0 (path from non-edge-aware callers)

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.


Scroll Flow (blocking pipeline)

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

Step 2: Scroll Distance Limiting

  • SCROLL_DIST_X (default 480, min 320) — max horizontal pixel distance from player
  • SCROLL_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_DIST from player AND current center is closer → block
  • Config: ViewMap::IGNORE_PLAYER_SCROLL_LIMITS disables this entirely

Step 3: Edge Border Check

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

Step 1: GetCenterTile (edge-aware centering)

Called when modeFlags != 0. First converts the target tile to pixel offset, then applies edge clamping.

Multi-Edge Zone Selection

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

EdgeClipping — Visual Clip System

MapVisibleArea Calculation

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.

Rendering Clips

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.

Why gmouse_check_scrolling_hack exists separately from tile_set_center_

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 reaching tile_set_center_).
  • If the mouse is outside mapVisibleArea but still over the display_win → returns a fake tile index 40000 (one past the valid 0..39999 range), which anchors the scroll calculation to produce no movement.
  • If EDGE_CLIPPING_ON is 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.

Square-Level Rendering Clip (Angel Clipping)

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.


Complete Scroll Blocker Architecture

                    ┌──────────────────────────────────┐
                    │  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 toggles

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)

Edge Version Differences

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

Default Edge (no .edg file)

If no .edg file exists for a map, SetDefaultEdgeData() creates a full-map boundary:

  • tileRect = {199, 0, 39800, 39999} — covers the entire 200×200 grid
  • squareRect = {99, 0, 0, 99} — full square grid
  • clipData = 0
  • edgeVersion = 0 (obsolete — no angel clipping)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment