《星际争霸:巢穴之战》 。 对我来说意味着多少 对于你们中的许多人。 如此之多,以至于我怀疑是否应该链接到Wiki。
一次, 霍尔特(Halt)在一封个人电子邮件中敲了我,并提出要学习Rust 。 像任何普通人一样,我们决定从 你好世界 为Windows编写一个动态库,该库可以加载到StarCraft游戏的地址空间中并管理单元。
本文将介绍使用技术来寻找解决方案的过程,这些技术将使您能够学习Rust语言及其生态系统中的新事物,或者激发您以自己喜欢的语言实现bot的灵感,无论是C,C ++,ruby,python等
在韩国的国歌下,这篇文章当然值得一读:
布瓦皮
该游戏已经20岁了。 而且它仍然很受欢迎 ,即使在2017年 ,锦标赛也聚集了美国整个大厅,在那里举行了大师级Jaedong与Bisu的战斗。 除现场玩家外, 无灵车也参加战斗! 这要归功于BWAPI 。 更有用的链接 。
十多年来,围绕这个游戏的机器人开发者社区已经出现。 爱好者会编写机器人并参加各种锦标赛。 他们中的许多人都学习AI和机器学习。 大学使用BWAPI来教育学生。 甚至还有一个抽搐频道播放游戏。
因此,几年前,一群粉丝扭转了Starcraft的内部结构,并编写了C ++ API,该API可让您编写机器人,融入游戏过程并控制可怜的矮人。
因为它经常发生在 盖房子,你需要买矿石,锻造工具... 写一个机器人,您需要实现API。 Rust可以提供什么?
联邦调查局
与Rust中的其他语言进行交互非常简单。 为此有FFI 。 让我提供文档的简短摘录。
假设我们有一个快照库,其中包含一个snappy-ch头文件,将从中复制函数声明。
使用cargo创建一个项目。
$ cargo new --bin snappy Created binary (application) `snappy` project $ cd snappy snappy$ tree . ├── Cargo.toml └── src └── main.rs 1 directory, 2 files
Cargo为该项目创建了标准文件结构。
在Cargo.toml
指定对libc的依赖关系:
[dependencies] libc = "0.2"
src/main.rs
文件将如下所示:
extern crate libc;
我们收集并运行:
snappy$ cargo build ... snappy$ cargo run Finished dev [unoptimized + debuginfo] target(s) in 0.02s Running `target/debug/snappy` max compressed length of a 100 byte buffer: 148
您只能调用“ cargo run
,而在发射前将调用“ cargo run
。 或构建一个项目并直接调用二进制文件:
snappy$ ./target/debug/snappy max compressed length of a 100 byte buffer: 148
在已安装snappy库的情况下编译代码(对于Ubuntu,必须安装libsnappy-dev软件包)。
snappy$ ldd target/debug/snappy ... libsnappy.so.1 => /usr/lib/x86_64-linux-gnu/libsnappy.so.1 (0x00007f8de07cf000)
如您所见,我们的二进制文件链接到libsnappy共享库。 snappy_max_compressed_length
调用是此库中的函数调用。
锈结合
如果我们能够自动生成FFI,那就太好了。 幸运的是,在拉斯托曼诺夫的军械库中有一个叫做rust-bindgen的工具 。 她能够生成与C(和某些C ++)库的FFI绑定。
安装方式:
$ cargo install bindgen
使用rust-bindgen是什么样的? 我们获取C / C ++头文件,在其上设置bindgen实用程序,然后获得生成的Rust代码以及结构,结构和功能的定义。 这就是用于快照的FFI代的样子:
$ bindgen /usr/include/snappy-ch | grep -C 1 snappy_max_compressed_length extern "C" { pub fn snappy_max_compressed_length(source_length: usize) -> usize; }
事实证明,bindgen在BWAPI标头前面传递,生成大量不可用的代码表(由于虚拟成员函数,公共API中的std ::字符串等)。 事实是BWAPI用C ++编写。 即使在C ++项目中,C ++通常也很难使用。 一旦可以更好地使用相同的链接器(相同版本)链接已编译的库,则可以使用相同的编译器(相同版本)更好地解析头文件。 因为有很多因素可以影响结果。 例如, mangling ,GNU GCC仍然无法正确实现 。 这些因素是如此重要,以至于即使在gtest中也无法克服这些因素,并且文档表明 ,最好使用相同的编译器和相同的链接器将gtest作为项目的一部分进行构建。
布瓦比克
C是通用语言编程。 如果rust-bindgen适用于C语言,为什么不为C实现BWAPI,然后使用其API? 好主意!
是的,这是一个好主意,直到您深入了解BWAPI并查看需要实现的类和方法的数量=(尤其是所有这些结构在内存,汇编器,内存补丁和其他我们没有时间的恐怖中的布局)。有必要最大限度地利用现有解决方案。
但是我们必须以某种方式处理改型,C ++代码,继承和虚拟成员函数。
在C ++中,有两个强大的工具可用来解决问题,它们是不透明的指针和extern "C"
。
extern "C" {}
使C ++代码能够伪装成C。这使您可以生成纯函数名称而无需处理。
不透明的指针使我们能够擦除类型并创建指向我们未提供其实现的“某种类型”的指针。 由于这仅是某种类型的声明,而不是其实现,因此无法通过值使用此类型,它只能由指针使用。
假设我们有这样的C ++代码:
namespace cpp { struct Foo { int bar; virtual int get_bar() { return this->bar; } }; }
我们可以将其转换为C头文件,如下所示:
extern "C" { typedef struct Foo_ Foo;
还有C ++部分,这将是C标头和C ++实现之间的链接:
int Foo_get_bar(Foo* self) {
并非所有的类方法都必须以这种方式处理。 BWAPI中有一些类,您可以使用这些结构的字段值来实现自己的操作,例如typedef struct Position { int x; int y; } Position;
typedef struct Position { int x; int y; } Position;
以及Position::get_distance
类的方法。
有些人必须以特殊的方式受到审判。 例如,AIModule应该是指向具有特定虚拟成员函数集的C ++类的指针。 但是,这里是标题和实现 。
因此,经过几个月的努力, 554种方法和十几个类,跨平台库BWAPI-C诞生了,它使您可以用C编写机器人。 副产品是交叉编译,并且能够以任何其他支持FFI和cdecl调用约定的语言来实现API。
如果要编写库,请编写C API。
BWAPI-C的最重要功能是与其他语言集成的最大能力。 Python
, Ruby
, Rust
, PHP
, Java
和许多其他人都知道如何使用C,因此,如果您对文件进行一点工作并实现包装器,则也可以在它们上编写一个机器人。
用C编写机器人
本部分介绍了Starcraft模块设计的一般原理。
机器人有2种类型:模块和客户端。 我们将看一个编写模块的例子。
模块是可下载的库;可以在此处查看加载的一般原理。 该模块应导出2个函数: newAIModule
和gameInit
。
使用gameInit
一切都很简单,调用此函数可以将指针传递给当前游戏。 该指针非常重要,因为在BWAPI中,存在一个全局静态变量,该变量在代码的某些部分中使用。 让我们描述gameInit
:
DLLEXPORT void gameInit(void* game) { BWAPIC_setGame(game); }
newAIModule
有点复杂。 它应该返回一个指向C ++类的指针,该类具有一个虚拟方法表,该虚拟方法表的名称为onXXXXX ,在某些游戏事件中被调用。 定义模块的结构:
typedef struct ExampleAIModule { const AIModule_vtable* vtable_; const char* name; } ExampleAIModule;
第一个字段必须是指向方法表的指针(魔术,所有事物)。 因此, newAIModule
函数:
DLLEXPORT void* newAIModule() { ExampleAIModule* const module = (ExampleAIModule*) malloc( sizeof(ExampleAIModule) ); module->name = "ExampleAIModule"; module->vtable_ = &module_vtable; return createAIModuleWrapper( (AIModule*) module ); }
createAIModuleWrapper
是另一种魔术,它可以将C指针转换为带有虚拟的C ++类的指针 方法 成员函数。
module_vtable
是方法表上的静态变量,方法的值填充有指向全局函数的指针:
static AIModule_vtable module_vtable = { onStart, onEnd, onFrame, onSendText, onReceiveText, onPlayerLeft, onNukeDetect, onUnitDiscover, onUnitEvade, onUnitShow, onUnitHide, onUnitCreate, onUnitDestroy, onUnitMorph, onUnitRenegade, onSaveGame, onUnitComplete }; void onEnd(AIModule* module, bool isWinner) { } void onFrame(AIModule* module) {} void onSendText(AIModule* module, const char* text) {} void onReceiveText(AIModule* module, Player* player, const char* text) {} void onPlayerLeft(AIModule* module, Player* player) {} void onNukeDetect(AIModule* module, Position target) {} void onUnitDiscover(AIModule* module, Unit* unit) {} void onUnitEvade(AIModule* module, Unit* unit) {} void onUnitShow(AIModule* module, Unit* unit) {} void onUnitHide(AIModule* module, Unit* unit) {} void onUnitCreate(AIModule* module, Unit* unit) {} void onUnitDestroy(AIModule* module, Unit* unit) {} void onUnitMorph(AIModule* module, Unit* unit) {} void onUnitRenegade(AIModule* module, Unit* unit) {} void onSaveGame(AIModule* module, const char* gameName) {} void onUnitComplete(AIModule* module, Unit* unit) {}
通过函数的名称及其签名,可以清楚地看出在什么条件下以及用什么参数调用它们。 例如,我将所有函数设为空
void onStart(AIModule* module) { ExampleAIModule* self = (ExampleAIModule*) module; Game* game = BWAPIC_getGame(); Game_sendText(game, "Hello from bwapi-c!"); Game_sendText(game, "My name is %s", self->name); }
游戏开始时调用此函数。 指向当前模块的指针作为参数传递。 BWAPIC_getGame
返回指向我们通过调用BWAPIC_setGame
设置的游戏的全局指针。 因此,我们将展示一个交叉编译和模块操作的示例:
bwapi-c/example$ tree . ├── BWAPIC.dll └── Dll.c 0 directories, 2 files bwapi-c/example$ i686-w64-mingw32-gcc -mabi=ms -shared -o Dll.dll Dll.c -I../include -L. -lBWAPIC bwapi-c/example$ cp Dll.dll ~/Starcraft/bwapi-data/ bwapi-c/example$ cd ~/Starcraft/bwapi-data/ Starcraft$ wine bwheadless.exe -e StarCraft.exe -l bwapi-data/BWAPI.dll --headful ... ... ...
我们戳一下按钮并开始游戏。 您可以在BWAPI网站和BWAPI-C中阅读有关启动的更多信息。
模块的结果:

在bwapi-c / example / Dll.c中可以找到一个稍微复杂一些的模块示例,其中显示了与迭代器,单元控制,矿物搜索和统计信息一起工作的情况。
bwapi系统
在Rasta生态系统中, 习惯上以某种方式调用链接到本机库的程序包。 任何foo-sys软件包都具有两个重要功能:
- 链接到本地libfoo库
- 提供来自libfoo库的函数声明。 但是,仅不提供声明-* -sys箱中的高级抽象。
为了使* -sys程序包能够成功链接,他们将本机库搜索和/或来自源的库程序集集成到其中。
为了使* -sys包提供声明,必须手动编写它们或使用bindgen生成。 再次bindgen。 尝试第二个=)
用bwapi-c生成活页夹变得淫秽:
bindgen BWAPI.h -o lib.rs \ --opaque-type ".+_" \ --blacklist-type "std.*|__.+|.+_$|Game_v(Send|Print|Draw).*|va_list|.+_t$" \ --no-layout-tests \ --no-derive-debug \ --raw-line "#![allow(improper_ctypes, non_snake_case)]" \ -- -I../submodules/bwapi-c/include sed -i -r -- 's/.+\s+(.+)_;/pub struct \1;/' lib.rs
其中BWAPI.h
是包含来自BWAPI-C的所有标头标头的文件。
例如,对于已知的函数,bindgen生成以下声明:
extern "C" {
有两种策略:将生成的代码存储在存储库中,并在组装期间动态生成代码。 这两种方法都有其优点和缺点 。
欢迎使用bwapi-sys ,这是朝我们目标迈出的又一小步。
还记得我刚才提到的跨平台吗? Nlinker加入了该项目并实施了一项棘手的策略。 如果目标是Windows,则从github下载已经组装好的BWAPIC。 对于其余的目标,我们从OpenBW的来源中收集BWAPI-C(稍后再告诉您)。
布瓦皮尔
现在有了绑定器,我们可以描述高级抽象了。 我们有两种类型可以使用:纯值和不透明指针。
有了纯价值,一切都会变得简单。 以颜色为例。 我们需要使使用Rust代码更方便,以便我们可以以方便自然的方式使用颜色:
game.draw_line(CoordinateType::Screen, (10, 20), (30, 40), Color::Red); ^^^
因此,为了方便使用,有必要使用C ++的常量定义Rust语言枚举的惯用语,并使用std :: convert :: From trait在bwapi_sys :: Color中定义转换方法:
尽管为方便起见,您可以使用crate enum-primitive-derive 。
使用不透明的指针,这不再困难。 为此,请使用Newtype模式:
pub struct Player(*mut sys::Player);
也就是说,Player是具有私有字段的特定结构-来自C的原始不透明指针。这是如何描述Player :: color方法的方法:
impl Player {
现在我们可以用Rust编写我们的第一个机器人了!
用Rust编写一个机器人
作为概念的证明,该机器人将看起来像一个著名的国家:它的所有功能都是雇用工人并收集矿物质。


让我们从所需的gameInit
和newAIModule
:
#[no_mangle] pub unsafe extern "C" fn gameInit(game: *mut void) { bwapi_sys::BWAPIC_setGame(game as *mut bwapi_sys::Game); } #[no_mangle] pub unsafe extern "C" fn newAIModule() -> *mut void { let module = ExampleAIModule { name: String::from("ExampleAIModule") }; let result = wrap_handler(Box::new(module)); result }
#[no_mangle]
与C ++中的extern "C"
执行相同的功能。 在wrap_handler
内部,替换虚拟函数表并伪装成C ++类会发生各种魔术。
模块结构的描述比C语言更简单,更漂亮:
struct ExampleAIModule { name: String, }
添加一些用于呈现统计信息和发出订单的方法:
impl ExampleAIModule { fn draw_stat(&mut self) { let game = Game::get(); let message = format!("Frame {}", game.frame_count()); game.draw_text(CoordinateType::Screen, (10, 10), &message); } fn give_orders(&mut self) { let player = Game::get().self_player(); for unit in player.units() { match unit.get_type() { UnitType::Terran_SCV | UnitType::Zerg_Drone | UnitType::Protoss_Probe => { if !unit.is_idle() { continue; } if unit.is_carrying_gas() || unit.is_carrying_minerals() { unit.return_cargo(false); continue; } if let Some(mineral) = Game::get() .minerals() .min_by_key(|m| unit.distance_to(m)) {
为了使ExampleAIModule类型变成一个真实的模块,您需要教它响应onXXXX事件,为此您需要实现EventHandler类型,它是C语言中AIModule_vtable虚拟表的类似物:
impl EventHandler for ExampleAIModule { fn on_start(&mut self) { Game::get().send_text(&format!("Hello from Rust! My name is {}", self.name)); } fn on_end(&mut self, _is_winner: bool) {} fn on_frame(&mut self) { self.draw_stat(); self.give_orders(); } fn on_send_text(&mut self, _text: &str) {} fn on_receive_text(&mut self, _player: &mut Player, _text: &str) {} fn on_player_left(&mut self, _player: &mut Player) {} fn on_nuke_detect(&mut self, _target: Position) {} fn on_unit_discover(&mut self, _unit: &mut Unit) {} fn on_unit_evade(&mut self, _unit: &mut Unit) {} fn on_unit_show(&mut self, _unit: &mut Unit) {} fn on_unit_hide(&mut self, _unit: &mut Unit) {} fn on_unit_create(&mut self, _unit: &mut Unit) {} fn on_unit_destroy(&mut self, _unit: &mut Unit) {} fn on_unit_morph(&mut self, _unit: &mut Unit) {} fn on_unit_renegade(&mut self, _unit: &mut Unit) {} fn on_save_game(&mut self, _game_name: &str) {} fn on_unit_complete(&mut self, _unit: &mut Unit) {} }
建立和启动模块就像使用C一样容易:
bwapi-rs$ cargo build --example dll --target=i686-pc-windows-gnu bwapi-rs$ cp ./target/i686-pc-windows-gnu/debug/examples/dll.dll ~/Starcraft/bwapi-data/Dll.dll bwapi-rs$ cd ~/Starcraft/bwapi-data/ Starcraft$ wine bwheadless.exe -e StarCraft.exe -l bwapi-data/BWAPI.dll --headful ... ... ...
和工作视频:
关于交叉编译的一些知识
简而言之,Rust很漂亮! 只需单击两次,您就可以为不同的平台放置许多工具链。 具体来说,i686-pc-windows-gnu工具链是通过以下命令设置的:
rustup target add i686-pc-windows-gnu
您还可以在.cargo/config
项目的根目录中为货物指定kofig:
[target.i686-pc-windows-gnu] linker = "i686-w64-mingw32-gcc" ar = "i686-w64-mingw32-ar" runner = "wine"
这就是从Windows on Linux编译Rust项目所需要做的一切。
Openbw
这些家伙走得更远。 他们决定编写游戏SC:BW!的开源版本。 他们做得很好。 他们的目标之一是实现高清图片,但是SC:Remastered领先于他们=(目前,您可以使用他们的API来编写机器人(是的,在C ++中也是如此)。但是,最令人惊奇的功能是可以在浏览器中直接查看回放的功能。
结论
在实现过程中仍然存在一个未解决的问题:我们不控制链接的唯一性,并且在更改对象时同时存在&mut
和&
会导致不确定的行为。 麻烦了 霍尔特(Halt) 试图实现惯用的绑定,但是他的导火索稍微消失了。 同样,要解决此问题,您将必须定性地使用C ++ API并正确设置const
限定符。
我真的很享受这个项目的工作,我观看了重播并深入到了大气中。 该游戏留下了遗产。 没有游戏在SC:BW上很流行,并且它对치정치的影响是不可想象的。 在黄金时段,韩国的职业玩家和韩国戏剧一样受欢迎。 또한프프게머머있있있있있있있있있있있있있있있있있있있있있있있있있있있있있있있있있있있。
星际争霸万岁!
参考文献