所有格式页面 ↓
图形与媒体

GAF——图形动画格式

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

GAF 存储索引色的精灵图动画。横扫千军中几乎每一个会动的 2D 元素都是 GAF:光标、鼠标指针、爆炸、单位部件、树木摇曳、武器图标、熔岩气泡,应有尽有。横扫千军:王国以 .taf 扩展名沿用同一种容器来存放它的带调色板动画(调色板取自一个附属文件)。TA:K 还发布了一种真彩色 .taf 变体,存储 16 位 ARGB 像素而不是索引——它另有专页:TAF / TSF。

cursors.gaf 中的 cursormove 序列    cursorselect 中的单帧

Tip

自己试试。

kbot gaf list   anims/cursors.gaf                                      # sequences, frame counts, loop flag
kbot gaf export anims/cursors.gaf --format gif --sequence 0 --target cursor.gif
kbot gaf dump   anims/cursors.gaf --target ./cursors                   # one PNG per frame

所有参数见 CLI 的 kbot gaf 参考。

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

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

r, _ := gaf.LoadFromFile("anims/cursors.gaf")
defer r.Close()
sequences, _ := r.ReadSequences()
for _, seq := range sequences {
    fmt.Println(seq.Name, len(seq.Frames))
}

概览

┌─ Header (12B) ─┐
│ ver  N  pad    │
└────────┬───────┘
         ▼
┌─ Sequence offset table ─┐  N × uint32 absolute offsets
└───┬─────────────────────┘
    ▼
┌─ SequenceHeader (40B) ─┐
│ frameCount, loop word, │
│ +4 word, name[32]      │
└─────────┬──────────────┘
          ▼
┌─ FrameListItem (8B) × frameCount ─┐  ptr + tick duration
└───┬───────────────────────────────┘
    ▼
┌─ FrameInfo (24B) ─┐
│ size, origin,     │
│ transparency,     │
│ compressed?,      │
│ layers, +11 byte  │
│ ptrPixelData      │
└─────┬─────────────┘
      ▼
┌─ Pixel data: raw or run-encoded ─┐
└──────────────────────────────────┘

GAF 是由序列组成的扁平数组;每个序列是由帧组成的数组;每一帧是一张宽 × 高的调色板索引位图,带有一个原点,以及(可选的)RLE 式透明游程。帧可以携带“子帧”,使一个逻辑上的动画步骤由多个叠加的图层合成。


文件头(12 字节)

typedef struct {
    uint32 Version;        // 0x00010100 (or 0 for a handful of stock GAFs)
    uint32 SequenceCount;  // Number of animations in the file
    uint32 Unknown1;       // Always 0
} GAFHeader;
字段 说明
Version 所有第三方工具都写 0x00010100。Cavedog 的 anims/terrain.gaf 和 anims/vismasks.gaf 发布时 Version == 0。 游戏不检查这个字;kbot 接受任何值(不认识的值会作为警告报告),写出时用 0x00010100。
SequenceCount 紧随其后的是 SequenceCount × uint32 个文件绝对偏移量,每个都指向一个 SequenceHeader。游戏只使用低 16 位,并按有符号数读取:1–32767 个序列;为零或负数时表示没有序列。
Unknown1 始终为零;写入时写零。

序列(40 字节)

typedef struct {
    uint16 FrameCount;
    uint16 LoopFlags;      // +2: low byte non-zero = the sequence loops
    uint32 Unknown4;       // +4: not used by the game; stock files store 0
    char   Name[32];       // NUL-padded, case-preserved
} GAFSequence;

当 LoopFlags 的低字节被置位时,游戏循环播放该序列;为 0 时只播放一次(然后停在最后一帧)。所有原版序列存的都是 1。 kbot 重写文件时会保留这两个字;对于没有 sequence.csv 的序列,kbot gaf build 会赋予原版的值 1。

其后是 FrameCount × FrameListItem:

typedef struct {
    uint32 PtrFrameInfo;   // → FrameInfo
    uint32 Duration;       // Game ticks (30 ticks = 1 second); low 16 bits used
} FrameListItem;

游戏只读取 Duration 的低 16 位,并按其 30 Hz 动画时钟,把一帧显示 max(Duration, 1) 个 tick。

序列名很重要——游戏按名称查找动画(例如 UnitInfo 引用 corpyro.gaf 脚本中的 corpyro_smoke)。匹配时请把它们当作不区分大小写的 ASCII。


