所有格式页面 ↓
地图与地形

TNT——地图地形

来自 coreprime/kbot @ 0ab97bf · 在 GitHub 上编辑

.tnt 文件是一张完整可玩的地图:图块图形、地形高度、地图物件的放置(树木、岩石、水源节点……)、海平面,以及一张 252×252 的小地图,全部装在一个自包含的文件里。地形几何不依赖任何外部文件——把 .tnt 放进游戏文件夹就能加载。(配套的 .ota 文件只是元数据:名称、描述、AI 提示、起始位置。)

渲染了地图物件精灵图和起始位置标记的 Metal Heck 地图
maps/metal heck.tnt——完整预览图,地图物件精灵图依据 features/*.tdf 合成,
起始位置标记取自配套的 .ota

内嵌的小地图    归一化的高度图
内嵌的小地图(左)和归一化的海拔网格(右)。

Tip

自己试试。

kbot tnt describe  "maps/metal heck.tnt"
kbot tnt minimap   "maps/metal heck.tnt" -t mini.png
kbot tnt heightmap "maps/metal heck.tnt" --normalize -t h.png
kbot tnt image     "maps/metal heck.tnt" -t map.png        # raw tile mosaic
kbot tnt preview   "maps/metal heck.tnt" -t preview.jpg    # + feature sprites + StartPos

# And the reverse: decompose into editable files, edit, repack.
kbot tnt unpack    "maps/metal heck.tnt" -t ./metal_heck
kbot tnt pack      ./metal_heck -t metal_heck.tnt

见 CLI 的 kbot tnt 参考。

在 Go 中调用。 使用 formats/tnt:

import "github.com/coreprime/kbot/formats/tnt"

f, _ := os.Open("maps/metal heck.tnt")
defer f.Close()
m, _ := tnt.LoadFromReader(f)
fmt.Printf("%dx%d tiles, %d unique, %d features\n",
    m.TileW, m.TileH, m.Header.Tiles, m.Header.TileAnims)

概览

┌─ Header (64 B) ─────────────────────────────────────────────┐
│ IDVersion (0x2000)  Width  Height (in 16-px attribute cells)│
│ PtrMapData  PtrMapAttr  PtrTileGfx  Tiles  TileAnims        │
│ PtrTileAnim  SeaLevel  PtrMiniMap  MinimapFlags  pad×4      │
└──────────────────────────────┬──────────────────────────────┘
                               │  offsets are absolute
       ┌─────────────────┬─────┴────────┬──────────────────────┐
       ▼                 ▼              ▼                      ▼
┌─ Map data ────┐ ┌─ Attributes ─┐ ┌─ Tile graphics ─┐ ┌─ Tile animations ─┐
│ uint16 grid   │ │ 4 B per 16px │ │ Tiles × 1024 B  │ │ TileAnims × 132 B │
│  (TileW×TileH)│ │   cell:      │ │  (32×32 idx)    │ │ name[128] each    │
│               │ │  h, feat, _  │ │                 │ │                   │
└───────────────┘ └──────────────┘ └─────────────────┘ └───────────────────┘
                                                                ▼
                                              ┌─ Minimap (252×252 paletted) ─┐
                                              └──────────────────────────────┘

文件头(64 字节)

typedef struct {
    uint32 IDVersion;       // 0x2000 (TA); 0x1020 is the older TA layout
    uint32 Width;           // In 16-pixel attribute cells (tile width = Width/2)
    uint32 Height;          // In 16-pixel attribute cells
    uint32 PtrMapData;      // → tile-index grid
    uint32 PtrMapAttr;      // → per-cell attributes
    uint32 PtrTileGfx;      // → tile pixel data
    uint32 Tiles;           // unique tile count
    uint32 TileAnims;       // feature table count (must be < 0xFFFB)
    uint32 PtrTileAnim;     // → feature name table
    uint32 SeaLevel;        // heights below this = underwater
    uint32 PtrMiniMap;      // → embedded 252×252 minimap
    uint32 MinimapFlags;    // 0x2c: bit 0 set = the game reads the minimap
    uint32 pad1, pad2, pad3, pad4;  // observed all zero
} TNTHeader;

Note

存在三种版本字。 0x2000 是所有工具都会写出的横扫千军地图。0x1020 是一种较早的 TA 布局,TA 3.1c 仍然能读取:它的属性记录是 8 字节(先是高度,然后是位于第 2 字节的单字节地图物件,0xFC 及以上表示没有),小地图指针和标志位于 0x38/0x3c;kbot 能读取它,保存时则写成 0x2000。0x4000 是横扫千军:王国的地图,TA 无法加载(见 TA:K 地图);kbot tnt describe、MCP 的 tnt_describe 工具和 studio 的 Open Map 选择器都会指出这一点。

Important

Width 和 Height 以 16 像素的属性格为单位,而不是图块。 除以 2 得到图块数;乘以 16 得到像素尺寸。因此一张 4096×4096 像素的地图 Width = Height = 256(格),其索引网格中有 128 × 128 = 16384 个图块。


图块索引网格(PtrMapData)

(Width/2) × (Height/2) 个 uint16 条目,按行优先排列(Y 在外层,X 在内层)。每个条目都是图块图形数组的索引。图块大小为 32 × 32 像素;这个网格就是游戏绘制战场时所拼接的图块马赛克。

Note

Cavedog 的工具会用临时内存中的内容把这个块填充到 16 字节边界。 kbot tnt unpack 会把这些填充字节原样记录在 metadata.json 中(设置了 --lossless 时),使往返逐字节一致。

属性网格(PtrMapAttr)

Width × Height 个格子,每格 4 字节,按行优先排列。每格的结构:

typedef struct {
    uint8  Height;   // Elevation (0..255)
    uint16 Feature;  // Feature placement index into the feature table,
                     //   or 0xFFFF if no feature,
                     //   or 0xFFFC if the cell is void / impassable
    uint8  _pad;     // Zero; alignment to 4 bytes
} TNTAttrCell;

只有当这个字小于地图物件表的条目数(TileAnims)时,游戏才会放置地图物件;0xFFFC 标记一个空白格(void),其余的字都不放置任何东西。无论表有多大,从 0xFFFB 起的字都是哨兵值,因此表的条目必须少于 0xFFFB 个。kbot tnt describe、kbot tnt features 和资源浏览器只统计游戏实际做出的放置。

属性网格在每个维度上都比图块网格密 2 倍——每个图块有 4 个属性格,每个 16 像素的子图块一个。正是这一点使引擎能以 16 像素的粒度处理单位寻路、建筑放置和武器碰撞。

Warning

零售版内容中会出现 0xFFFE 和 0xFFFD(Lava Run、Metal Heck 和几张早期战役地图)。它们既不是地图物件索引,也不是空白格:游戏在那里不放置任何东西,这些格子仍然可以建造。kbot 在往返时保留原值。

图块图形(PtrTileGfx)

Tiles × 1024 字节的调色板索引,在每个 32×32 块内按自上而下、从左到右排列,各块连续存放。地图以内联方式携带全部图块美术——把 TNT 放进游戏文件夹,就能玩。

地图物件名称(PtrTileAnim)

TileAnims 个如下条目:

typedef struct {
    uint32 Index;       // Matches array position; safe to ignore on read
    char   Name[128];   // NUL-padded, references a [FEATURE] section
                        //   in features/*.tdf
} TNTFeatureEntry;

这些名称对应 features/**/*.tdf 中的 [FEATURE] 节(见 TDF)——引擎正是借此知道要为“MetalTower10”或“ArmTreeFoo”绘制哪个 .3do 网格和哪个 .gaf 精灵图。地图物件的 TDF 条目还记录了占地范围、伤害、回收价值等。

