Pliki o rozszerzeniu WWD (Wap World Document) służą do przechowywania poziomów w grach opartych na silniku WAP32. Do edycji tych plików służy edytor poziomów: Gruntz Level Editor (GLE) w przypadku gry Gruntz lub Wap World dla gry Claw. Edytory pod względem implementacyjnym praktycznie w ogóle się nie różnią, ale jako że GLE dodaje parę nowych elementów względem swojego poprzednika, artykuł będzie poświęcony właśnie plikom WWD produkowanym przez edytor GLE, które są funkcjonalnym nadzbiorem poziomów WWD do gry Claw.

Poniżej znajduje się formalna specyfikacja formatu plików WWD z uwzględnieniem delikatnych różnic pomiędzy grami Gruntz oraz Claw.

Na początek garść podstawowych informacji, jaka jest struktura poziomów WWD. Dłuższy czas się zastanawiałem nad tym, czy tłumaczyć te terminy na język polski, ale w końcu stwierdziłem, że nie będę pisał potworków w stylu: “na plane’ie znajdują się tiles’y”. Być może zbiorę od wielu cięgi za te tłumaczenia, no ale trudno, biorę to na klatę 😛

  • plane – w wolnym tłumaczeniu “płaszczyzna”. Każdy poziom jest złożony z co najmniej jednej płaszczyzny oznaczonej jako główna, na której dzieje się akcja gry. Pozostałe płaszczyzny są opcjonalne i są używane w celach dekoracyjnych.
  • tiles – spotkałem się z tłumaczeniem “klocki”, ale chyba bardziej wolę termin “kafelki”. Złożona jest z nich każda płaszczyzna.
  • tile properties – każdy kafelek na głównej płaszczyźnie może mieć różną funkcjonalność i w różny sposób reagować z otoczeniem. Informacje te są opisane we właściwości kafelków.

1. Typy danych

W specyfikacji znajdują się odniesienia do następujących typów danych:

  • null-terminated string oraz fixed-length string – typowe ciągi znaków. Pierwszy jest zakończony znakiem NULL i jego wystąpienie jest interpretowane jako koniec ciągu znaków, a drugi ma zawsze ustaloną wcześniej długość w bajtach.
  • null-terminated, fixed-length string – swoiste połączenie null-terminated string oraz fixed-length string, które moim zdaniem wynika z błędu w implementacji edytora. Wiele ciągów znaków w edytorze ma zarezerwowaną stałą ilość bajtów w pliku WWD, np. na pole tekstowe “Author” są zarezerwowane 64 znaki. Nie są to jednak typowe fixed-length strings z tego względu, że jeżeli w całym tym obszarze nie będzie występował znak NULL, edytor zinterpretuje również kolejne bajty jako część napisu. Tak więc w praktyce pole “Author” w edytorze może zawierać co najwyżej 63 znaki, a ostatnim zawsze powinien być NULL, by tekst został poprawnie przez edytor zinterpretowany.
  • uint32_t oraz int32_t – czterobajtowy typ całkowitoliczbowy bez znaku oraz ze znakiem. Wszystkie liczby są w postaci little endian, tj. najmniej znaczące cyfry znajdują się w pierwszym bajcie liczby. Uwaga: to, czy dana liczba jest ze znakiem czy też nie, zostało ustalone tylko i wyłącznie na podstawie tego, jak edytor interpretuje i wyświetla te wartości. Dlatego jest to czysto umowna informacja i gra może je interpretować zupełnie inaczej.

W paru miejscach specyfikacji występuje również typ danych rect, który jest zdefiniowany w następujący sposób:

struct rect
{
	int32_t left;
	int32_t top;
	int32_t right;
	int32_t bottom;
};

2. Nagłówek WWD oraz główny blok danych

Pierwszy blok danych to nagłówek pliku WWD, który ma zawsze 1524 bajty. Pozostała część pliku została przeze mnie nazwana “main block“, czyli główny blok danych. Jego postać jest zależna od tego, czy ustawiona jest flaga Compress w ustawieniach World Properties edytora:

  • jeżeli flaga nie jest ustawiona, pozostała część pliku jest w normalnej postaci i nie wymaga żadnego wstępnego przetwarzania.
  • jeżeli flaga jest ustawiona, cały główny blok danych jest skompresowany algorytmem deflate. Żeby plik WWD odczytać, należy cały główny blok danych zdekompresować, a następnie cały plik traktować, jakby był w nieskompresowanej postaci.

