Bot untuk Starcraft di Rust, C atau bahasa lainnya


StarCraft: Brood War . Game ini sangat berarti bagi saya! Dan bagi banyak dari Anda, saya kira. Begitu banyak, sehingga saya bertanya-tanya apakah saya harus memberikan tautan ke halamannya di Wikipedia atau tidak.


Suatu hari Hentikan mengirimi saya PM dan menawarkan untuk belajar Rust . Seperti orang biasa, kami memutuskan untuk memulai halo dunia menulis perpustakaan dinamis untuk Windows yang dapat dimuat ke ruang alamat StarCraft dan mengelola unit.


Artikel berikut akan menjelaskan proses menemukan solusi dan menggunakan teknologi dan teknik yang memungkinkan Anda mempelajari hal-hal baru tentang Rust dan ekosistemnya. Anda juga mungkin terinspirasi untuk mengimplementasikan bot menggunakan bahasa favorit Anda, apakah itu C, C ++, Ruby, Python, dll.


Sangat layak untuk mendengarkan nyanyian pujian dari Korea Selatan saat membaca artikel ini:


OST Starcraft

Bwapi


Game ini sudah hampir 20 tahun. Dan itu masih populer ; Kejuaraan menarik banyak orang di AS bahkan pada tahun 2017 di mana pertempuran grand master Jaedong vs Bisu terjadi. Selain pemain manusia, mesin tanpa jiwa juga mengambil bagian dalam pertempuran SC! Dan ini dimungkinkan karena BWAPI . Tautan yang lebih bermanfaat.


Selama lebih dari satu dekade sekarang, telah ada komunitas pengembang bot di sekitar game ini. Penggemar menciptakan bot dan berpartisipasi dalam berbagai kejuaraan. Banyak dari mereka mempelajari AI dan pembelajaran mesin. BWAPI digunakan oleh universitas untuk melatih siswa mereka. Bahkan ada saluran kedutan yang menyiarkan pertandingan semacam itu.


Jadi, tim penggemar membalikkan Starcraft back-end beberapa tahun yang lalu dan mengembangkan API di C ++, yang memungkinkan Anda membuat bot, melakukan injeksi dalam proses permainan dan mendominasi manusia yang sengsara.


Seperti yang sering terjadi, sebelumnya membangun rumah, perlu menambang bijih, menempa alat membuat bot, Anda perlu menerapkan API. Apa yang ditawarkan Rust?


Ffi


Cukup mudah untuk bekerja dengan bahasa lain dari Rust. Ada FFI untuk ini. Biarkan saya memberi Anda kutipan singkat dari dokumentasi .


Bayangkan kita memiliki pustaka tajam , yang memiliki file header snappy-ch , yang berisi deklarasi fungsi.


Mari kita buat proyek menggunakan kargo .


$ cargo new --bin snappy Created binary (application) `snappy` project $ cd snappy snappy$ tree . β”œβ”€β”€ Cargo.toml └── src └── main.rs 1 directory, 2 files 

Cargo telah membuat struktur file standar untuk proyek tersebut.


Di Cargo.toml kami menentukan ketergantungan ke libc :


 [dependencies] libc = "0.2" 

File src/main.rs akan terlihat seperti ini:


 extern crate libc; // To import C types, in our case for size_t use libc::size_t; #[link(name = "snappy")] // Specify the name of the library for linking the function extern { // We write the declaration of the function which we want to import // in C the declaration looks like this: // size_t snappy_max_compressed_length(size_t source_length); fn snappy_max_compressed_length(source_length: size_t) -> size_t; } fn main() { let x = unsafe { snappy_max_compressed_length(100) }; println!("max compressed length of a 100 byte buffer: {}", x); } 

Mari kita membangun dan menjalankan proyek:


 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 

Anda bisa memanggil cargo run saja, yang memanggil cargo build sebelum lari. Pilihan lain adalah membangun proyek dan memanggil biner secara langsung:


 snappy$ ./target/debug/snappy max compressed length of a 100 byte buffer: 148 