Note

Cavedog 的工具会在每个 Name[128] 的 NUL 结束符之后的尾部留下未初始化的临时字节。 kbot tnt unpack(默认)只把可打印的部分写入 features.csv;往返时会重建干净的表。传入 --lossless 可以精确保留原始字节,实现逐字节一致的打包。

小地图(PtrMiniMap)

uint32 Width;   // 252
uint32 Height;  // 252 (33 of the 275 retail maps store 256)
uint8  pixels[Width * Height];  // Palette indices

只有当文件头中 0x2c 处的字(MinimapFlags;在 0x1020 地图中是 0x3c)的第 0 位被置位时,游戏才会读取存储的小地图;否则它会根据图块生成雷达画面。全部 275 张零售版地图都设置了这一位,studio 保存的每张地图也都设置了。只有当存储的小地图两边都至少为 252 时,游戏的雷达才会使用它。

地图画在小地图的左上角,其余部分是填充,即调色板索引 0x64(有几张战役地图用 0 填充)。这个区域显示的是可见地图,它比图块范围窄 32 像素、短 128 像素(游戏从不显示这些边缘):它较长的一边是 252,另一边保持可见地图的宽高比,向下取整:

visible_w = Width  * 16 - 32;    // Width/Height in attribute cells
visible_h = Height * 16 - 128;
if (visible_w >= visible_h) { region_w = 252; region_h = 252 * visible_h / visible_w; }
else                        { region_h = 252; region_w = 252 * visible_w / visible_h; }