Wszystkie wartości offsetów w całej specyfikacji mają wartości bezwzględne i są liczone od początku pliku przy założeniu braku kompresji.

Najważniejsze elementy nagłówka WWD:

  • offset do początku definicji płaszczyzn (planes)
  • offset do sekcji z właściwościami kafelków (tile properties)
  • wielkość zdekompresowanego głównego bloku danych (tylko jeżeli flaga Compress jest ustawiona, w przeciwnym razie jest tam wartość 0)
  • suma kontrolna (checksum), która jest liczona na podstawie całego głównego bloku danych. Jeżeli jest ona błędnie wyznaczona, zarówno edytor, jak i gra odrzucają taki poziom i nie chcą go wczytać.

Kolejne sekcje w pliku (tj. nagłówki płaszczyzn, kafelki, obiekty itd.) mogą być już w dowolnej kolejności, jako że wiążące są jedynie offsety wskazujące na ich początek. W specyfikacji jednak sekcje zostały przedstawione w takiej kolejności, w jakiej edytor GLE zapisuje je do pliku.

struct wwd_header
{
	// 0x0 (0) - (?) WWD signature (and/or header size)
	uint32_t signature; // 0x000005F4

	// 0x4 (4)
	uint32_t unknown1; // 0x00000000

	// 0x8 (8) - flags
	// 0x1 - use z coords
	// 0x2 - compress
	uint32_t flags;

	// 0xC (12)
	uint32_t unknown2; // 0x00000000

	// 0x10 (16) - name
	// null terminated, fixed length string
	char name[64];

	// 0x50 (80) - author
	// null terminated, fixed length string
	char author[64];

	// 0x90 (144) - birth
	// null terminated, fixed length string
	char birth[64];

	// 0xD0 (208) - rez file path
	// null terminated, fixed length string
	char rez_file[256];

	// 0x1D0 (464) - image dir
	// null terminated, fixed length string
	char image_dir[128];

	// 0x250 (592) - pal rez
	// null terminated, fixed length string
	char pal_rez[128];

	// 0x2D0 (720) - start x
	int32_t start_x;

	// 0x2D4 (724) - start y
	int32_t start_y;

	// 0x2D8 (728)
	uint32_t unknown3; // 0x00000000

	// 0x2DC (732) - number of planes
	uint32_t num_planes;

	// 0x2E0 (736) - offset to the definition of planes
	uint32_t offset_planes;

	// 0x2E4 (740) - offset to the properties of tiles
	uint32_t offset_tile_properties;

	// 0x2E8 (744) - size of the decompressed main block
	// 0 for decompressed files
	uint32_t decompressed_mainblock_size;

	// 0x2EC (748) - checksum
	uint32_t checksum;

	// 0x2F0 (752)
	uint32_t unknown4; // 0x00000000

	// 0x2F4 (756) - launch app
	// null terminated, fixed length string
	char launch_app[128];

	// 0x374 (884) - image set 1
	// null terminated, fixed length string
	char image_set1[128];

	// 0x3F4 (1012) - image set 2
	// null terminated, fixed length string
	char image_set2[128];

	// 0x474 (1140) - image set 3
	// null terminated, fixed length string
	char image_set3[128];

	// 0x4F4 (1268) - image set 4
	// null terminated, fixed length string
	char image_set4[128];

	// 0x574 (1396) - prefix 1
	// null terminated, fixed length string
	char prefix1[32];

	// 0x594 (1428) - prefix 2
	// null terminated, fixed length string
	char prefix2[32];

	// 0x5B4 (1460) - prefix 3
	// null terminated, fixed length string
	char prefix3[32];

	// 0x5D4 (1492) - prefix 4
	// null terminated, fixed length string
	char prefix4[32];
}

Algorytm wyznaczania sumy kontrolnej jest dosyć … specyficzny, mówiąc oględnie. Dokładna postać algorytmu wygląda bowiem w następujący sposób (pseudokod a’la C++):

// the main block data in its final form as it is supposed to be saved to the file
// either compressed (when the flag Compress is set) or not
unsigned char* mainBlockData;
// the size of the mainBlockData buffer
unsigned int mainBlockSize;

// the calculated checksum
unsigned int checksum = -mainBlockSize;

for(unsigned int offset = 1; offset < mainBlockSize; ++offset)
	checksum += mainBlockData[offset] - offset;