Jika snappy library terinstal kode akan dikompilasi (untuk Ubuntu Anda harus menginstal paket libsnappy-dev).


 snappy$ ldd target/debug/snappy ... libsnappy.so.1 => /usr/lib/x86_64-linux-gnu/libsnappy.so.1 (0x00007f8de07cf000) 

Seperti yang Anda lihat, biner kami ditautkan ke pustaka bersama libsnappy. Dan panggilan ke snappy_max_compressed_length dalam kode kami adalah panggilan fungsi dari perpustakaan ini.


karat-bindgen


Alangkah baiknya jika kita dapat secara otomatis menghasilkan FFI kita. Untungnya, ada sebuah utilitas yang disebut rust-bindgen di kotak peralatan Rust addict. Ia mampu menghasilkan binding FFI ke pustaka C (dan beberapa C ++).


Instalasi:


 $ cargo install bindgen 

Seperti apa bentuk karat-bindgen ? Kami mengambil file header C / C ++, kami mengarahkan utilitas bindgen padanya, dan output yang kami dapatkan dihasilkan kode Rust dengan deklarasi yang tepat untuk membiarkan kami menggunakan struktur dan fungsi C. Inilah yang bindgen hasilkan untuk tajam:


 $ bindgen /usr/include/snappy-ch | grep -C 1 snappy_max_compressed_length extern "C" { pub fn snappy_max_compressed_length(source_length: usize) -> usize; } 

Ternyata bindgen tidak dapat mengatasi header BWAPI, menghasilkan banyak kode yang tidak dapat digunakan (karena fungsi anggota virtual, std :: string dalam API publik, dll.). Masalahnya adalah bahwa BWAPI ditulis dalam C ++. C ++ umumnya sulit digunakan bahkan dari proyek C ++. Setelah pustaka dirakit, lebih baik untuk menautkannya dengan linker yang sama (versi yang sama), file header harus diurai dengan kompiler yang sama (versi yang sama). Semua faktor ini dapat mempengaruhi hasilnya. Mangling misalnya, yang masih tidak dapat diimplementasikan tanpa kesalahan di GNU GCC. Faktor-faktor ini sangat signifikan sehingga bahkan gtest tidak dapat mengatasinya. Dan dalam dokumentasi itu tertulis: Anda sebaiknya membangun gtest sebagai bagian dari proyek oleh kompiler yang sama dan tautan yang sama.


Bwapi-c


C adalah lingua franca dari rekayasa perangkat lunak. Jika rust-bindgen bekerja dengan baik untuk bahasa C, mengapa tidak mengimplementasikan BWAPI untuk C, dan kemudian gunakan API-nya? Ide bagus!


Ya, itu ide yang bagus sampai Anda melihat bagian dalam BWAPI dan melihat jumlah kelas dan metode yang harus Anda terapkan. Terutama semua susunan memori ini, kode asm, penambalan memori dan "kengerian" lainnya yang tidak sempat kita miliki. Perlu untuk menggunakan solusi yang ada sepenuhnya.


Tetapi kita perlu entah bagaimana mengalahkan fungsi mangling, kode C ++, inheritance dan anggota virtual.


Dalam C ++, ada dua alat kuat yang akan kita gunakan untuk menyelesaikan masalah kita: pointer buram dan extern "C" .


extern "C" {} memungkinkan kode C ++ untuk "menutupi" sendiri di bawah C. Hal ini memungkinkan untuk membuat nama fungsi tanpa mangling.


Pointer buram memungkinkan kita untuk menghapus jenis dan membuat pointer ke "beberapa jenis" tanpa menyediakan implementasinya. Karena ini hanya deklarasi dari beberapa jenis, tidak mungkin untuk menggunakan jenis ini berdasarkan nilai, Anda dapat menggunakannya hanya dengan pointer.


Bayangkan kita memiliki kode C ++ ini:


 namespace cpp { struct Foo { int bar; virtual int get_bar() { return this->bar; } }; } // namespace cpp 

