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