// if the main block data is compressed
if( isCompressed )
{
	// decompressed main block data
	unsigned char* decompressedMainBlockData = ... ;
	checksum += decompressedMainBlockData[mainBlockSize];
}

Podsumowując algorytm wyznaczania sumy kontrolnej: dla każdego bajtu głównego bloku danych (w dokładnie takiej postaci, w jakiej ma być zapisany do pliku) do sumy kontrolnej dodawana jest wartość tego bajtu, po czym odejmowany jest jego offset względem początku głównego bloku danych. Problem jest w tym, że … no właśnie, nie dla każdego bajtu. Wyznaczanie sumy kontrolnej zaczyna się bowiem dopiero od drugiego bajtu bloku, pierwszy bajt jest w całości ignorowany. Dodatkowo, jeżeli zastosowana została kompresja, to do sumy kontrolnej należy wliczyć również jeden kolejny bajt ze zdekompresowanego głównego bloku danych. Wyjaśnienie, dlaczego ten algorytm jest tak pokrętny, zarezerwuję na osobny artykuł, choć wnikliwy programista już może podejrzewać, w czym rzecz. Powiem jedynie, że moim zdaniem, algorytm ten jest niestety wynikiem błędu twórców edytora GLE. Tak – kolejnego błędu. O tych błędach to naprawdę można cały cykl artykułów napisać …

W ramach swoich testów odkryłem, że wyliczona suma kontrolna nie musi być idealnie taka sama co do wartości, żeby edytor oraz gra ją zaakceptowały. Dokładnych warunków akceptacji sumy kontrolnej jeszcze nie znam, ale jak je poznam to zaktualizuję ten artykuł. Dodam tylko, że można ominąć ostatnią część algorytmu, w której dodawany jest pojedynczy bajt ze zdekompresowanego głównego bloku danych i żaden z programów nie powinien sprawiać kłopotów ze wczytywaniem poziomu. Mimo to z kronikarskiego obowiązku byłem zmuszony opublikować kompletny algorytm 🙂

3. Definicje płaszczyzn

wwd_header.offset_planes

Na samym początku głównego bloku danych edytor GLE zapisuje sekwencję nagłówków wszystkich płaszczyzn. Ich liczba jest zapisana w nagłówku pliku WWD (wwd_header.num_planes). Każdy pojedynczy nagłówek ma długość 160 bajtów.

Najważniejsze elementy nagłówka płaszczyzny:

  • offset do listy kafelków, z których składa się płaszczyzna
  • offset do listy image sets płaszczyzny
  • offset do listy obiektów (GLE zapisuje obiekty tylko i wyłącznie w przypadku głównej płaszczyzny poziomu, w przeciwnym wypadku offset jest równy 0)
struct plane_header
{
	// 0x0 (0) - (?) block size
	uint32_t block_size; // 0x000000A0

	// 0x4 (4)
	uint32_t unknown1; // 0x00000000

	// 0x8 (8) - flags
	// 0x01 - main plane
	// 0x02 - no draw
	// 0x04 - x wrapping
	// 0x08 - y wrapping
	// 0x10 - auto tile size
	uint32_t flags;

	// 0xC (12)
	uint32_t unknown2; // 0x00000000

	// 0x10 (16) - name
	// null terminated, fixed length string
	char name[64];

	// 0x50 (80) - width of the plane in px (tiles wide * tiles width in px)
	int32_t width_px;

	// 0x54 (84) - height of the plane in px (tiles high * tiles height in px)
	int32_t height_px;

	// 0x58 (88) - tiles width in px
	int32_t tiles_width;

	// 0x5C (92) - tiles height in px
	int32_t tiles_height;

	// 0x60 (96) - dimensions - tiles wide
	int32_t tiles_wide;

	// 0x64 (100) - dimensions - tiles high
	int32_t tiles_high;

	// 0x68 (104)
	uint32_t unknown3; // 0x00000000

	// 0x6C (108)
	uint32_t unknown4; // 0x00000000

	// 0x70 (112) - movement - x percent
	int32_t movement_x_percent;

	// 0x74 (116) - movement - y percent
	int32_t movement_y_percent;

	// 0x78 (120) - fill color
	int32_t fill_color;

	// 0x7C (124) - number of image sets
	uint32_t num_image_sets;

	// 0x80 (128) - number of objects
	uint32_t num_objects;

	// 0x84 (132) - offset to the tiles
	uint32_t offset_tiles;