帧(24 字节信息 + 载荷)

typedef struct {
    uint16 Width;
    uint16 Height;
    int16  OriginX;            // Render anchor, may be negative
    int16  OriginY;
    uint8  TransparencyIndex;  // Key: transparent in raw frames
    uint8  Compressed;         // 0 = raw bitmap, non-zero = run-encoded
    uint8  LayerCount;         // +10: 0 = simple frame, >0 = composite of layers
    uint8  Blend;              // +11: set on a layer = drawn translucently
    uint32 Unknown2;           // +12: editor scratch, not used by the game
    uint32 PtrFrameData;       // → pixel bytes OR layer pointer table
    uint32 Unknown3;           // Always 0
} FrameInfo;

OriginX / OriginY 描述把帧绘制(blit)到世界中时使用的锚点。对于爆炸精灵图,原点通常是爆炸中心;对于光标,则是热点像素。

分层帧

当 LayerCount > 0 时,PtrFrameData 是一个由 LayerCount × uint32 个指针组成的数组,指向嵌套的 FrameInfo,而不是像素数据。各图层按数组顺序(从后往前)绘制,其热点对齐外层帧的热点。+11 字节被置位的图层会经由半透明表绘制,而不是直接复制(没有任何原版文件设置它)。+10 字节和 +11 字节是两个独立的字段:如果把它们当作一个 16 位计数来读取,一个被置位的 +11 字节就会变成 256 个图层。

像素数据

TransparencyIndex 是逐帧的,而不是整个文件共用——例如光标 GAF 在某些帧上使用索引 9(系统粉色),在另一些帧上使用索引 0。

未压缩(Compressed == 0)