因此 225×196 个图块得到 252×216 的区域,128×96(ac04)得到 252×182,96×128 得到 193×252。这个区域由地图的尺寸决定,而不是由像素决定:有些零售版小地图根本没有填充(ac01、ac04 到 ac11 填满了 252×256),但区域仍然是算出来的那个。kbot-io 的 Map.MinimapContentBounds 和 MinimapContentSize 会计算它;资源浏览器在其中放置 StartPos 标记,studio 保存的小地图也按这种方式生成(Map.BuildMinimap:小地图的每个像素是它所覆盖的地图像素的平均颜色)。


实例解析——metal heck.tnt

$ kbot tnt describe "maps/metal heck.tnt"
File Size: 973176 bytes

Header:
  IDVersion:   0x2000 (Total Annihilation)
  Width:       262 (16px cells) -> 131 tiles, 4192 pixels
  Height:      262 (16px cells) -> 131 tiles, 4192 pixels
  SeaLevel:    1
  Tiles:       583 unique
  Features:    28 in table, 74 placements
  Minimap:     252x252
  MinimapFlags: 1 (bit 0 set, the game reads the stored minimap)
  Pads:        0 0 0 0

Elevation:
  min=13 max=181 mean=24.7  cells below sealevel: 0 (0.00%)

Top features:
  [  6] MetalTower10  count=10
  [ 14] MetalTower09  count=9
  [  5] MetalVent01   count=8
  ...

解读:Metal Heck 是一张 4192×4192 像素的地图,由 583 个不同的图块组成,有 74 处地图物件放置,取自 28 种不同的地图物件定义。这些地图物件的 .tdf 定义由“Metal”世界的压缩包提供。


解包与编辑

kbot tnt unpack <file.tnt> --target <dir> 会生成一个对应的 pack 命令可以读回的布局:

metal_heck/
├── map.png            full RGBA render of the tile mosaic
├── heightmap.png      8-bit grayscale, pixel value = raw elevation byte
├── minimap.png        paletted PNG of the embedded minimap
├── tiles/<n>.png      paletted 32×32 per unique tile
├── tilemap.csv        2D grid of tile indices (rows = y, cols = x)
├── features.csv       feature_index,name,attr_x,attr_y per placement
└── metadata.json      header constants + round-trip data

修改以上任何文件,然后重新打包:

kbot tnt pack ./metal_heck --target metal_heck.tnt

Important

默认的解包是有损的。 地图物件名称表不会写入 metadata.json,而是在打包时根据 features.csv 中不重复的名称重建。这会丢掉 Cavedog 的工具留在表中的任何尾部临时字节。要实现逐字节一致的往返,请传入 --lossless 记录原始的地图物件字节:

kbot tnt unpack "metal heck.tnt" -t ./metal_heck --lossless

配套的 .ota 文件