	// 0x88 (136) - offset to the names of image sets
	uint32_t offset_image_sets;

	// 0x8C (140) - offset to objects
	uint32_t offset_objects;

	// 0x90 (144) - z coord
	int32_t z_coord;

	// 0x94 (148)
	uint32_t unknown5; // 0x00000000

	// 0x98 (152)
	uint32_t unknown6; // 0x00000000

	// 0x9C (156)
	uint32_t unknown7; // 0x00000000
}

4. Lista kafelków

plane_header.offset_tiles

W tej sekcji znajduje się lista kafelków danej płaszczyzny. Każdy pojedynczy kafelek jest reprezentowany przez cztery bajty, zawierające numer ID danego kafelka (dane typu uint32_t). Liczba kafelków to: plane_header.tiles_wide * plane_header.tiles_high.

Kafelki są indeksowane od lewego górnego rogu płaszczyzny do prawego dolnego, wiersz po wierszu.

Kafelek zamiast numeru ID może przyjmować również jedną z dwóch predefiniowanych wartości:

  • 0xFFFFFFFF – kafelek jest typu Invisible.
  • 0xEEEEEEEE – kafelek jest typu Filled.

5. Lista image sets

plane_header.offset_image_sets

W tej sekcji znajduje się lista image sets, z których brane są kafelki. Jest to lista następujących po sobie null-terminated strings. Ich liczba to: plane_header.num_image_sets.

Edytor GLE z jakichś powodów ignoruje wszystkie image sets zdefiniowane w tej sekcji, za wyjątkiem pierwszego na liście, który jest wiążący i z którego czerpana jest lista wszystkich dostępnych kafelków.

6. Lista obiektów

plane_header.offset_objects

W tej sekcji znajduje się lista wszystkich obiektów na płaszczyźnie. Ich definicje występują jedna po drugiej. Liczba obiektów w tej sekcji to: plane_header.num_objects.

Na szczególną uwagę zasługują tutaj pola object_type oraz flags_hit_type, które bazują na flagach. Otóż flagi user1 oraz user2 nie powinny być używane z tego względu, że edytory GLE oraz WapWorld niestety źle je zaimplementowały. Wystarczy tylko spojrzeć na wartości masek im odpowiadających – flaga user3 mianowicie nadpisuje wartości flag user1 oraz user2, z którymi dzieli te same bity. O ile w przypadku pola object_type ten problem nie jest istotny, gdyż nie bazuje na flagach ale na bezpośrednich wartościach masek, o tyle z polem flags_hit_type mogą już być problemy. Jeżeli w tym polu użyjemy flag user1 oraz user2 do własnych celów, w wyniku działania edytora informacje w nich zawarte możemy bezpowrotnie utracić. Szczegóły dotyczące tego problemu planuję umieścić w osobnym artykule.

struct object
{
	// 0x0 (0) - id
	int32_t id;

	// 0x4 (4) - number of characters in the name string
	uint32_t size_name;

	// 0x8 (8) - number of characters in the logic string
	uint32_t size_logic;

	// 0xC (12) - number of characters in the image set string
	uint32_t size_image_set;

	// 0x10 (16) - number of characters in the animation string
	uint32_t size_animation;

	// 0x14 (20) - location x
	int32_t location_x;

	// 0x18 (24) - location y
	int32_t location_y;

	// 0x1C (28) - location z
	int32_t location_z;

	// 0x20 (32) - location_i
	int32_t location_i;

	// 0x24 (36) - flags - add flags
	// 0x01 - difficult
	// 0x02 - eye candy
	// 0x04 - high detail
	// 0x08 - multiplayer
	// 0x10 - extra memory
	// 0x20 - fast cpu
	uint32_t flags_add;

	// 0x28 (40) - flags - dynamic flags
	// 0x1 - no hit
	// 0x2 - always active
	// 0x4 - safe
	// 0x8 - auto hit damage
	uint32_t flags_dynamic;

	// 0x2C (44) - flags - draw flags
	// 0x1 - no draw
	// 0x2 - mirror
	// 0x4 - invert
	// 0x8 - flash
	uint32_t flags_draw;

	// 0x30 (48) - flags - user flags
	// 0x001 - flag 1
	// 0x002 - flag 2
	// 0x004 - flag 3
	// 0x008 - flag 4
	// 0x010 - flag 5
	// 0x020 - flag 6
	// 0x040 - flag 7
	// 0x080 - flag 8
	// 0x100 - flag 9
	// 0x200 - flag 10
	// 0x400 - flag 11
	// 0x800 - flag 12
	uint32_t flags_user;