Kita bisa mengubahnya menjadi header C:


 extern "C" { typedef struct Foo_ Foo; // Opaque pointer to Foo // call cpp::Foo::get_bar int Foo_get_bar(Foo* self); } 

Dan di sini adalah bagian C ++ yang akan menjadi tautan antara header C dan implementasi C ++:


 int Foo_get_bar(Foo* self) { // cast the opaque pointer to the certain cpp::Foo and call the method ::get_bar return reinterpret_cast<cpp::Foo*>(self)->get_bar(); } 

Tidak semua metode kelas harus diproses dengan cara ini. Di BWAPI, ada kelas yang bisa Anda implementasikan sendiri menggunakan bidang struktur ini, misalnya typedef struct Position { int x; int y; } Position; typedef struct Position { int x; int y; } Position; dan metode seperti Position::get_distance .


Ada kelas-kelas yang harus saya perlakukan dengan cara khusus. Sebagai contoh, AIModule harus menjadi pointer ke kelas C ++ dengan serangkaian fungsi anggota virtual tertentu. Namun demikian, ini adalah tajuk dan implementasi .


Jadi, setelah beberapa bulan kerja keras, 554 metode dan selusin kelas, perpustakaan lintas platform BWAPI-C lahir, yang memungkinkan Anda membuat bot di C. Produk sampingan adalah kemungkinan kompilasi silang dan kemampuan untuk mengimplementasikan API dalam bahasa lain yang mendukung FFI dan konvensi panggilan cdecl.


Jika Anda menulis perpustakaan, silakan, tulis API-nya di C.


Fitur terpenting dari BWAPI-C adalah integrasi seluas mungkin dengan bahasa pemrograman lain. Python , Ruby , Rust , PHP , Java , dan banyak lagi yang lain dapat bekerja dengan C, jadi jika Anda menyelesaikan masalah dan menerapkan pembungkus sendiri, Anda juga dapat menulis bot dengan bantuan mereka.


Menulis bot di C


Bagian ini menjelaskan prinsip-prinsip umum organisasi internal modul Starcraft.


Ada 2 jenis bot: modul dan klien. Mari kita lihat contoh penulisan modul.


Modul ini adalah perpustakaan yang dinamis. Prinsip umum memuat pustaka dinamis dapat dilihat di sini . Modul harus mengekspor 2 fungsi: newAIModule dan gameInit .


gameInit mudah. Fungsi ini dipanggil untuk meneruskan pointer ke gim saat ini. Pointer ini sangat penting, karena ada variabel statis global di belantara BWAPI, yang digunakan di beberapa bagian kode. Mari kita gambarkan gameInit :


 DLLEXPORT void gameInit(void* game) { BWAPIC_setGame(game); } 

newAIModule sedikit lebih rumit. Seharusnya mengembalikan pointer ke kelas C ++, yang memiliki tabel metode virtual dengan nama-nama seperti onXXXXX yang dipanggil pada acara permainan tertentu. Mari kita mendeklarasikan struktur modul:


 typedef struct ExampleAIModule { const AIModule_vtable* vtable_; const char* name; } ExampleAIModule; 

Bidang pertama harus menjadi pointer ke tabel metode (ini semacam sihir). Berikut ini adalah fungsi baru newAIModule :


 DLLEXPORT void* newAIModule() { ExampleAIModule* const module = (ExampleAIModule*) malloc( sizeof(ExampleAIModule) ); module->name = "ExampleAIModule"; module->vtable_ = &module_vtable; return createAIModuleWrapper( (AIModule*) module ); } 

createAIModuleWrapper adalah trik sulap lain yang mengubah pointer C menjadi pointer ke kelas C ++ metode virtual fungsi anggota.


module_vtable adalah variabel statis pada tabel metode, nilai metode diisi dengan pointer ke fungsi global:


 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) {} 

Jika Anda melihat nama fungsi dan tanda tangannya, jelas dalam kondisi apa dan dengan argumen apa mereka perlu dipanggil. Sebagai contoh, saya membuat semua fungsi kosong, kecuali


 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); } 

Fungsi ini dipanggil saat game berjalan. Argumennya adalah pointer ke modul saat ini. BWAPIC_getGame mengembalikan pointer global ke game, yang kami atur menggunakan panggilan ke BWAPIC_setGame . Jadi, mari kita tunjukkan contoh kerja modul cross-compiling:


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