Width × Height 个字节,从上到下、从左到右。游戏绘制所有像素,等于 TransparencyIndex 的除外。单位纹理(textures/*.gaf)和视野遮罩(anims/vismasks.gaf)被当作普通像素数组读取,所以它们的帧必须是未压缩的;那里的每个原版帧也确实如此。

压缩(Compressed != 0)——TA 游程编码

像素数据是由 Height 个相互独立的行组成的序列。每行以一个 uint16 lineLength 开头,其后是恰好 lineLength 字节的游程编码字节流。在该字节流中,每个块(chunk)以一个 1 字节的掩码开头,其最低两位决定块的种类:

掩码测试 块的种类 长度(像素) 后续字节
mask & 0x01 透明游程 count = mask >> 1 (无——这些像素被跳过)
mask & 0x02 重复游程 count = (mask >> 2) + 1 1 字节——要重复的颜色索引
都不是 字面复制 count = (mask >> 2) + 1 count 字节——字面像素

因此,每个块的最大游程长度为:透明 127 像素,重复 64 像素,字面 64 像素。只有被跳过的像素才是透明的: 重复或字面像素即使其值等于 TransparencyIndex 也会被绘制,调色板索引 0 就是普通的不透明黑色。如果编码的行在达到 Width 之前就结束了,kbot 会用透明像素补齐并报告;游戏则会继续读入后面的字节。图层可以比外层帧小,并由各自的 OriginX/Y 定位。

参考:formats/gaf/gaf.go readCompressed 和 formats/gaf/writer.go compressRow。

Important

GAF 本身不带调色板。 文件只存储 8 位索引。要以彩色渲染,你需要全局的 TA 调色板(palettes/palette.pal),或者对于横扫千军:王国,需要一个存放在 GAF 之外、按资源区分的调色板。TAK 专有的机制见[横扫千军:王国——调色板解析] (#ta-kingdoms--palette-resolution)和横扫千军:王国——透明度怪癖。kbot gaf export 默认使用内置的 TA 调色板和游戏的透明规则;两者都可以覆盖。


横扫千军:王国——调色板解析

Note

本节只讲 TA:K 的差异。 TA 的渲染器总是使用 palettes/palette.pal;GAF 的字节布局在 TA 和 TAK 之间完全相同,但 TAK 换上了一套按资源区分的调色板管线。

横扫千军:王国发布的 .gaf(有时是 .taf)容器与 TA 相同,但调色板取自按资源区分的附属文件。零售版 TAK 按优先级顺序遵循三条有据可查的约定:

  1. 与 .gaf 相邻的同名 .pcx。 界面/动画资源会附带一个 1×1 的 PCX 文件,所需的调色板作为该 PCX 的 PLTE 块嵌入其中。anims/actionbuttons.gaf 以 anims/actionbuttons.pcx 为调色板渲染。TAK 的 anims/*.gaf 文件中约有 38% 带有这样的附属文件。

  2. palettes/ 中基本名相同的 .pal。 很少见;只涉及少数原版资源,Cavedog 为它们导出了原始的 1024 字节调色板。

  3. 按文件名前缀区分的阵营调色板。 单位和地图物件的 GAF 以 3 个字母的阵营前缀命名,并使用对应阵营的调色板:

    前缀 阵营 调色板文件
    ara* Aramon palettes/aramon.pcx
    tar* Taros palettes/taros.pcx
    ver* Veruna palettes/veruna.pcx
    zon* Zhon palettes/zhon.pcx
    aid* Aiden(Iron Plague) palettes/aiden.pcx
    cre* Creon(Iron Plague) palettes/creon.pcx

如果以上都解析不到,解析器会退回到 palettes/palette.pal,最后退回到内置的 TA 调色板——在零售版 TAK 中,这两者逐字节相同,也与 TA 的调色板相同,因此对于不匹配任何阵营前缀的单位/地图物件 GAF,如果不显式覆盖,颜色就会是错的。

kbot 的解析器

internal/palettepick 实现了上面的解析链。每个渲染 GAF 的入口都会经过它:

  • Web 资源浏览器。 /png/… 和 /apng/… 路由默认自动检测;GAF 查看器提供一个调色板下拉菜单,列出解析器找到的每个候选项,外加一个 auto 选项。传入 ?palette=<vfs-path> 可以强制使用某一个(下拉菜单会替你做这件事)。
  • kbot gaf export / kbot gaf dump。 传入 --palette <path.pcx|path.pal> 进行覆盖;否则使用内置的 TA 调色板(CLI 不运行 VFS 解析器,因为除非你用 kbot mount 包装它,否则它没有 VFS 上下文)。
  • MCP 的 gaf_export 工具。 接受一个可选的 palette 参数(配置的 game-data 文件夹内的路径);省略时运行完整的解析器。

把 TA 的 GAF 移植到横扫千军:王国

TA 和 TAK 的 GAF 字节布局完全相同——只有调色板来源不同。要移植一个现有的 TA .gaf:

  1. 创建一个嵌入了 TA 调色板的 1×1 带调色板 PCX。
  2. 把它以相同的基本名保存在 GAF 旁边(例如 MyGaf.gaf + MyGaf.pcx)。

这样就足以让 TAK 以原来的 TA 颜色渲染它。


横扫千军:王国——透明度怪癖

Note

本节只讲 TA:K 的差异。 TA 的渲染器以帧一节中的 TransparencyIndex 字节为准,但 TAK 的内容作者在未压缩帧中并没有让它与实际像素数据保持一致。

许多 TAK 纹理图集 GAF(textures/*.gaf 下的全部文件,以及 anims/*.gaf 中的一部分)带有这样的未压缩帧:元数据 TransparencyIndex 读出某个常量(通常为 9),而这个值从未出现在实际的像素数据中——与此同时,美术人员用另一个调色板索引(常见的是 5)填充了“透明”边框。照字面读取元数据,就会在 TA 渲染器本应产生透明的地方渲染出不透明的彩色背景。

TA 的渲染器从不猜测:对未压缩帧使用存储的透明色键,对压缩帧使用跳过命令。对于 TAK 图集,kbot 提供一种需手动启用的角点检测启发式(TransparencyModeHeuristic,见 kbot-io 的 formats/gaf 中的 EffectiveTransparencyIndex),它只适用于未压缩帧:

  1. 如果 TransparencyIndex 出现在像素缓冲区的任何位置,就信任元数据。
  2. 否则,取四个角的像素。如果四个角的值都一致,就把这个值用作实际的透明索引。这能挽救那些美术人员画了统一边框的 TAK 未压缩帧。
  3. 否则,退回到磁盘上的 TransparencyIndex(这样可以保持像 dungwormB1 这样铺满画面的纹理的渲染效果——它的四个角都是单位像素,自动检测会正确地放弃覆盖)。

压缩帧和合成帧始终使用其被跳过的像素。磁盘上的字节永远不会被改写——往返写入器看到的是原始值。一个带统一边框、完全不透明的 TA 帧在这种启发式下会丢掉边框,所以 TA 的渲染从不使用它。

渲染时覆盖透明度

gaf.RenderOptions 提供以下模式:

模式 行为 用途
TransparencyModeAuto / TransparencyModeMetadata 游戏的规则:未压缩帧中的透明色键像素和压缩帧中被跳过的像素是透明的;索引 0 是不透明的黑色(零值,也是 kbot 中所有地方针对 TA 的默认值)。 显示、导出、往返。
TransparencyModeHeuristic 未压缩帧使用角点猜测(见上文),其余情况使用游戏的规则。studio 对横扫千军:王国的精灵图使用的就是它。 TAK 纹理图集。
TransparencyModeNone 每个像素都不透明。 检查透明色键的颜色。
TransparencyModeIndex 使用调用方提供的索引。 界面覆盖;调试。

每次导出时,调色板条目中除一个透明槽位外都是不透明的,这个槽位通常是该帧的透明色键。当某一帧绘制了值等于其透明色键的像素(或者同一动画的各帧把彼此的透明色键当作颜色使用)时,改用一个没有任何绘制像素使用的索引作为该槽位,从而让图像保留每一个调色板索引。

资源浏览器的 GAF 查看器在调色板选择器旁提供一个下拉菜单 Default / Game rule / Corner guess / None,并把所选项作为 ?transparency=game|heuristic|none|<N> 附加到每个 PNG/GIF/APNG 请求上。对 TA 的游戏文件默认采用游戏规则,对横扫千军:王国的游戏文件默认采用角点猜测。缓存键包含透明度标签,因此切换模式时不会返回过期的渲染结果。

MCP 的 gaf_export 工具采用同样的默认值:游戏规则,但注册为横扫千军:王国的 game-data 文件夹(takingdoms 类型的 kbot 上下文)使用角点猜测;其结果会注明所用的模式。kbot gaf export 读取的是不带游戏上下文的普通文件,所以默认采用游戏规则;对于横扫千军:王国的图集,请传入 --transparency heuristic。kbot gaf dump 总是使用游戏规则,因为 kbot gaf build 会从图像中读回存储的透明色键。


实例解析——cursors.gaf

$ kbot gaf list anims/cursors.gaf
GAF: 22 sequence(s), version 0x00010100

#   Name             Frames  Loops  Duration (ticks)  Duration (sec)
─   ────             ──────  ─────  ────────────────  ──────────────
0   cursormove       8       yes    64                2.13
1   cursorgrn        1       yes    10                0.33
2   cursorselect     2       yes    12                0.40
...

一个典型的 cursormove 帧:

Width:       32
Height:      32
OriginX:     16        ← hotspot in the middle of the cursor
OriginY:     16
Transparency: 0        ← this frame's key (compressed, so only skips are transparent)
Compressed:  1         ← run-encoded
LayerCount:  0         ← flat (no sub-frames)

构建 GAF

kbot gaf dump 写出一种目录布局,kbot gaf build 可以把它读回:

cursors/
├── cursormove/
│   ├── sequence.csv    ← index,name,loop_flags,unknown4
│   ├── frames.csv      ← frame,width,height,origin_x,origin_y,transparency,
│   │                     duration_ticks,duration_sec,storage,blend
│   ├── animated.png    ← preview only; not read back
│   ├── 0.png
│   ├── 1.png
│   └── ...
└── ...
kbot gaf dump  anims/cursors.gaf --target ./cursors
# edit some PNGs / tweak frames.csv or sequence.csv
kbot gaf build ./cursors --target ./cursors-rebuilt.gaf
kbot gaf roundtrip anims/                     # check dump/build keeps what the game reads

帧图像是按游戏规则绘制的索引色 PNG(使用 --format gif 时为 GIF),因此每个像素都保留其调色板索引,只有透明像素被标记出来。构建步骤把这些索引复制回去,并把透明像素变成该帧的透明色键;RGBA 图像则改为匹配到最接近的调色板颜色,同时避开透明色键。

  • sequence.csv 恢复序列的顺序、确切名称、循环字和 +4 字。没有它时,文件夹名就是序列名,并且序列会循环播放,与所有原版序列一样。
  • storage(raw 或 compressed)和 blend(+11 字节)恢复每一帧的帧头。没有 storage 的帧会被压缩,但写入 textures/*.gaf 或 anims/vismasks.gaf 的每一帧都是未压缩的,因为游戏把它们当作普通像素数组读取。--storage raw|compressed 会覆盖这两种情况。
  • 合成(分层)帧导出为一张拍平的图像,构建回去时成为简单帧;kbot gaf roundtrip 会报告有多少这样的帧。
  • 没有像素的帧(宽或高为 0)没有图像;只凭它在 frames.csv 中的那一行就能重建。
  • 既没有 frames.csv 也没有 sequence.csv 的子文件夹会被跳过并给出警告。其他任何无法构建的子文件夹(图像缺失或无法读取、CSV 值有误)都会让构建停止,因此序列绝不会被悄悄漏掉。

Note

TA 与 TA:K 的 GAF 逐字节相同。 容器完全相同;只有调色板管线不同。完整的解析链以及把 GAF 移植到 TAK 的实例步骤,见上文的横扫千军:王国——调色板解析。


坑点

Warning

循环字节为 0 的序列只播放一次就停下。 把 +2 字写成零的工具,会让经它们处理过的每个动画地图物件都定格不动。kbot 会保留这个字,而 kbot 新构建的序列都会循环播放。

  • 有些序列发布时帧时长为 0。 游戏把这样的帧显示一个 tick,与时长为 1 相同。不要把它们“修正”成某个默认值。
  • 压缩的像素行可能短于 Width。 游戏会把后面的字节当作命令继续读下去;kbot 用透明像素补齐该行并报告。没有任何原版文件存在这样的行。
  • 图层原点就是热点:每个图层的放置方式,是让它的原点落在外层帧的原点上。
  • 横扫千军:王国的 .taf 有两种。 kbot 把带调色板的 .taf 当作普通 GAF 读取,但 TA:K 也发布了真彩色的 .taf 文件(16 位 ARGB 像素,没有调色板)。检查帧格式字节即可区分两者,真彩色变体见 TAF / TSF。
  • anims/terrain.gaf 和 anims/vismasks.gaf 的 Version == 0。 游戏忽略版本字。
  • 每个序列的帧数没有上限——cursorteleport 有 46 帧。分配缓冲区时不要假定有固定上限。

典型大小

项目 在 Cavedog GAF 中观察到的范围
anims/cursors.gaf 约 30 KB,22 个序列,每个 1–46 帧
单位部件动画(例如枪口火焰) 5–20 KB,1 个序列,4–12 帧
爆炸(anims/explosions.gaf 一族) 每个文件 50–250 KB
anims/textures.gaf(纹理总表) 约 12 MB(TA 中最大的 GAF)
anims/terrain.gaf 约 1.5 MB
每帧数据 通常 <1 KB;大帧(256×256 的爆炸)可达 10 KB 以上

参考目录中的实际示例

TA 游戏文件中值得注意的 GAF——任何一个都可以先用 kbot hpi extract 提取,再用 kbot gaf list / kbot gaf export 查看:

  • anims/cursors.gaf——22 个序列,每个都是一个界面光标动画。最小的序列(cursorgrn、cursorred)只有一帧;最大的(cursorteleport)在 460 个 tick 内播放 46 帧。上文的实例解析一节展示了它。
  • anims/textures.gaf——约 12 MB,是被每个 3DO 模型的 OffsetToTextureName 字符串引用的图块总表。用 kbot gaf list anims/textures.gaf | head 浏览。
  • anims/explosions.gaf 一族——典型的特效 GAF:lasersht.gaf、acid.gaf、bigexpls.gaf——每个都包含多个有名称的爆炸序列。
  • anims/armcom.gaf / corcom.gaf——按阵营区分的指挥官部件动画(选择圈、建造激光)。
  • anims/terrain.gaf + anims/vismasks.gaf——仅有的两个文件头中 Version == 0 的原版 GAF(编写解析器时值得了解)。

另见

  • TAF / TSF——横扫千军:王国基于同一容器的真彩色 .taf 变体,以及它可编辑的文本形式。
  • PAL——渲染 GAF 帧所需的颜色表。
  • PCX——TA:K 如何为带调色板的 .taf 提供调色板。
  • 3DO——当一个“精灵图”实际上是带纹理的 3D 模型时(大多数爆炸是 GAF;大多数弹丸是 3DO + GAF 枪口火焰)。
  • 术语表——sub-frame、origin、tick。