	// 0x34 (52) - score
	int32_t score;

	// 0x38 (56) - points
	int32_t points;

	// 0x3C (60) - powerup
	int32_t powerup;

	// 0x40 (64) - damage
	int32_t damage;

	// 0x44 (68) - smarts
	int32_t smarts;

	// 0x48 (72) - health
	int32_t health;

	// 0x4C (76) - rect move
	rect rect_move;

	// 0x5C (92) - rect hit
	rect rect_hit;

	// 0x6C (108) - rect attack
	rect rect_attack;

	// 0x7C (124) - rect clip
	rect rect_clip;

	// 0x8C (140) - rect user1
	rect rect_user1;

	// 0x9C (156) - rect user2
	rect rect_user2;

	// 0xAC (172) - user1
	int32_t user1;

	// 0xB0 (176) - user2
	int32_t user2;

	// 0xB4 (180) - user3
	int32_t user3;

	// 0xB8 (184) - user4
	int32_t user4;

	// 0xBC (188) - user5
	int32_t user5;

	// 0xC0 (192) - user6
	int32_t user6;

	// 0xC4 (196) - user7
	int32_t user7;

	// 0xC8 (200) - user8
	int32_t user8;

	// 0xCC (204) - min x
	int32_t min_x;

	// 0xD0 (208) - min y
	int32_t min_y;

	// 0xD4 (212) - max x
	int32_t max_x;

	// 0xD8 (216) - max y
	int32_t max_y;

	// 0xDC (220) - speed x
	int32_t speed_x;

	// 0xE0 (224) - speed y
	int32_t speed_y;

	// 0xE4 (228) - tweak x
	int32_t tweak_x;

	// 0xE8 (232) - tweak y
	int32_t tweak_y;

	// 0xEC (236) - counter
	int32_t counter;

	// 0xF0 (240) - speed
	int32_t speed;

	// 0xF4 (244) - width
	int32_t width;

	// 0xF8 (248) - height
	int32_t height;

	// 0xFC (252) - direction
	int32_t direction;

	// 0x100 (256) - face dir
	int32_t face_dir;

	// 0x104 (260) - time delay
	int32_t time_delay;

	// 0x108 (264) - frame delay
	int32_t frame_delay;

	// 0x10C (268) - hits - object type (single value)
	// 0x001 - generic
	// 0x002 - player
	// 0x004 - enemy
	// 0x008 - powerup
	// 0x010 - shot
	// 0x020 - p-shot
	// 0x040 - e-shot
	// 0x080 - special
	// 0x100 - user1 (usage not recommended)
	// 0x200 - user2 (usage not recommended)
	// 0x300 - user3 (yes - the value is not a mistake)
	// 0x400 - user4 (yes - the value is not a mistake)
	uint32_t object_type;

	// 0x110 (272) - hits - hit type flags
	// 0x001 - generic
	// 0x002 - player
	// 0x004 - enemy
	// 0x008 - powerup
	// 0x010 - shot
	// 0x020 - p-shot
	// 0x040 - e-shot
	// 0x080 - special
	// 0x100 - user1 (should be deprecated)
	// 0x200 - user2 (should be deprecated)
	// 0x300 - user3 (yes - the value is not a mistake)
	// 0x400 - user4 (yes - the value is not a mistake)
	// 0xFFFFFFFF - all (separate, special value overriding all other flags)
	uint32_t flags_hit_type;

	// 0x114 (276) - move res x
	uint32_t move_res_x;

	// 0x118 (280) - move res y
	uint32_t move_res_y;

	// 0x11C (284) - name string
	// fixed length, object.size_name characters
	char* name;

	// (variable) - logic string
	// fixed length, object.size_logic characters
	char* logic;

	// (variable) - image set string
	// fixed length, object.size_image_set characters
	char* image_set;

	// (variable) - animation string
	// fixed length, object.size_animation characters
	char* animation;
}

7. Właściwości kafelków

wwd_header.offset_tile_properties

Pierwsze 32 bajty to nagłówek całej sekcji:

struct tile_properties_header
{
	// 0x0 (0)
	uint32_t unknown1; // 0x00000020

	// 0x4 (4)
	uint32_t unknown2; // 0x00000000

	// 0x8 (8) - the number of the tile properties
	uint32_t num_tile_properties;