Tekan tombol dan jalankan game. Informasi lebih lanjut tentang kompilasi dan eksekusi dapat ditemukan di situs web BWAPI dan di BWAPI-C .


Hasil modul:


gambar


Anda dapat menemukan contoh modul yang sedikit lebih rumit yang menunjukkan cara bekerja dengan iterator, manajemen unit, pencarian mineral, dan output statistik di bwapi-c / example / Dll.c.


bwapi-sys


Dalam ekosistem Rust ada cara tertentu untuk menamai paket yang menautkan ke perpustakaan asli. Setiap paket foo-sys menjalankan dua fungsi penting:


  • tautan dengan libfoo perpustakaan asli;
  • menyediakan deklarasi ke fungsi-fungsi dari libfoo library. Tapi deklarasi saja! Abstraksi tingkat tinggi tidak disediakan dalam peti * -sys.

Agar paket * -sys dapat terhubung dengan sukses, Anda harus memberi tahu kargo untuk mencari perpustakaan asli dan / atau untuk membangun perpustakaan dari sumber.


Agar paket * -sys memberikan deklarasi, Anda harus menulisnya sendiri atau membuatnya menggunakan bindgen. Lagi bindgen. Percobaan nomor dua =)


Generasi binding sangat mudah:


 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 adalah file dengan semua header C dari BWAPI-C.


Sebagai contoh, bindgen telah menghasilkan deklarasi untuk fungsi-fungsi di atas:


 extern "C" { /// BWAPIC_setGame must be called from gameInit to initialize BWAPI::BroodwarPtr pub fn BWAPIC_setGame(game: *mut Game); } extern "C" { pub fn BWAPIC_getGame() -> *mut Game; } 

Ada dua strategi: menyimpan kode yang dihasilkan dalam repositori, dan membuat kode dengan cepat selama proses pembuatan. Kedua pendekatan memiliki kelebihan dan kekurangan .


Senang bertemu Anda bwapi-sys ; satu langkah kecil lagi ke tujuan kita.


Apakah Anda ingat bahwa sebelumnya saya berbicara tentang cross-platform? nlinker bergabung dengan proyek dan menerapkan strategi yang licik. Jika host target adalah Windows, unduh BWAPIC yang sudah dirakit dari GitHub. Dan untuk target yang tersisa, kami mengumpulkan BWAPI-C dari sumber-sumber untuk OpenBW (saya akan memberi tahu Anda tentangnya nanti).


bwapi-rs


Sekarang kita memiliki ikatan dan kita dapat mendefinisikan abstraksi tingkat tinggi. Kami memiliki dua jenis untuk dikerjakan: nilai murni dan pointer buram.


Semuanya sederhana dengan nilai-nilai murni. Mari kita ambil warna sebagai contoh. Kita perlu membuatnya mudah digunakan dari kode Rust untuk menggunakan warna dengan cara yang nyaman dan alami:


 game.draw_line(CoordinateType::Screen, (10, 20), (30, 40), Color::Red); ^^^ 

Jadi, untuk penggunaan yang mudah perlu mendefinisikan enumerasi dengan konstanta C ++ tetapi juga idiomatis untuk Rust, dan mendefinisikan metode untuk mengubahnya dalam bwapi_sys :: Color using std :: convert :: From:


 // FFI version #[repr(C)] #[derive(Copy, Clone)] pub struct Color { pub color: ::std::os::raw::c_int, } // Idiomatic version #[derive(PartialEq, PartialOrd, Copy, Clone)] pub enum Color { Black = 0, Brown = 19, ... 

Untuk kenyamanan Anda, Anda dapat menggunakan kotak enum-primitive- derive.


Juga mudah digunakan pointer buram. Mari kita gunakan pola Newtype :


 pub struct Player(*mut sys::Player); 

Ini berarti bahwa Player adalah sejenis struktur dengan bidang pribadi - sebuah pointer buram mentah dari C. Dan inilah cara Anda dapat mendefinisikan Player :: color:


 impl Player { // so the method is declared Player::getColor in bwapi-sys //extern "C" { // pub fn Player_getColor(self_: *mut Player) -> Color; //} pub fn color(&self) -> Color { // bwapi_sys::Player_getColor - wrapper function from BWAPI-C // self.0 - opaque pointer let color = unsafe { bwapi_sys::Player_getColor(self.0) }; color.into() // cast bwapi_sys::Color -> Color } } 

Sekarang kita bisa menulis bot pertama kita di Rust!


Membuat bot di Rust


Sebagai bukti konsep, bot akan serupa dengan countru yang terkenal: seluruh tugasnya adalah untuk merekrut pekerja dan mengumpulkan mineral.


Korea utara


Korea selatan


Mari kita mulai dengan fungsi yang diperlukan gameInit dan 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] melakukan fungsi yang sama dengan extern "C" di C ++. Di dalam wrap_handler semua keajaiban terjadi, dengan substitusi dari tabel fungsi virtual dan "masking" kelas C ++.


Definisi struktur modul lebih sederhana dan lebih menarik daripada di C:


 struct ExampleAIModule { name: String, } 

Mari kita tambahkan beberapa metode untuk menyajikan statistik dan memberikan pesanan:


 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)) { // WE REQUIRE MORE MINERALS unit.right_click(&mineral, false); } } UnitType::Terran_Command_Center => { unit.train(UnitType::Terran_SCV); } UnitType::Protoss_Nexus => { unit.train(UnitType::Protoss_Probe); } UnitType::Zerg_Hatchery | UnitType::Zerg_Lair | UnitType::Zerg_Hive => { unit.train(UnitType::Zerg_Drone); } _ => {} }; } } } 

Untuk mengubah tipe ExampleAIModule menjadi modul nyata, Anda harus membuatnya merespons peristiwa onXXXX . Untuk melakukannya, Anda perlu mengimplementasikan tipe EventHandler, yang merupakan analog dari tabel virtual AIModule_vtable dari C:


 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) {} } 