每个 .tnt 都在同一目录中附带一个 .ota(“Online Total Annihilation”)附属文件。任务元数据都在那里:便于阅读的名称、星球名、天气、重力、起始位置、AI 简报等。

见 TDF——.ota 与 .fbi、.tdf 是同一种类 INI 的文本格式。kbot tnt preview 会自动读取它,在预览图中画出带编号的起始位置标记。kbot 对地图的每一种视图都按游戏的方式读取 .ota 的方案和起始位置(见 TDF——OTA);studio 的地图编辑器在保存时会就地修改所打开地图的 .ota。


坑点

Warning

地图编辑器(TAE)的某些版本会写入 Height = actual_height + 6——Saruman 1997 年的文档就注意到了这一点:“怪得很,比用到的多 6”。现代工具把文件头中的尺寸视为权威,忽略尾部未使用的行。kbot 的读取器与此行为一致。

  • SeaLevel == 0 并不表示“没有水”——它表示高度字节 0 是最高的水下高度,也就是没有任何地方在水下。没有水的地图通常使用 SeaLevel = 1。
  • 图块索引是 uint16,因此一张地图最多可以有 65,536 个不同的图块——但实际上超过几千个就会让引擎因内存吃紧而频繁换页。Metal Heck(583 个图块)更接近中位数,而非长尾。studio 拒绝保存需要更多图块的地图,kbot tnt pack 也拒绝不是该地图图块索引的 tilemap.csv 值(比如 -1 或 70000,后者会回绕成 4464)。某个格子引用了超出图块集的图块时,地图仍能加载(游戏会读到图块集之外);kbot tnt lint 会将其报告为 bad-tile-index。
  • 小地图的填充是调色板索引 0x64;引擎会忽略地图区域之外的像素。
  • MinimapFlags(0x2c,在 0x1020 地图中是 0x3c)的第 0 位必须置位,否则游戏会忽略存储的小地图。其余位保持原样。
  • 四个 pad 字段总是零。 有些第三方编辑器会写入非零值;游戏似乎会忽略它们,但 kbot 会原样保留读到的任何值。
  • 横扫千军:王国使用一种相关但不兼容的地图格式(.btm / .bmp 高度图 + 图块页)。见 TA:K 地图。

典型大小

项目 在 Cavedog maps/*.tnt 中观察到的范围
文件大小 0.3 – 7 MB
地图尺寸(图块) 64×64 – 256×256(即边长 2048 – 8192 像素)
不同图块数 200 – 2000
地图物件放置数 10 – 600
表中的地图物件定义数 5 – 50
文件头开销 总是 64 字节
小地图(252×252 带调色板;33 张零售版地图为 252×256) 63504 字节
配套 .ota 文本大小 1 – 8 KB

参考目录中的实际示例

值得研究的地图——可从任何 TA 安装目录的 maps/<name>.tnt 中提取:

  • metal heck.tnt——即上文实例解析中的地图。4192×4192 像素,583 个不同图块,74 处地图物件放置。资源丰富的经典地图。
  • a gentle time.tnt——4 人小地图;制作 TNT 查看器或渲染管线原型时的入门之选。
  • lava run.tnt——属性格中有不寻常的 0xFFFE 地图物件哨兵值(早期逆向工程中那些边界情况就来自这类地图)。
  • acid foursome.tnt——8 人大地图,展示了配套 .ota 中分布很开的 [Schema 0] 起始位置。
  • example.tnt——最小的发行版 TNT(约 263 KB);用于 kbot tnt unpack / pack 往返测试的最小可用地图。

横扫千军:王国的 TNT 变体(IDVersion 字不同)见 TA:K 地图研究笔记。


另见

  • SCT——TAE 用来拼装 TNT 的构件。
  • TDF——按名称解析的地图物件定义,以及配套 .ota 文件的格式。
  • PAL——渲染图块像素和小地图所用的调色板。
  • GAF——kbot tnt preview 所合成的地图物件精灵图。
  • TA:K 地图——横扫千军:王国截然不同的地图管线。
  • 术语表——属性格(attribute cell)、海平面(sea level)、空白格(void cell)。