	// 0xC (12)
	uint32_t unknown3; // 0x00000000

	// 0x10 (16)
	uint32_t unknown4; // 0x00000000

	// 0x14 (20)
	uint32_t unknown5; // 0x00000000

	// 0x18 (24)
	uint32_t unknown6; // 0x00000000

	// 0x1C (28)
	uint32_t unknown7; // 0x00000000
}

Po tym nagłówku znajdują się właściwości wszystkich dostępnych kafelków w edytorze. Definicje są jedna za drugą, a ich liczba jest podana w polu tile_properties_header.num_tile_properties. To, którego kafelka dotyczy dana definicja, zależy od jego położenia na liście. Definicje są indeksowane od zera, tak więc pierwsza definicja dotyczy kafelka o ID 0, druga definicja dotyczy kafelka o ID 1 itd. Może to być sporym zaskoczeniem dla wielu, gdyż zarówno w grze Gruntz, jak i Claw (z tego co się orientuję) kafelki są numerowane raczej od 1. Również należy pamiętać o tym, że w związku z tym, że lista definicji jest ciągła, to mogą występować definicje kafelków, które w ogóle nie istnieją. Nie należy się tym jednak przejmować, gdyż definicje te po prostu nie są przez edytor w takim wypadku używane i są zwykłym wypełniaczem.

Każda definicja kafelka zaczyna się od następujących 16 bajtów:

struct tile_property_base
{
	// 0x0 (0) - tile type
	// 0x1 - single
	// 0x2 - double
	// 0x3 - mask
	uint32_t tile_type;

	// 0x4 (4)
	uint32_t unknown1; // 0x00000000

	// 0x8 (8) - tile width
	uint32_t width;

	// 0xC (12) - tile height
	uint32_t height;
}

Dalsza część danych zależna jest już od pola tile_property_base.tile_type, które może przyjąć jedną z trzech wartości: 1 (single), 2 (double) lub 3 (mask).

  • single (1) – podstawowy typ kafelka, który ma w całości przypisany pojedynczy atrybut. Gra Gruntz wykorzystuje tylko kafelki tego typu.
  • double (2) – kafelek jest podzielony na dwie części: na prostokątny obszar oraz na obszar znajdujący się poza nim. Każdy z tych dwóch obszarów może mieć przypisany oddzielny atrybut. Ten typ kafelka jest wykorzystywany tylko w grze Claw.
  • mask (3) – każdy piksel kafelka może mieć osobny atrybut. Są one zapisywane w postaci tzw. maski. Wartość piksela maski to wartość atrybutu dla odpowiadającego mu piksela kafelka. Ten typ kafelka nie jest wykorzystywany przez żadną z gier. Edytor GLE posiada szczątkową obsługę kafelków tego typu, która niestety nie działa zbyt dobrze i może doprowadzić nawet do niezapowiedzianego crashu edytora. Używanie tego typu kafelka nie jest więc wskazane.

Należy się jeszcze wyjaśnienie czym jest atrybut kafelka. Atrybut jest to liczbowa wartość reprezentująca sposób interakcji tego kafelka (lub jego konkretnej części) z otoczeniem. Edytory WapWorld oraz GLE udostępniają pięć podstawowych typów atrybutów: Clear, Solid, Ground, Climb oraz Death. Gra Gruntz oraz edytor GLE znacznie rozszerzają tę listę aż do prawie 60 wartości. Każda wartość atrybutu, która nie jest jedną z pięciu podstawowych wypisanych wcześniej, jest zbiorczo nazwana User Attribute.

Poniżej znajdują się dalsze definicje każdego z powyższych typów kafelków oraz wartości podstawowych rodzajów atrybutów:

// Standard attribute values in WapWorld and GLE:
// 0x00 (0) - clear
// 0x01 (1) - solid
// 0x02 (2) - ground
// 0x03 (3) - climb
// 0x04 (4) - death

struct tile_property_single
{
	tile_property_base base;

	// 0x10 (16) - tile attribute
	int32_t attribute;
}

struct tile_property_double
{
	tile_property_base base;

	// 0x10 (16) - outside tile attribute
	int32_t attribute_outside;

	// 0x14 (20) - inside tile attribute
	int32_t attribute_inside;

	// 0x18 (24) - tile rect
	rect rect;
}

struct tile_property_mask
{
	tile_property_base base;

	// 0x10 (16) - mask data
	// tile_property_base.width * tile_property_base.height bytes
	char* mask;
}