Membangun dan menjalankan modul sesederhana untuk 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 ... ... ... 

Dan video hasil kerjanya:



Openbw


Orang-orang ini bahkan melangkah lebih jauh. Mereka memutuskan untuk menulis versi open-source SC: BW! Dan mereka bagus dalam hal itu. Salah satu tujuan mereka adalah untuk mengimplementasikan gambar HD, tetapi SC: Remastered ada di depan mereka = (Pada saat ini, Anda dapat menggunakan API mereka untuk menulis bot (ya, juga di C ++), tetapi fitur yang paling menakjubkan adalah kemampuan untuk melihat replay langsung di browser Anda .


Kesimpulan


Ada masalah yang belum terpecahkan dengan implementasi: kami tidak mengontrol referensi menjadi unik, sehingga keberadaan &mut dan & ke wilayah yang sama akan menghasilkan perilaku yang tidak terdefinisi saat objek diubah. Agak masalah. Halt berusaha menerapkan ikatan idiomatik, tetapi ia tidak berhasil menemukan solusi. Juga jika Anda ingin menyelesaikan tugas ini, Anda harus hati-hati "menyekop" API C ++ dan menempatkan kualifikasi const dengan benar.


Saya benar-benar menikmati mengerjakan proyek ini, saya menonton tayangan ulang ν•˜λ£¨ deeply dan tenggelam dalam suasana. Game ini membuat penyok di alam semesta. Tidak ada permainan yang dapat 비ꡐ할 수 μ—†λ‹€ berdasarkan popularitas dengan SC: BW, dan dampaknya pada λŒ€ν•œλ―Όκ΅­ μ •μΉ˜ μ—κ²Œ tidak terpikirkan. Pro-gamer di Korea μ•„λ§ˆλ„ sama populernya dengan λ“œλΌλ§ˆ μ£Όμ—° 배우 dor siaran dorams Korea di jam tayang utama. λ˜ν•œ, ν•œκ΅­ μ—μ„œ ν”„λ‘œ 게이머 라면 κ΅°λŒ€ 의 νŠΉλ³„ν•œ 윑ꡰ 에 μž…λŒ€ μž…λŒ€ ν•  수 수.


StarCraft live yang panjang!






Terima kasih banyak kepada Steve Klabnik karena membantu saya meninjau artikel ini.

Source: https://habr.com/ru/post/id436254/


All Articles