diff --git a/doc/convert/convert_single_file.md b/doc/convert/convert_single_file.md index 2879a7415e..8dc88d4fea 100644 --- a/doc/convert/convert_single_file.md +++ b/doc/convert/convert_single_file.md @@ -6,15 +6,25 @@ png image. The invocation could be: +SLPs in DRS archives (for older versions of Age of Empires 1, Age of Empires 2 and SWGB): ``` -python3 -m openage convert-file ~/games/aoe2/aoe2/Data/graphics.drs 326.slp /tmp/rofl.png +python3 -m openage convert-file --drs ~/games/aoe2/Data/graphics.drs 326.slp /tmp/rofl.png +``` + +Standalone SLPs (Age of Empires 1: DE and Age of Empires 2: HD): +``` +python3 -m openage convert-file --palette-file ~/games/aoe2hd/Data/50500.bina 326.slp /tmp/rofl.png +``` + +Standalone SMPs (Age of Empires 2: DE): +``` +python3 -m openage convert-file --palette-file ~/games/aoe2de/Data/01_units.pal u_elite_eagle.smp /tmp/rofl.png ``` Have a look at `openage/convert/singlefile.py`, this is also a simple API demo for how to interact with the aoe files. - ### Interactive Shell You can also [browse the archives and data interactively](interactive.md). diff --git a/doc/media/slp-files.md b/doc/media/slp-files.md index aed152f7a0..13bc7d994a 100644 --- a/doc/media/slp-files.md +++ b/doc/media/slp-files.md @@ -5,7 +5,7 @@ textures. Like the DRS format, it can also be read sequentially. ## SLP file format -#### Header +### Header (up to version 3.0) The SLP file starts with a header: Length | Type | Description | Example @@ -24,17 +24,53 @@ struct slp_header { ``` Python format: `Struct("< 4s i 24s")` -#### SLP Frame info -After the header, there are `num_frames` entries of `slp_frame_info`: +### Header (since version 4.0X) +Age of Empires 1: Definitive Edition SLPs (v4.0) use a differently structured header: + +Length | Type | Description | Example +---------|---------|-------------------------|-------- +4 bytes | string | Version | 4.0X +2 bytes | int16 | Number of frames | 960, 0x000003C0 +2 bytes | int16 | possibly angles | 8, 0x08 (only for effects and v4.1 SLPs) +2 bytes | int16 | unknown | always 0x0001 +2 bytes | int16 | Number of frames | value never differs from previous value +4 bytes | int32 | possibly checksum field | always 0x00000000 (v4.1 uses it) +4 bytes | int32 | Offset for main graphic | 0x00000020 +4 bytes | int32 | Offset for shadow | 0x0010C8C0, if 0x00000000 then there is no shadow stored +8 bytes | Padding | Padding | - + +v4.1 SLPs have the same structure as the v4.0 SLPs. However, they are only used for *decay* SLPs. + +```cpp +struct slp_header_v4 { + char version[4]; + int16 num_frames; + int16 angles; + int16 unknown; + int16 num_frames_alt; + int32 checksum; + int32 offset_main; + int32 offset_shadow; + pad byte padding[8]; +}; +``` +Python format: `Struct("< 4s H H H H i i i 8x")` + +### SLP Frame info +After the header, there are `num_frames` entries of `slp_frame_info`. Every `slp_frame_info` stores meta-information about a single frame (texture) within the SLP. +SLPs with version higher than 4.0 have `num_frames` additional entries of +`slp_frame_info` at `offset_shadow` if the offset is a non-zero value. +These frames store the shadows of the game entity. + Length | Type | Description | Example ---------|--------|----------------------------|-------- 4 bytes | uint32 | Command table offset | 2464, 0x000009A0 4 bytes | uint32 | Outline table offset | 64, 0x00000040 4 bytes | uint32 | Palette offset (unused) | 0, 0x00000000 -4 bytes | uint32 | Properties (likely unused) | 16, 0x00000010 +4 bytes | uint32 | Properties | 16, 0x00000010 4 bytes | int32 | Width of image | 800, 0x00000320 4 bytes | int32 | Height of image | 600, 0x00000258 4 bytes | int32 | Centre of sprite (X coord) | 0, 0x00000000 @@ -56,14 +92,17 @@ Python format: `Struct("< I I I I i i i i")` * `palette_offset` is apparently never used. * `properties` is only used to indicate which palette for the image to use. - However, these seem to be the same, so can generally be ignored. - * 0x10 - "default game palette" - * 0x00 - "global color palette" + Up to HD, these seem to be the same, so can generally be ignored. + * 0x10 - "default game palette" + * 0x00 - "global color palette" +* In AoE1:DE `properties` stores the palette number in the second last 2 bytes + * 0x0009 - use `09_buildings_asian.pal` * `hotspot_x` & `hotspot_y` tell the engine where the centre of the unit is. -One image of size `width`*`height` has `height` rows, of course. +One image of size `width * height` has `height` rows, of course. + -#### SLP Frame row edge +### SLP Frame row edge At `outline_table_offset` (after the `slp_frame_info` structs), an array of `slp_frame_row_edge` (of length `height`) structs begins. @@ -92,6 +131,9 @@ Note that there are no command bytes for these rows, so will have to be skipped `width - left_space - right_space` = number of pixels in this line. + +### SLP command table + After those padding frames, at `slp_frame_info.cmd_table_offset`, an array of uint32 (of length `height`) begins: @@ -116,61 +158,67 @@ The actual command data starts after the end of the command offsets, or: ### SLP drawing commands The image is drawn line by line, a line is finished with the "End of line" -command (0x0F), where a command is a one-byte number (stored in the -least-significant bits of the byte, e.g. `command = data & 0x0F`), followed by -command-specific data with a length varying depending on the command. The next -command immediately follows the previous command's data. +command (0x0F). A command is a one-byte number (`cmd_byte`), followed +by command-specific data with a length (number of pixels) varying +depending on the command. The next command immediately follows the +previous command's data. + +Commands can also store meta information like the number of following pixels +in the same byte. More complex commands store the pixel count in the following +byte. Each command triggers a drawing method for n = "Count" pixels. All the commands tell you to draw a palette_index, for n pixels. -The *Pixel count* value is stored at these locations: (where `cmd_byte` is the -value of the command byte, and `next_byte` is the value of the next byte) +Commands can be identified by examining the 4 least significant bits of `cmd_byte`. + +For examples of drawing commands, see the [Examples](#examples) section. -Value name | Pixel count +### Full drawing command list + +Value name | Description ---------------|------------ -next | `next_byte` -`>> 2` | `cmd_byte >> 2` (i.e., most significant bits used as data value) +next | The byte following `cmd_byte` +`>> 2` | `cmd_byte >> 2` (i.e., the 6 most significant bits used as data value) `>> n` or next | `pixel_count = cmd_byte >> n; if pixel_count == 0: pixel_count = next_byte`. i.e., the `8 - n` most significant bits of `cmd_byte` if they are `!= 0`, else the next byte. `<< 4 + next` | `((cmd_byte & 0xf0) << 4) + next_byte` -Commands where *Count* is `>> i` only reuse the most significant bits of `cmd_byte`. - -Command Name | Byte value | Count | Description --------------------------|------------|----------------|------------ -Lesser block copy | `0x00` | `>> 2` | An array of length *Count* filled with 1-byte palette indices follows, 1 index per pixel. -Lesser skip | `0x01` | `>> 2` or next | *Count* transparent pixels should be drawn from the current position. -Greater block copy | `0x02` | `<< 4 + next` | An array of length *Count* filled with 1-byte palette indices follows, 1 index per pixel. -Greater skip | `0x03` | `<< 4 + next` | *Count* transparent pixels should be drawn from the current position. -Player color block copy | `0x06` | `>> 4` or next | An array of length "Count" filled with 1-byte player color palette indices follow, 1 index per pixel. The real palette index is `player_color_palette_index + player * 16`, where `player` is the player ID you're drawing for (1-8). -Fill | `0x07` | `>> 4` or next | One palette index byte follows. This color should be drawn `pixel_count` times from the current position. -Fill player color | `0x0A` | `>> 4` or next | One player palette index byte follows. This color should be drawn `pixel_count` times. See `player_color_list (0x06)` -Shadow | `0x0B` | `>> 4` or next | Draw *Count* shadow pixels (The pixels to draw when a unit is behind another object). -Extended command | `0x0E` | depends | Get the specific extended command by looking at the most significant bits of the command. (See the table below for details) -End of line | `0x0F` | 0 | End of commands for this row. If more commands follow, they are for the next row. +An `X` signifies that the bit can have any value. These bits are often used for +storing the length (pixel count) of the command. + +Command Name | Byte value | Pixel Count | Description +-------------------------|------------------|-------------------------|------------ +Lesser draw | `0bXXXXXX00` | `cmd_byte >> 2` | An array of length *Count* filled with 1-byte palette indices follows, 1 index per pixel. +Lesser skip | `0bXXXXXX01` | `cmd_byte >> 2` or next | *Count* transparent pixels should be drawn from the current position. +Greater draw | `0bXXXX0010` | `cmd_byte << 4 + next` | An array of length *Count* filled with 1-byte palette indices follows, 1 index per pixel. +Greater skip | `0bXXXX0011` | `cmd_byte << 4 + next` | *Count* transparent pixels should be drawn from the current position. +Player color draw | `0bXXXX0110` | `cmd_byte >> 4` or next | An array of length "Count" filled with 1-byte player color palette indices follow, 1 index per pixel. The real palette index is `player_color_palette_index + player * 16`, where `player` is the player ID you're drawing for (1-8). +Fill | `0bXXXX0111` | `cmd_byte >> 4` or next | One palette index byte follows. This color should be drawn `pixel_count` times from the current position. +Fill player color | `0bXXXX1010` | `cmd_byte >> 4` or next | One player palette index byte follows. This color should be drawn `pixel_count` times. See `player_color_list (0x06)` +Shadow draw | `0bXXXX1011` | `cmd_byte >> 4` or next | Draw *Count* shadow pixels (The pixels to draw when a unit is behind another object). +Extended command | `0bXXXX1110` | depends | Get the specific extended command by looking at the most significant bits of the command. (See the table below for details) +End of row | `0x0F` | 0 | End of commands for this row. If more commands follow, they are for the next row. Extended commands, for outlines behind trees/buildings: -Command name | Byte value | Count | Description ---------------------|------------|-------|------------ -render_hint_xflip | `0x0E` | 0 | Draw the following command if sprite is not flipped right to left. -render_h_notxflip | `0x1E` | 0 | Draw following command if this sprite is x-flipped. -table_use_normal | `0x2E` | 0 | Set color transform table to normal. -table_use_alternate | `0x3E` | 0 | Set color transform table to alternate. -outline_1 | `0x4E` | 1 | `palette_index = player_index = player * 16`, if obstructed, draw player color, else transparent. This is the player color outline you see when a unit is behind a building. (special color = 1) -outline_span_1 | `0x5E` | next | `palette_index = player_index = player * 16`, can be >=1 pixel -outline_2 | `0x6E` | 1 | `palette_index = 0`, if pixel obstructed, draw a pixel as black outline, else transparent. (special color = 2) -outline_span_2 | `0x7E` | next | `palette_index = 0`, same as obstruct_black, >=1 pixel. -dither | `0x8E` | ? | ? -premulti_alpha | `0x9E` | ? | Premultiplied alpha? -orig_alph | `0xA0` | ? | Original alpha? - - -* "Lesser block copy" (0x00) also takes the values `0x04, 0x08 & 0x0C` -* "Lesser skip" (0x01) also takes the values `0x05, 0x09, 0x0D`. -* "Player color block copy" (0x06) is an index into the palette, in range 0-15. -* "Shadow" (0x0B) destination "pixels" are used as a lookup into a shadow_table. +Command name | Byte value | Pixel Count | Description +--------------------|------------|-------------|------------ +render_hint_xflip | `0x0E` | 0 | Draw the following command if sprite is not flipped right to left. +render_h_notxflip | `0x1E` | 0 | Draw following command if this sprite is x-flipped. +table_use_normal | `0x2E` | 0 | Set color transform table to normal. +table_use_alternate | `0x3E` | 0 | Set color transform table to alternate. +outline_1 | `0x4E` | 1 | `palette_index = player_index = player * 16`, if obstructed, draw player color, else transparent. This is the player color outline you see when a unit is behind a building. (special color = 1) +outline_span_1 | `0x5E` | next | `palette_index = player_index = player * 16`, can be >=1 pixel +outline_2 | `0x6E` | 1 | `palette_index = 0`, if pixel obstructed, draw a pixel as black outline, else transparent. (special color = 2) +outline_span_2 | `0x7E` | next | `palette_index = 0`, same as obstruct_black, >=1 pixel. +dither | `0x8E` | ? | ? +premulti_alpha | `0x9E` | ? | Premultiplied alpha? +orig_alph | `0xAE` | ? | Original alpha? + + +* "Player color block copy" (`0bXXXX0110`) is an index into the palette, in range 0-15. +* "Shadow" (`0bXXXX1011`) destination "pixels" are used as a lookup into a shadow_table. This lookup pixel is then used to draw into the buffer. The shadow table is a color-tinted variation of the real color table. It's also used to draw things like the red-tinted checkerboard when placing buildings at forbidden places. @@ -181,7 +229,7 @@ orig_alph | `0xA0` | ? | Original alpha? is transparent. So, the left outline is one pixel more to the left than the first color. - Usually, these outline pixels should be stored to a second image, which is + Usually, these outline pixels should be stored to a second spritesheet, which is rendered on top of the tree/building by the fragment shader. For later drawing, a graphics file needs flags for: @@ -193,16 +241,89 @@ For later drawing, a graphics file needs flags for: Now the palette indices for all the colors of the unit are known, but a palette is needed for them to be drawn. -## Palette Files +#### Examples -The drawing palette is stored inside `interfac.drs`. It's basically an array of -`(r, g, b)` tuples. +##### Lesser draw and skip -The file id is `50500+x`, the palettes (color tables) are stored the same way -as .SLPs are. (see [.DRS Files](drs-files.md)) Here `x` is the palette index, -which should be 0, experiment with `[1,10]`... +``` +Row example: 0x08 0x55 0xF4 0x19 0x28 0x99 0x35 0xF4 0x6D 0x67 0x6E 0xA5 0x01 0x4D 0x8E 0x0F -`interfac.drs` contains many of these files, but the ingame art uses id 50500. +first cmd_byte = 0x08 = 0b00001000 +``` + +The first `cmd_byte` value has the 2 least significant bits set to `0b00`, +so we know it has to be a *lesser draw*. We can now calculate the pixel +count by shifting the command byte to the right 2 times: + +``` +pixel_count = cmd_byte >> 2 = 0b00000010 = 0x02 = 2 +``` + +The pixel count is 2 which tells us that an array of 2 indices will follow +`cmd_byte`. Therefore, the bytes `0x55` and `0xF4` belong to the drawing +command. + +The next `cmd_byte` is `0xF4`: + +``` +second cmd_byte = 0x19 = 0b00011001 +``` + +The 2 least significant bits of this command are `0b01`, so this can be +identified as a *lesser skip* command. Here, we also have to calculate +the pixel count by shifting 2 times to the right: + +``` +pixel_count = cmd_byte >> 2 = 0b00000110 = 0x06 = 6 +``` + +This tells us that 6 transparent pixels have to be drawn. *Lesser skips* do +not reference any other bytes, so the following byte `0x28` is our next +command byte. + +``` +third cmd_byte = 0x28 = 0b00101000 +``` + +This again is a *lesser draw* command because the 2 least significant bits +are set to `0b00`, albeit with a different pixel count. + +``` +pixel_count = cmd_byte >> 2 = 0b00001010 = 0x0A = 10 +``` + +This time, the next 10 bytes are palette indices that belong to the drawing +command (`0x99 0x35 0xF4 0x6D 0x67 0x6E 0xA5 0x01 0x4D 0x8E`). + +Our next command byte is `0x0F`. This is the *end of row* command which tells +us that the row is finished. + +### SLP shadow commands (since version 4.0X) + +SLPs with versions higher than 4.0 store their shadows in separate frames, but +use the same command syntax. Shadows are drawn with the *lesser draw*, +*lesser skip* and *fill* commands. + +Command Name | Byte value | Pixel Count | Description +-------------------------|------------------|-------------------------|------------ +Lesser draw | `0bXXXXXX00` | `cmd_byte >> 2` | An array of length *Count* filled with 1-byte "shadow" values (see below) +Lesser skip | `0bXXXXXX01` | `cmd_byte >> 2` or next | *Count* transparent pixels should be drawn from the current position. +Greater draw | `0bXXXX0010` | `cmd_byte << 4 + next` | An array of length *Count* filled with 1-byte "shadow" values follows, 1 value per pixel. +Greater skip | `0bXXXX0011` | `cmd_byte << 4 + next` | *Count* transparent pixels should be drawn from the current position. +Fill | `0bXXXX0111` | `cmd_byte >> 4` or next | One palette index byte follows. This color should be drawn `pixel_count` times from the current position. + +The "shadow" values have to be converted to an alpha mask by left shifting +by 2 and and flipping the bits (i.e. subtracting from 255): + +``` +shadow_alpha = 255 - (shadow_value << 2) +``` + +## Palette Files + +The drawing palette is stored inside `interfac.drs` (until AoE2: HD), while +AoE1:DE and AoE2:DE store their palettes in separate files with a `.pal` or +`.palx` extension. It's basically an array of `(r, g, b)` tuples. The palette is a (text-based) JASC Paint Shop Pro file, starting with `JASC-PAL\r\n`. Read this line at the very start of a .BIN file to see if it's a @@ -212,48 +333,67 @@ third line stores the number of entries, as text. The rest is that many `r g b\r\n` lines. Again, `r`, `g` and `b` are stored as text (range `0-255`). -They are referenced in the SLP files, so should be indexed. It's simply an -array, so line 3=>index 0, line 4=>index 1 etc. +Colors from the palette are referenced in the SLP files with an index. +The index refers to a line in the palette, so line 3=>index 0, +line 4=>index 1 etc. + + +### Palette files in older versions of AoE1 and AoE2 (up until AoE2:HD) + +The file id is `50500+x`, the palettes (color tables) are stored the same way +as SLPs are. (see [DRS Files](drs-files.md)) Here `x` is the palette index, +which should be 0, experiment with `[1,10]`... + +`interfac.drs` contains many of these files, but the ingame art uses id 50500. + + +### Palette files in AoE1:DE + +Palettes are stored as plain human-readable palette files with the suffixes +`.pal` or `.palx`. Palette numbers are stored in a `palettes.conf` file that +contains a bunch of lines with assignments in the form of `palette_number,filename`. ## SLP types ### SLP files for moving objects -Unit .SLP files contain 5 states: +In Age of Empires 1, Age of Empires 2 and Star Wars: Galactic Battlegrounds, +each animation has 10 keyframes and 5 directions. The other 3 directions are +generated later by flipping the sprite on the y axis. -1. Attacking -2. Standing Ground -3. Dying -4. Rotting -5. Moving +One SLP stores one animation -> 50 frames per SLP. -On average, each state has roughly 50 animation frames -> ~250 textures per unit +Military units have 5 states that have animations: -For each animation, 5 directions of it are stored, the other 3 directions are -generated later by flipping the sprite on the y axis. +1. Idle +2. Move +3. Attack +4. Die +5. Decay -Villagers have way more than the 5 animations (for the different resources), -boats have 2 (boat & sail). +Villagers have way more than the 5 states (for the different resources), +boats have 2 separate animations for boat & sails. ### SLP files for static objects -Contain the image of the building. Some static objects have multiple chunks, +Contain 1 frame for the building. Some static objects have multiple chunks, like the Town Center, where units can be under one arm and 'in front of' the main building. -### SLP files for weapons +### SLP files for projectiles -Dust, arrows, etc. +Arrows and projectiles have 35 keyframes and 5 directions for their animation +because they need a smoother transition between up- and downwards motion. +-> 175 frames per SLP. ### SLP files for shadows -Every object in game has a shadow. Moving units have frames for their shadow in -the same SLP file, but buildings and other objects have their shadows in -separate SLPs. +Every object in game has a shadow. Up until SLP v3.0, moving units store +their shadow in the same frame as the main graphic, but buildings and +other objects have their shadows in separate SLPs. -Large numbers of shadow SLPs are difficult to identify, due to the image only -being in a single color. +Since version 4.0, shadows are stored in separate frames in the same SLP file. diff --git a/doc/media/smp-files.md b/doc/media/smp-files.md new file mode 100644 index 0000000000..10f7675978 --- /dev/null +++ b/doc/media/smp-files.md @@ -0,0 +1,324 @@ +# SMP files + +SMP files are the successor format to SLP files. Like SLP files, +they contain animations, shadows and outlines for units. SMPs +were introduced with Age of Empires 2: Definitive Edition. + + +## SMP file format + +SMPs share a lot of structural similarities to SLPs. However, +all of the drawing commands have changed, so the formats are not +compatible to each other. + + +### Header + +The SMP file starts with a header: + +Length | Type | Description | Example +---------|--------|--------------------|-------- +4 bytes | string | Version | SMP$ +4 bytes | int32 | ?? | 256, 0x00000100 (same value for almost all units) +4 bytes | int32 | Number of frames | 721, 0x000002D1 +4 bytes | int32 | ?? | 1, 0x0000001 (almost always 0x00000001) +4 bytes | int32 | Number of frames | 721, 0x000002D1 (0x00000001 for version 0x0B) +4 bytes | int32 | possibly checksum | 0x8554F6F3 +4 bytes | int32 | File size in bytes | 0x003D5800 +4 bytes | int32 | Version? | 0x0B or 0x0C +32 bytes | string | Comment | Apparently the file path on FE's machines + + +```cpp +struct smp_header { + char version[4]; + int32 ??; + int32 num_frames; + int32 ??; + int32 num_frames; + int32 checksum; + int32 file_size; + int32 version; + char comment[32]; +}; +``` +Python format: `Struct("< 4s 7i 32s")` + + +### SMP Bundle Offsets + +SMP frames come in bundles that can consist of up to 3 images. The images +contain the following data: + +* main sprite +* shadow for that sprite (optional) +* outline (optional, only used for units) + +After the header, there are `num_frames` entries of `smp_bundle_offset`. +Every `smp_bundle_offset` stores the offset to a bundle within the SMP +file. + +```cpp +struct smp_bundle_offset { + uint32 offset; +} +``` +Python format: `Struct("< I")` + + +### SMP Bundle header + +At every `smp_bundle_offset` there is a 32 bytes long bundle header +that stores how many images exist for the frame in a 4 byte length +field at the end. + +```cpp +struct smp_bundle_offset { + 28 bytes unused; # stores frame header info in 0x0B SMP version + uint32 length; +} +``` + +In version 0x0B, the bundle header has the same structure as the frame +headers (see below), except that `outline_table_offset` and +`cmd_table_offset` and `frame_type` are set to zero. In version +0x0C, all fields except the length fiield are set to zero. + + +### SMP Frame Header + +After the bundle header, there are `length` entries of `smp_frame_header`. +These struct are similar to the SLP Frame Info struct in that they store +metadata about the frame. + +Length | Type | Description | Example +---------|--------|----------------------------|-------- +4 bytes | uint32 | Width of image | 168, 0x000000A8 +4 bytes | uint32 | Height of image | 145, 0x00000091 +4 bytes | uint32 | Centre of sprite (X coord) | 88, 0x00000058 +4 bytes | uint32 | Centre of sprite (Y coord) | 99, 0x00000063 +4 bytes | int32 | Frame type | 0x02, 0x04 or 0x08 +4 bytes | int32 | Outline table offset | 600, 0x00000258 +4 bytes | int32 | Command table offset | 0, 0x00000000 +4 bytes | int32 | ?? | 0x01, 0x02 or 0x80 + +```cpp +struct smp_frame_header { + uint32 width; + uint32 height; + uint32 hotspot_x; + uint32 hotspot_y; + uint32 frame_type; + uint32 outline_table_offset; + uint32 cmd_table_offset; + uint32 ??; +}; +``` +Python format: `Struct("< 8i")` + +* Frame types can be `0x02` (main graphic), `0x04` (shadow) or `0x08` +(outline). In version 0x0B, outlines have a different frame type: `0x10`. +* Outline and command table offsets **are always relative to the bundle offset**. + + +### SMP Frame row edge + +At `outline_table_offset` (after the `smp_frame_header` structs), an array of +`smp_frame_row_edge` (of length `height`) structs begins. + +Length | Type | Description | Example +---------|--------|---------------|----------- +2 bytes | uint16 | Left spacing | 20, 0x0014 +2 bytes | uint16 | Right spacing | 3, 0x0003 + +```cpp +struct smp_frame_row_edge { + uint16 left_space; + uint16 right_space; +}; +``` +Python format: `Struct("< H H")` + +For every row, `left_space` and `right_space` specify the number of transparent +pixels, from each side to the center. For example, in a 50 pixels wide row, with +a `smp_frame_row_edge` of `{ .left_space = 20, .right_space = 3 }`, the leftmost +20 pixels will be transparent, the rightmost 3 will be transparent and there +will be 27 pixels of graphical data provided through some number of commands. + +If the right or left value is `0xFFFF`, the row is completely transparent. +Note that there are no command bytes for these rows, so will have to be skipped +"manually". + +`width - left_space - right_space` = number of pixels in this line. + + +### SMP command table + +At `smp_frame_header.cmd_table_offset`, an array of +uint32 (of length `height`) begins: + +```cpp +struct smp_command_offset { + uint32 offset; +} +``` +Python format: `Struct("< I")` + +Each `offset` defines the offset (beginning) of the first command of a row. +The first `offset` in this array is the first drawing command for the image. +All offsets are relative to their respective `smp_bundle_offset`. + +These are not actually necessary to use (but obviously are necessary to read), +since the commands can be read sequentially, although they can be used for +validation purposes. + + +### SMP drawing commands + +The image is drawn line by line, a line is finished with the "End of line" +command (0x03). A command is a one-byte number (`cmd_byte`), followed +by command-specific data with a length (number of pixels) varying +depending on the command. The next command immediately follows the +previous command's data. + +In contrast to SLPs, the SMP format uses a much more simplified command set +that only contains four commands: *Skip*, *Draw*, *Player Color Draw* +and *End of Row*. The type of command is stored in the 2 least significant +bits of the command byte. The 6 most significant bytes define the length of the +command. + +Each command triggers a drawing method for n = "Count" pixels. + +For examples of drawing commands, see the [Examples](#examples) section. + + +### Full command list + +An `X` signifies that the bit can have any value. These bits are used for +storing the length (pixel count) of the command. + +The commands works slightly different for each frame type. + + +#### Main graphics type + +Command Name | Byte value | Pixel Count | Description +-----------------|---------------|--------------------------|------------ +Skip | `0bXXXXXX00` | `(cmd_byte >> 2) + 1` | *Count* transparent pixels should be drawn from the current position. +Draw | `0bXXXXXX01` | `(cmd_byte >> 2) + 1` | An array of length `pixel_count * 4` bytes filled with 4-byte SMP pixels follows (see [SMP Pixel](#smp-pixel)) +Playercolor Draw | `0bXXXXXX10` | `(cmd_byte >> 2) + 1` | An array of length `pixel_count * 4` bytes filled with 4-byte SMP pixels follows (see [SMP Pixel](#smp-pixel)) +End of Row | `0bXXXXXX11` | 0 | End of commands for this row. If more commands follow, they are for the next row. + +* When converting the main graphics, the alpha values from the palette are +apparently ignored by the game. + + +#### Shadow type + +Command Name | Byte value | Pixel Count | Description +-----------------|---------------|--------------------------|------------ +Skip | `0bXXXXXX00` | `(cmd_byte >> 2) + 1` | *Count* transparent pixels should be drawn from the current position. +Draw | `0bXXXXXX01` | `(cmd_byte >> 2) + 1` | An array of length `pixel_count * 4` bytes filled with 1-byte alpha values follows. +End of Row | `0bXXXXXX11` | 0 | End of commands for this row. If more commands follow, they are for the next row. + +* Shadow frames (frame type `0x04`) sometimes do not explicitely draw the last +pixel in a row. If that happens, the openage converter draws the last *Draw* command +again + + +#### Outline type + +Command Name | Byte value | Pixel Count | Description +-----------------|---------------|--------------------------|------------ +Skip | `0bXXXXXX00` | `(cmd_byte >> 2) + 1` | *Count* transparent pixels should be drawn from the current position. +Draw | `0bXXXXXX01` | `(cmd_byte >> 2) + 1` | *Count* player color pixels should be drawn from the current position. +End of Row | `0bXXXXXX11` | 0 | End of commands for this row. If more commands follow, they are for the next row. + +* SMP files do not specify a color from a palette for outlines. The openage converter +always uses the color from index 0 in the player color palette for these *Draw* commands. + + +### SMP Pixel + +SMP pixels store a palette index, palette number and section as well +as occlusion masks. + +Length | Type | Description | Example +---------|--------|----------------------------|-------- +1 byte | uint8 | Palette index | 20, 0x0014 +1 byte | uint8 | Palette number and section | 7, 0x0007 +1 byte | uint8 | Damage mask | 128, 0x80 (only high nibble is used) +1 byte | uint8 | Damage mask | 3, 0x03 + +```cpp +struct smp_pixel { + uint8 px_index; + uint8 px_palette; + uint8 px_damage_mask_1; + uint8 px_damage_mask_2; +}; +``` +Python format: `Struct("< B B B B")` + +Colors are stored in JASC palettes with 1024 colors. The palettes are assigned an index +which is stored in a `palette.conf` file. To find the encoded color of a SMP pixel, +the *palette index* and *palette section* have to be determined from the `px_palette` +value. The palette index is stored in the 6 most significant bits, while the palette +section is stored in the 2 least significant bits. + +``` +palette_index = px_palette >> 2 + +palette_section = px_palette & 0b00000011 +``` + +`px_index` has to be added to `256 * palette_section` to retrieve the actual +index for the palette. This index can then be used to get the color value from +the palette with the index `palette_index`. + +The other two bytes in the file are used for masking when units get damaged. + + +#### Examples + +##### Retrieving a color value from a SMP pixel + +Let's assume we have a single SMP pixel and want to find the correct palette for it. + +``` +SMP pixel example: 0xEF 0x57 0x50 0x20 +``` + +The second byte value `0x57` contains the palette information. + +We can retrieve the *palette index* by shifting `0x57` by 2 to the right. Alternatively, +you can also divide the value by 4 and floor the result. + +``` +palette_index = 0x57 >> 2 = 0b01010111 >> 2 = 0b00010101 = 21 +``` + +The *palette index* is 21 which maps to the palette `b_west.pal` in the `palettes.conf` +of Age of Empires 2: Definitive Edition. + +Now we have to determine the section of the palette that is used for the pixel. To +do that, we can either look at the 2 most significant or calculate +`px_palette mod 4`. + +``` +palette_section = 0x57 & 0b00000011 = 0b01010111 & 0b00000011 = 0b00000011 = 3 +``` + +Here, the *palette section* is 3 which would cover the indexes 512 to 767 in +`b_west.pal`. From the retrieved values we can now determine the actual index +in the palette by adjusting `px_index = 0xEF` to the palette section. + +``` +color_index = px_index + 256 * palette_section + = 0xEF + 256 * 0x03 + = 239 + 256 * 3 + = 1007 +``` + +Finally, we can use this index to look up the color value in `b_west.pal`. +In our example, the RGBA value is (5,19,4,255). diff --git a/openage/convert/CMakeLists.txt b/openage/convert/CMakeLists.txt index da36323c93..d6b7a20dd6 100644 --- a/openage/convert/CMakeLists.txt +++ b/openage/convert/CMakeLists.txt @@ -20,6 +20,7 @@ add_py_modules( add_cython_modules( slp.pyx + smp.pyx ) add_pxds( diff --git a/openage/convert/gamedata/unit.py b/openage/convert/gamedata/unit.py index 0339c8546b..20a400d050 100644 --- a/openage/convert/gamedata/unit.py +++ b/openage/convert/gamedata/unit.py @@ -1,4 +1,4 @@ -# Copyright 2013-2017 the openage authors. See copying.md for legal info. +# Copyright 2013-2019 the openage authors. See copying.md for legal info. # TODO pylint: disable=C,R,too-many-lines @@ -133,9 +133,9 @@ class UnitHeader(Exportable): # Only used in SWGB class UnitLine(Exportable): - name_struct = "unit_header" - name_struct_file = "unit" - struct_description = "stores a bunch of unit commands." + name_struct = "unit_line" + name_struct_file = "unit_lines" + struct_description = "stores a bunch of units in SWGB." data_format = [ (READ, "name_length", "uint16_t"), diff --git a/openage/convert/hardcoded/texture.py b/openage/convert/hardcoded/texture.py index 8a4d023b9a..af5a1ccee6 100644 --- a/openage/convert/hardcoded/texture.py +++ b/openage/convert/hardcoded/texture.py @@ -1,11 +1,13 @@ -# Copyright 2016-2016 the openage authors. See copying.md for legal info. +# Copyright 2016-2019 the openage authors. See copying.md for legal info. """ Constants for texture generation. """ # The maximum allowed texture dimension. -MAX_TEXTURE_DIMENSION = 8194 +# TODO: Maximum allowed dimension needs to +# be determined by converter. +MAX_TEXTURE_DIMENSION = 32768 # Margin between subtextures in atlas to avoid texture bleeding. MARGIN = 1 diff --git a/openage/convert/singlefile.py b/openage/convert/singlefile.py index 88626e4fe7..c7ac0d1a1c 100644 --- a/openage/convert/singlefile.py +++ b/openage/convert/singlefile.py @@ -1,4 +1,4 @@ -# Copyright 2015-2018 the openage authors. See copying.md for legal info. +# Copyright 2015-2019 the openage authors. See copying.md for legal info. """ Convert a single slp file from some drs archive to a png image. @@ -19,17 +19,28 @@ def init_subparser(cli): cli.set_defaults(entrypoint=main) - cli.add_argument("--palette", default="50500", help="palette number") + cli.add_argument("--palette-index", default="50500", + help="palette number in interfac.drs") + cli.add_argument("--palette-file", type=argparse.FileType('rb'), + help=("palette file where the palette" + "colors are contained")) + cli.add_argument("--player-palette-file", type=argparse.FileType('rb'), + help=("palette file where the player" + "colors are contained")) cli.add_argument("--interfac", type=argparse.FileType('rb'), help=("drs archive where palette " "is contained (interfac.drs). " "If not set, assumed to be in same " "directory as the source drs archive")) - cli.add_argument("drs", type=argparse.FileType('rb'), - help=("drs archive filename that contains the slp " + cli.add_argument("--drs", type=argparse.FileType('rb'), + help=("drs archive filename that contains an slp " "e.g. path ~/games/aoe/graphics.drs")) - cli.add_argument("slp", help=("slp filename inside the drs archive " - "e.g. 326.slp")) + cli.add_argument("--mode", choices=['drs-slp', 'slp', 'smp'], + help=("choose between drs-slp, slp or smp; " + "otherwise, this is determined by the file extension")) + cli.add_argument("filename", help=("filename or, if inside a drs archive " + "given by --drs, the filename within " + "the drs archive")) cli.add_argument("output", help="image output path name") @@ -37,20 +48,112 @@ def main(args, error): """ CLI entry point for single file conversions """ del error # unused - drspath = Path(args.drs.name) - outputpath = Path(args.output) + file_path = Path(args.filename) + file_extension = file_path.suffix[1:].lower() + + if args.mode == "slp" or (file_extension == "slp" and not args.drs): + if not args.palette_file: + raise Exception("palette-file needs to be specified") + + read_slp_file(args.filename, args.palette_file, args.output, + player_palette=args.player_palette_file) + + elif args.mode == "drs-slp" or (file_extension == "slp" and args.drs): + if not (args.drs and args.palette_index): + raise Exception("palette-file needs to be specified") + + read_slp_in_drs_file(args.drs, args.filename, args.palette_index, + args.output, interfac=args.interfac) + + elif args.mode == "smp" or file_extension == "smp": + if not (args.palette_file and args.player_palette_file): + raise Exception("palette-file needs to be specified") + + read_smp_file(args.filename, args.palette_file, args.player_palette_file, + args.output) - if args.interfac: - interfacfile = args.interfac else: - # if no interfac was given, assume - # the same path of the drs archive. + raise Exception("format could not be determined") - interfacfile = drspath.with_name("interfac.drs").open("rb") # pylint: disable=no-member - # here, try opening slps from interfac or whereever - info("opening slp in drs '%s:%s'...", drspath, args.slp) - slpfile = DRS(args.drs).root[args.slp].open("rb") +def read_slp_file(slp_path, main_palette, output_path, player_palette=None): + """ + Reads a single SLP file. + """ + output_file = Path(output_path) + + # open the slp + info("opening slp file at '%s'", Path(slp_path).name) + slp_file = Path(slp_path).open("rb") + + # open palette from independent file + info("opening palette in palette file '%s'", main_palette.name) + palette_file = Path(main_palette.name).open("rb") + + info("parsing palette data...") + main_palette_table = ColorTable(palette_file.read()) + + # import here to prevent that the __main__ depends on SLP + # just by importing this singlefile.py. + from .slp import SLP + + # parse the slp_path image + info("parsing slp image...") + slp_image = SLP(slp_file.read()) + + player_palette_table = None + + # Player palettes need to be specified if SLP version is greater + # than 3.0 + if slp_image.version in (b'3.0\x00', b'4.0X', b'4.1X'): + if not player_palette: + raise Exception("SLPs version %s require a player " + "color palette" % slp_image.version) + + # open player color palette from independent file + info("opening player color palette in palette file '%s'", player_palette.name) + player_palette_file = Path(player_palette.name).open("rb") + + info("parsing palette data...") + player_palette_table = ColorTable(player_palette_file.read()) + + # create texture + info("packing texture...") + tex = Texture(slp_image, main_palette_table, player_palette_table) + + # save as png + tex.save(Directory(output_file.parent).root, output_file.name) + + +def read_slp_in_drs_file(drs, slp_path, palette_index, output_path, interfac=None): + """ + Reads a SLP file from a DRS archive. + """ + output_file = Path(output_path) + + # open from drs archive + drs_file = DRS(drs) + + info("opening slp in drs '%s:%s'...", drs.name, slp_path) + slp_file = drs_file.root[slp_path].open("rb") + + if interfac: + # open the interface file if given + interfac_file = interfac + + else: + # otherwise use the path of the drs. + interfac_file = Path(drs.name).with_name( + "interfac.drs").open("rb") # pylint: disable=no-member + + # open palette + info("opening palette in drs '%s:%s.bina'...", + interfac_file.name, palette_index) + palette_file = DRS( + interfac_file).root["%s.bina" % palette_index].open("rb") + + info("parsing palette data...") + palette = ColorTable(palette_file.read()) # import here to prevent that the __main__ depends on SLP # just by importing this singlefile.py. @@ -58,18 +161,51 @@ def main(args, error): # parse the slp image info("parsing slp image...") - slpimage = SLP(slpfile.read()) + slp_image = SLP(slp_file.read()) - # open color palette - info("opening palette in drs '%s:%s.bina'...", interfacfile.name, args.palette) - palettefile = DRS(interfacfile).root["%s.bina" % args.palette].open("rb") + # create texture + info("packing texture...") + tex = Texture(slp_image, palette) + + # save as png + tex.save(Directory(output_file.parent).root, output_file.name) + + +def read_smp_file(smp_path, main_palette, player_palette, output_path): + """ + Reads a single SMP file. + """ + output_file = Path(output_path) + + # open the smp + info("opening smp file at '%s'", smp_path) + smp_file = Path(smp_path).open("rb") + + # open main palette from independent file + info("opening main palette in palette file '%s'", main_palette.name) + main_palette_file = Path(main_palette.name).open("rb") info("parsing palette data...") - palette = ColorTable(palettefile.read()) + main_palette_table = ColorTable(main_palette_file.read()) + + # open player color palette from independent file + info("opening player color palette in palette file '%s'", player_palette.name) + player_palette_file = Path(player_palette.name).open("rb") + + info("parsing palette data...") + player_palette_table = ColorTable(player_palette_file.read()) + + # import here to prevent that the __main__ depends on SMP + # just by importing this singlefile.py. + from .smp import SMP + + # parse the slp_path image + info("parsing smp image...") + smp_image = SMP(smp_file.read()) # create texture info("packing texture...") - tex = Texture(slpimage, palette) + tex = Texture(smp_image, main_palette_table, player_palette_table) - # to save as png: - tex.save(Directory(outputpath.parent).root, outputpath.name) + # save as png + tex.save(Directory(output_file.parent).root, output_file.name) diff --git a/openage/convert/slp.pyx b/openage/convert/slp.pyx index ab8acacb04..31adca28e8 100644 --- a/openage/convert/slp.pyx +++ b/openage/convert/slp.pyx @@ -1,4 +1,4 @@ -# Copyright 2013-2018 the openage authors. See copying.md for legal info. +# Copyright 2013-2019 the openage authors. See copying.md for legal info. # # cython: profile=False @@ -21,19 +21,6 @@ from ..log import spam, dbg endianness = "< " -class SpecialColorValue(Enum): - shadow = "%" - transparent = " " - player_color = "P" - black_color = "#" - - def __str__(self): - return self.value - - def __repr__(self): - return self.value - - # command ids may have encoded the pixel length. # this is used when unpacked. cdef struct cmd_pack: @@ -49,14 +36,22 @@ cdef struct boundary_def: # SLP pixels can be very special. cdef enum pixel_type: - color_standard # standard pixel - color_shadow # shadow pixel - color_transparent # transparent pixel - color_player # non-outline player color pixel - color_black # black outline pixel - color_special_1 # player color outline pixel - color_special_2 # black outline pixel - + color_standard # standard pixel + color_shadow # shadow pixel + color_shadow_v4 # shadow pixel in the 4.0X version + color_transparent # transparent pixel + color_player # non-outline player color pixel + color_player_v4 # non-outline player color pixel + color_black # black outline pixel + color_special_1 # player color outline pixel + color_special_2 # black outline pixel + + +# SLPs with version 4.0+ have special +# rules for shadows +cdef enum slp_type: + slp_standard # standard type + slp_shadow # shadow SLP (v4.0 and higher) # One SLP pixel. cdef struct pixel: @@ -70,12 +65,28 @@ class SLP: This format is used to store all graphics within AOE. """ - # struct slp_header { + # struct slp_version { # char version[4]; + # }; + slp_version = Struct(endianness + "4s") + + # struct slp_header { # int frame_count; # char comment[24]; # }; - slp_header = Struct(endianness + "4s i 24s") + slp_header = Struct(endianness + "i 24s") + + # struct slp_header_v4 { + # unsigned short frame_count; + # unsigned short angles; + # unsigned short unknown; + # unsigned short frame_count_alt; + # unsigned int checksum; + # int offset_main; + # int offset_shadow; + # padding 8 bytes; + # }; + slp_header_v4 = Struct(endianness + "H H H H i i i 8x") # struct slp_frame_info { # unsigned int qdl_table_offset; @@ -90,57 +101,99 @@ class SLP: slp_frame_info = Struct(endianness + "I I I I i i i i") def __init__(self, data): - header = SLP.slp_header.unpack_from(data) - version, frame_count, comment = header + self.version = SLP.slp_version.unpack_from(data)[0] + + if self.version in (b'4.0X', b'4.1X'): + header = SLP.slp_header_v4.unpack_from(data, SLP.slp_version.size) + frame_count, angles, _, _, checksum, offset_main, offset_shadow = header - dbg("SLP") - dbg(" version: %s", version.decode('ascii')) - dbg(" frame count: %s", frame_count) - dbg(" comment: %s", comment.decode('ascii')) + dbg("SLP") + dbg(" version: %s", self.version.decode('ascii')) + dbg(" frame count: %s", frame_count) + dbg(" offset main graphic: %s", offset_main) + dbg(" offset shadow graphic: %s", offset_shadow) - self.frames = list() + else: + header = SLP.slp_header.unpack_from(data, SLP.slp_version.size) + frame_count, comment = header + + dbg("SLP") + dbg(" version: %s", self.version.decode('ascii')) + dbg(" frame count: %s", frame_count) + dbg(" comment: %s", comment.decode('ascii')) + + self.main_frames = list() + self.shadow_frames = list() spam(FrameInfo.repr_header()) # read all slp_frame_info structs for i in range(frame_count): - frame_header_offset = (SLP.slp_header.size + + frame_header_offset = (SLP.slp_version.size + + SLP.slp_header.size + i * SLP.slp_frame_info.size) frame_info = FrameInfo(*SLP.slp_frame_info.unpack_from( - data, frame_header_offset - )) + data, frame_header_offset), self.version, slp_standard) spam(frame_info) - self.frames.append(SLPFrame(frame_info, data)) + + if self.version in (b'3.0\x00', b'4.0X', b'4.1X'): + self.main_frames.append(SLPMainFrameDE(frame_info, data)) + + else: + self.main_frames.append(SLPMainFrameAoC(frame_info, data)) + + if self.version in (b'4.0X', b'4.1X') and offset_shadow != 0x00000000: + # 4.0X SLPs contain a shadow SLP inside them + # read all slp_frame_info of shadow + for i in range(frame_count): + frame_header_offset = (offset_shadow + + i * SLP.slp_frame_info.size) + + frame_info = FrameInfo(*SLP.slp_frame_info.unpack_from( + data, frame_header_offset), self.version, slp_shadow) + spam(frame_info) + self.shadow_frames.append(SLPShadowFrame(frame_info, data)) def __str__(self): ret = list() ret.extend([repr(self), "\n", FrameInfo.repr_header(), "\n"]) - for frame in self.frames: + for frame in self.main_frames: ret.extend([repr(frame), "\n"]) return "".join(ret) def __repr__(self): # TODO: lookup the image content description - return "SLP image<%d frames>" % len(self.frames) + return "SLP image<%d frames>" % len(self.main_frames) class FrameInfo: def __init__(self, qdl_table_offset, outline_table_offset, - palette_offset, properties, - width, height, hotspot_x, hotspot_y): + palette_offset, properties, width, height, + hotspot_x, hotspot_y, version, type): + + # offset of command table self.qdl_table_offset = qdl_table_offset + + # offset of transparent outline table self.outline_table_offset = outline_table_offset + self.palette_offset = palette_offset self.properties = properties # TODO what are properties good for? + self.size = (width, height) self.hotspot = (hotspot_x, hotspot_y) + # meta info + self.version = version + self.frame_type = type + @staticmethod def repr_header(): return ("offset (qdl table|outline table|palette) |" - " properties | width x height | hotspot x/y") + " properties | width x height | hotspot x/y |" + " version") def __repr__(self): ret = ( @@ -150,6 +203,7 @@ class FrameInfo: "% 10d | " % self.properties, "% 5d x% 7d | " % self.size, "% 4d /% 5d" % self.hotspot, + "% 4d" % self.version, ) return "".join(ret) @@ -274,6 +328,60 @@ cdef class SLPFrame: return row_data + cdef process_drawing_cmds(self, vector[pixel] &row_data, + Py_ssize_t rowid, + Py_ssize_t first_cmd_offset, + size_t expected_size): + pass + + cdef inline uint8_t get_byte_at(self, Py_ssize_t offset): + """ + Fetch a byte from the slp. + """ + return self.data_raw[offset] + + cdef inline cmd_pack cmd_or_next(self, uint8_t cmd, + uint8_t n, Py_ssize_t pos): + """ + to save memory, the draw amount may be encoded into + the drawing command itself in the upper n bits. + """ + + cdef uint8_t packed_in_cmd = cmd >> n + + if packed_in_cmd != 0: + return cmd_pack(packed_in_cmd, pos) + + else: + pos += 1 + return cmd_pack(self.get_byte_at(pos), pos) + + def get_picture_data(self, main_palette, player_palette=None, + player_number=0): + """ + Convert the palette index matrix to a colored image. + """ + return determine_rgba_matrix(self.pcolor, main_palette, + player_palette, player_number) + + def get_hotspot(self): + """ + Return the frame's hotspot (the "center" of the image) + """ + return self.info.hotspot + + def __repr__(self): + return repr(self.info) + + +cdef class SLPMainFrameAoC(SLPFrame): + """ + SLPFrame for the main graphics sprite up to SLP version 2.0. + """ + + def __init__(self, frame_info, data): + super().__init__(frame_info, data) + cdef process_drawing_cmds(self, vector[pixel] &row_data, Py_ssize_t rowid, Py_ssize_t first_cmd_offset, @@ -282,7 +390,6 @@ cdef class SLPFrame: create palette indices (colors) for the drawing commands found for this row in the SLP frame. """ - # position in the data blob, we start at the first command of this row cdef Py_ssize_t dpos = first_cmd_offset @@ -293,7 +400,7 @@ cdef class SLPFrame: cdef uint8_t nextbyte cdef uint8_t lower_nibble cdef uint8_t higher_nibble - cdef uint8_t lower_bits + cdef uint8_t lowest_crumb cdef cmd_pack cpack cdef int pixel_count @@ -312,16 +419,16 @@ cdef class SLPFrame: lower_nibble = 0x0f & cmd higher_nibble = 0xf0 & cmd - lower_bits = 0b00000011 & cmd + lowest_crumb = 0b00000011 & cmd # opcode: cmd, rowid: rowid - if lower_nibble == 0x0f: + if lower_nibble == 0x0F: # eol (end of line) command, this row is finished now. eor = True continue - elif lower_bits == 0b00000000: + elif lowest_crumb == 0b00000000: # color_list command # draw the following bytes as palette colors @@ -329,9 +436,10 @@ cdef class SLPFrame: for _ in range(pixel_count): dpos += 1 color = self.get_byte_at(dpos) + row_data.push_back(pixel(color_standard, color)) - elif lower_bits == 0b00000001: + elif lowest_crumb == 0b00000001: # skip command # draw 'count' transparent pixels # count = cmd >> 2; if count == 0: count = nextbyte @@ -377,9 +485,6 @@ cdef class SLPFrame: dpos += 1 color = self.get_byte_at(dpos) - # the SpecialColor class preserves the calculation with - # player * 16 + color, this is the palette offset - # for tinted player colors. row_data.push_back(pixel(color_player, color)) elif lower_nibble == 0x07: @@ -405,11 +510,6 @@ cdef class SLPFrame: dpos += 1 color = self.get_byte_at(dpos) - # TODO: verify this. might be incorrect. - # color = ((color & 0b11001100) | 0b00110011) - - # SpecialColor class preserves the calculation of - # player*16 + color for _ in range(cpack.count): row_data.push_back(pixel(color_player, color)) @@ -500,51 +600,371 @@ cdef class SLPFrame: # end of row reached, return the created pixel array. return - cdef inline uint8_t get_byte_at(self, Py_ssize_t offset): - """ - Fetch a byte from the slp. - """ - return self.data_raw[offset] - cdef inline cmd_pack cmd_or_next(self, uint8_t cmd, - uint8_t n, Py_ssize_t pos): +cdef class SLPMainFrameDE(SLPFrame): + """ + SLPFrame for the main graphics sprite since SLP version 3.0. + """ + + def __init__(self, frame_info, data): + super().__init__(frame_info, data) + + cdef process_drawing_cmds(self, vector[pixel] &row_data, + Py_ssize_t rowid, + Py_ssize_t first_cmd_offset, + size_t expected_size): """ - to save memory, the draw amount may be encoded into - the drawing command itself in the upper n bits. + create palette indices (colors) for the drawing commands + found for this row in the SLP frame. """ + # position in the data blob, we start at the first command of this row + cdef Py_ssize_t dpos = first_cmd_offset - cdef uint8_t packed_in_cmd = cmd >> n + # is the end of the current row reached? + cdef bool eor = False - if packed_in_cmd != 0: - return cmd_pack(packed_in_cmd, pos) + cdef uint8_t cmd + cdef uint8_t nextbyte + cdef uint8_t lower_nibble + cdef uint8_t higher_nibble + cdef uint8_t lowest_crumb + cdef cmd_pack cpack + cdef int pixel_count - else: - pos += 1 - return cmd_pack(self.get_byte_at(pos), pos) + # work through commands till end of row. + while not eor: + if row_data.size() > expected_size: + raise Exception( + "Only %d pixels should be drawn in row %d, " + "but we have %d already!" % ( + expected_size, rowid, row_data.size() + ) + ) - def get_picture_data(self, palette, player_number=0): - """ - Convert the palette index matrix to a colored image. - """ - return determine_rgba_matrix(self.pcolor, palette, player_number) + # fetch drawing instruction + cmd = self.get_byte_at(dpos) - def get_hotspot(self): + lower_nibble = 0x0f & cmd + higher_nibble = 0xf0 & cmd + lowest_crumb = 0b00000011 & cmd + + # opcode: cmd, rowid: rowid + + if lower_nibble == 0x0F: + # eol (end of line) command, this row is finished now. + eor = True + continue + + elif lowest_crumb == 0b00000000: + # color_list command + # draw the following bytes as palette colors + + pixel_count = cmd >> 2 + for _ in range(pixel_count): + dpos += 1 + color = self.get_byte_at(dpos) + + row_data.push_back(pixel(color_standard, color)) + + elif lowest_crumb == 0b00000001: + # skip command + # draw 'count' transparent pixels + # count = cmd >> 2; if count == 0: count = nextbyte + + cpack = self.cmd_or_next(cmd, 2, dpos) + dpos = cpack.dpos + for _ in range(cpack.count): + row_data.push_back(pixel(color_transparent, 0)) + + elif lower_nibble == 0x02: + # big_color_list command + # draw (higher_nibble << 4 + nextbyte) following palette colors + + dpos += 1 + nextbyte = self.get_byte_at(dpos) + pixel_count = (higher_nibble << 4) + nextbyte + + for _ in range(pixel_count): + dpos += 1 + color = self.get_byte_at(dpos) + row_data.push_back(pixel(color_standard, color)) + + elif lower_nibble == 0x03: + # big_skip command + # draw (higher_nibble << 4 + nextbyte) + # transparent pixels + + dpos += 1 + nextbyte = self.get_byte_at(dpos) + pixel_count = (higher_nibble << 4) + nextbyte + + for _ in range(pixel_count): + row_data.push_back(pixel(color_transparent, 0)) + + elif lower_nibble == 0x06: + # player_color_list command + # we have to draw the player color for cmd>>4 times, + # or if that is 0, as often as the next byte says. + + cpack = self.cmd_or_next(cmd, 4, dpos) + dpos = cpack.dpos + for _ in range(cpack.count): + dpos += 1 + color = self.get_byte_at(dpos) + + # version 3.0 uses extra palettes for player colors + row_data.push_back(pixel(color_player_v4, color)) + + elif lower_nibble == 0x07: + # fill command + # draw 'count' pixels with color of next byte + + cpack = self.cmd_or_next(cmd, 4, dpos) + dpos = cpack.dpos + + dpos += 1 + color = self.get_byte_at(dpos) + + for _ in range(cpack.count): + row_data.push_back(pixel(color_standard, color)) + + elif lower_nibble == 0x0A: + # fill player color command + # draw the player color for 'count' times + + cpack = self.cmd_or_next(cmd, 4, dpos) + dpos = cpack.dpos + + dpos += 1 + color = self.get_byte_at(dpos) + + for _ in range(cpack.count): + # version 3.0 uses extra palettes for player colors + row_data.push_back(pixel(color_player_v4, color)) + + elif lower_nibble == 0x0B: + # shadow command + # draw a transparent shadow pixel for 'count' times + + cpack = self.cmd_or_next(cmd, 4, dpos) + dpos = cpack.dpos + + for _ in range(cpack.count): + row_data.push_back(pixel(color_shadow, 0)) + + elif lower_nibble == 0x0E: + # "extended" commands. higher nibble specifies the instruction. + + if higher_nibble == 0x00: + # render hint xflip command + # render hint: only draw the following command, + # if this sprite is not flipped left to right + spam("render hint: xfliptest") + + elif higher_nibble == 0x10: + # render h notxflip command + # render hint: only draw the following command, + # if this sprite IS flipped left to right. + spam("render hint: !xfliptest") + + elif higher_nibble == 0x20: + # table use normal command + # set the transform color table to normal, + # for the standard drawing commands + spam("image wants normal color table now") + + elif higher_nibble == 0x30: + # table use alternat command + # set the transform color table to alternate, + # this affects all following standard commands + spam("image wants alternate color table now") + + elif higher_nibble == 0x40: + # outline_1 command + # the next pixel shall be drawn as special color 1, + # if it is obstructed later in rendering + row_data.push_back(pixel(color_special_1, 0)) + + elif higher_nibble == 0x60: + # outline_2 command + # same as above, but special color 2 + row_data.push_back(pixel(color_special_2, 0)) + + elif higher_nibble == 0x50: + # outline_span_1 command + # same as above, but span special color 1 nextbyte times. + + dpos += 1 + pixel_count = self.get_byte_at(dpos) + + for _ in range(pixel_count): + row_data.push_back(pixel(color_special_1, 0)) + + elif higher_nibble == 0x70: + # outline_span_2 command + # same as above, using special color 2 + + dpos += 1 + pixel_count = self.get_byte_at(dpos) + + for _ in range(pixel_count): + row_data.push_back(pixel(color_special_2, 0)) + + elif higher_nibble == 0x80: + # dither command + raise NotImplementedError("dither not implemented") + + elif higher_nibble in (0x90, 0xA0): + # 0x90: premultiplied alpha + # 0xA0: original alpha + raise NotImplementedError("extended alpha not implemented") + + else: + raise Exception( + "unknown slp drawing command: " + + "%#x in row %d" % (cmd, rowid)) + + dpos += 1 + + # end of row reached, return the created pixel array. + return + +cdef class SLPShadowFrame(SLPFrame): + """ + SLPFrame for the shadow graphics in SLP version 4.0 and 4.1. + """ + + def __init__(self, frame_info, data): + super().__init__(frame_info, data) + + cdef process_drawing_cmds(self, vector[pixel] &row_data, + Py_ssize_t rowid, + Py_ssize_t first_cmd_offset, + size_t expected_size): """ - Return the frame's hotspot (the "center" of the image) + create palette indices (colors) for the drawing commands + found for this row in the SLP frame. """ - return self.info.hotspot - def __repr__(self): - return repr(self.info) + # position in the data blob, we start at the first command of this row + cdef Py_ssize_t dpos = first_cmd_offset + + # is the end of the current row reached? + cdef bool eor = False + + cdef uint8_t cmd + cdef uint8_t nextbyte + cdef uint8_t lower_nibble + cdef uint8_t higher_nibble + cdef uint8_t lowest_crumb + cdef cmd_pack cpack + cdef int pixel_count + + # work through commands till end of row. + while not eor: + if row_data.size() > expected_size: + raise Exception( + "Only %d pixels should be drawn in row %d, " + "but we have %d already!" % ( + expected_size, rowid, row_data.size() + ) + ) + + # fetch drawing instruction + cmd = self.get_byte_at(dpos) + + lower_nibble = 0x0f & cmd + higher_nibble = 0xf0 & cmd + lowest_crumb = 0b00000011 & cmd + + # opcode: cmd, rowid: rowid + + if lower_nibble == 0x0F: + # eol (end of line) command, this row is finished now. + eor = True + continue + + elif lowest_crumb == 0b00000000: + # color_list command + # draw the following bytes as palette colors + + pixel_count = cmd >> 2 + for _ in range(pixel_count): + dpos += 1 + color = self.get_byte_at(dpos) + + # shadows in v4.0 draw a different color + row_data.push_back(pixel(color_shadow_v4, color)) + + elif lowest_crumb == 0b00000001: + # skip command + # draw 'count' transparent pixels + # count = cmd >> 2; if count == 0: count = nextbyte + + cpack = self.cmd_or_next(cmd, 2, dpos) + dpos = cpack.dpos + for _ in range(cpack.count): + row_data.push_back(pixel(color_transparent, 0)) + + elif lower_nibble == 0x02: + # big_color_list command + # draw (higher_nibble << 4 + nextbyte) following palette colors + + dpos += 1 + nextbyte = self.get_byte_at(dpos) + pixel_count = (higher_nibble << 4) + nextbyte + + for _ in range(pixel_count): + dpos += 1 + color = self.get_byte_at(dpos) + row_data.push_back(pixel(color_shadow_v4, color)) + + elif lower_nibble == 0x03: + # big_skip command + # draw (higher_nibble << 4 + nextbyte) + # transparent pixels + + dpos += 1 + nextbyte = self.get_byte_at(dpos) + pixel_count = (higher_nibble << 4) + nextbyte + + for _ in range(pixel_count): + row_data.push_back(pixel(color_transparent, 0)) + + elif lower_nibble == 0x07: + # fill command + # draw 'count' pixels with color of next byte + + cpack = self.cmd_or_next(cmd, 4, dpos) + dpos = cpack.dpos + + dpos += 1 + color = self.get_byte_at(dpos) + + for _ in range(cpack.count): + # shadows in v4.0 draw a different color + row_data.push_back(pixel(color_shadow_v4, color)) + + else: + raise Exception( + "unknown slp shadow drawing command: " + + "%#x in row %d" % (cmd, rowid)) + + dpos += 1 + + # end of row reached, return the created pixel array. + return @cython.boundscheck(False) @cython.wraparound(False) cdef numpy.ndarray determine_rgba_matrix(vector[vector[pixel]] &image_matrix, - palette, int player_number=0): + main_palette, player_palette, + int player_number=0): """ converts a palette index image matrix to an rgba matrix. """ + cdef size_t height = image_matrix.size() cdef size_t width = image_matrix[0].size() @@ -552,7 +972,12 @@ cdef numpy.ndarray determine_rgba_matrix(vector[vector[pixel]] &image_matrix, numpy.zeros((height, width, 4), dtype=numpy.uint8) # micro optimization to avoid call to ColorTable.__getitem__() - cdef list lookup = palette.palette + cdef list m_lookup = main_palette.palette + cdef list p_lookup + + # player palette for SLPs with version higher than 3.0 + if player_palette: + p_lookup = player_palette.palette cdef uint8_t r cdef uint8_t g @@ -578,7 +1003,7 @@ cdef numpy.ndarray determine_rgba_matrix(vector[vector[pixel]] &image_matrix, if px_type == color_standard: # simply look up the color index in the table - r, g, b = lookup[px_val] + r, g, b = m_lookup[px_val] alpha = 255 elif px_type == color_transparent: @@ -587,6 +1012,14 @@ cdef numpy.ndarray determine_rgba_matrix(vector[vector[pixel]] &image_matrix, elif px_type == color_shadow: r, g, b, alpha = 0, 0, 0, 100 + elif px_type == color_shadow_v4: + r, g, b = 0, 0, 0 + alpha = 255 - (px_val << 2) + + elif px_type == color_player_v4: + r, g, b = p_lookup[px_val] + alpha = 255 + else: if px_type == color_player: # mark this pixel as player color @@ -610,7 +1043,7 @@ cdef numpy.ndarray determine_rgba_matrix(vector[vector[pixel]] &image_matrix, # get rgb base color from the color table # store it the preview player color # in the table: [16*player, 16*player+7] - r, g, b = lookup[px_val + (16 * player_number)] + r, g, b = m_lookup[px_val + (16 * player_number)] # array_data[y, x] = (r, g, b, alpha) array_data[y, x, 0] = r diff --git a/openage/convert/slp_converter_pool.py b/openage/convert/slp_converter_pool.py index 6e8fb7be99..e18f7be3b4 100644 --- a/openage/convert/slp_converter_pool.py +++ b/openage/convert/slp_converter_pool.py @@ -1,4 +1,4 @@ -# Copyright 2015-2018 the openage authors. See copying.md for legal info. +# Copyright 2015-2019 the openage authors. See copying.md for legal info. """ Multiprocessing-based SLP-to-texture converter service. @@ -90,7 +90,7 @@ def convert(self, slpdata, custom_cutter=None): """ if self.fake: # convert right here, without entering the thread. - return Texture(SLP(slpdata), self.palette, custom_cutter) + return Texture(SLP(slpdata), self.palette, custom_cutter=custom_cutter) if free_memory() < 2**30: # TODO print the warn only once @@ -99,7 +99,7 @@ def convert(self, slpdata, custom_cutter=None): # acquire job_mutex in order to block any concurrent activity until # this job is done. with self.job_mutex: # pylint: disable=not-context-manager - return Texture(SLP(slpdata), self.palette, custom_cutter) + return Texture(SLP(slpdata), self.palette, custom_cutter=custom_cutter) # get the data queue for an idle worker process inqueue, outqueue = self.idle.get() @@ -154,7 +154,7 @@ def converter_process(inqueue, outqueue): slpdata, custom_cutter = work_item try: - texture = Texture(SLP(slpdata), palette, custom_cutter) + texture = Texture(SLP(slpdata), palette, custom_cutter=custom_cutter) outqueue.put(texture) except BaseException as exc: import traceback diff --git a/openage/convert/smp.pyx b/openage/convert/smp.pyx new file mode 100644 index 0000000000..c668d763e9 --- /dev/null +++ b/openage/convert/smp.pyx @@ -0,0 +1,829 @@ +# Copyright 2013-2019 the openage authors. See copying.md for legal info. +# +# cython: profile=False + +from struct import Struct, unpack_from + +from enum import Enum + +cimport cython +import numpy +cimport numpy + +from libc.stdint cimport uint8_t, uint16_t +from libcpp cimport bool +from libcpp.vector cimport vector + +from ..log import spam, dbg + + +# SMP files have little endian byte order +endianness = "< " + + +cdef struct boundary_def: + Py_ssize_t left + Py_ssize_t right + bool full_row + + +# SMP pixels are super special. +cdef enum pixel_type: + color_standard # standard pixel + color_shadow # shadow pixel + color_transparent # transparent pixel + color_player # non-outline player color pixel + color_outline # player color outline pixel + + +# One SMP pixel. +cdef struct pixel: + pixel_type type + uint8_t index # index in a palette section + uint8_t palette # palette number and palette section + uint8_t unknown1 # ??? + uint8_t unknown2 # masking for damage? + + +class SMP: + """ + Class for reading/converting the SMP image format (successor of SLP). + This format is used to store all graphics within AoE2: Definitive Edition. + """ + + # struct smp_header { + # char file_descriptor[4]; + # ??? 4 bytes; + # int frame_count; + # ??? 4 bytes; + # ??? 4 bytes; + # unsigned int unknown_offset_1; + # unsigned int file_size; + # ??? 4 bytes; + # char comment[32]; + # }; + smp_header = Struct(endianness + "4s i i i i I i I 32s") + + # struct smp_frame_bundle_offset { + # unsigned int frame_info_offset; + # }; + smp_frame_bundle_offset = Struct(endianness + "I") + + # struct smp_frame_bundle_size { + # padding 28 bytes; + # int frame_bundle_size; + # }; + smp_frame_bundle_size = Struct(endianness + "28x i") + + # struct smp_frame_info { + # int width; + # int height; + # int hotspot_x; + # int hotspot_y; + # int frame_type; + # unsigned int outline_table_offset; + # unsigned int qdl_table_offset; + # ??? 4 bytes; + # }; + smp_frame_header = Struct(endianness + "i i i i I I I I") + + def __init__(self, data): + smp_header = SMP.smp_header.unpack_from(data) + _, _, frame_count, _, _, _, file_size, _, comment = smp_header + + dbg("SMP") + dbg(" frame count: %s", frame_count) + dbg(" file size: %s B", file_size) + dbg(" comment: %s", comment.decode('ascii')) + + # Frame bundles store main graohic, shadow and outline headers + frame_bundle_offsets = list() + + # read offsets of the smp frames + for i in range(frame_count): + frame_bundle_pointer = (SMP.smp_header.size + + i * SMP.smp_frame_bundle_offset.size) + + frame_bundle_offset = SMP.smp_frame_bundle_offset.unpack_from( + data, frame_bundle_pointer)[0] + + frame_bundle_offsets.append(frame_bundle_offset) + + # SMP graphic frames are created from overlaying + # the main graphic frame with a shadow frame and + # and (for units) an outline frame + self.main_frames = list() + self.shadow_frames = list() + self.outline_frames = list() + + spam(FrameHeader.repr_header()) + + # read all smp_frame_bundle structs in a frame bundle + for bundle_offset in frame_bundle_offsets: + + # how many frame headers are in the bundle + frame_bundle_size = SMP.smp_frame_bundle_size.unpack_from( + data, bundle_offset)[0] + + for i in range(1, frame_bundle_size + 1): + frame_header_offset = (bundle_offset + + i * SMP.smp_frame_header.size) + + frame_header = FrameHeader(*SMP.smp_frame_header.unpack_from( + data, frame_header_offset), bundle_offset) + + if frame_header.frame_type == 0x02: + # frame that store the main graphic + self.main_frames.append(SMPMainFrame(frame_header, data)) + + elif frame_header.frame_type == 0x04: + # frame that stores a shadow + self.shadow_frames.append(SMPShadowFrame(frame_header, data)) + + elif frame_header.frame_type == 0x08 or \ + frame_header.frame_type == 0x10: + # frame that stores an outline + self.outline_frames.append(SMPOutlineFrame(frame_header, data)) + + else: + raise Exception( + "unknown frame header type: " + + "%h at offset %h" % (frame_header.frame_type, frame_header_offset)) + + spam(frame_header) + + def __str__(self): + ret = list() + + ret.extend([repr(self), "\n", FrameHeader.repr_header(), "\n"]) + for frame in self.frames: + ret.extend([repr(frame), "\n"]) + return "".join(ret) + + def __repr__(self): + # TODO: lookup the image content description + return "SMP image<%d frames>" % len(self.main_frames) + + +class FrameHeader: + def __init__(self, width, height, hotspot_x, + hotspot_y, type, outline_table_offset, + qdl_table_offset, unknown_value, + frame_bundle_offset): + + self.size = (width, height) + self.hotspot = (hotspot_x, hotspot_y) + + # 2 = normal, 4 = shadow, 8 = outline + self.frame_type = type + + # table offsets are relative to the frame bundle offset + self.outline_table_offset = outline_table_offset + frame_bundle_offset + self.qdl_table_offset = qdl_table_offset + frame_bundle_offset + + # the absolute offset of the bundle + self.bundle_offset = frame_bundle_offset + + @staticmethod + def repr_header(): + return ("width x height | hotspot x/y | " + "frame type | " + "offset (outline table|qdl table)" + ) + + def __repr__(self): + ret = ( + "% 5d x% 7d | " % self.size, + "% 4d /% 5d | " % self.hotspot, + "% 4d | " % self.frame_type, + "% 13d| " % self.outline_table_offset, + " % 9d|" % self.qdl_table_offset, + ) + return "".join(ret) + +cdef class SMPFrame: + """ + one image inside the SMP. you can imagine it as a frame of a video. + """ + + # struct smp_frame_row_edge { + # unsigned short left_space; + # unsigned short right_space; + # }; + smp_frame_row_edge = Struct(endianness + "H H") + + # struct smp_command_offset { + # unsigned int offset; + # } + smp_command_offset = Struct(endianness + "I") + + # struct smp_pixel { + # unsigned char palette_index; + # unsigned char palette; + # unsigned char unknown1; occlusion mask? + # unsigned char unknown2; occlusion mask? + # } + smp_pixel = Struct(endianness + "B B B B") + + # frame information + cdef object info + + # for each row: + # contains (left, right, full_row) number of boundary pixels + cdef vector[boundary_def] boundaries + + # stores the file offset for the first drawing command + cdef vector[int] cmd_offsets + + # pixel matrix representing the final image + cdef vector[vector[pixel]] pcolor + + # memory pointer + cdef const uint8_t *data_raw + + def __init__(self, frame_header, data): + self.info = frame_header + + if not (isinstance(data, bytes) or isinstance(data, bytearray)): + raise ValueError("Frame data must be some bytes object") + + # convert the bytes obj to char* + self.data_raw = data + + cdef size_t i + cdef int cmd_offset + + cdef size_t row_count = self.info.size[1] + + # process bondary table + for i in range(row_count): + outline_entry_position = (self.info.outline_table_offset + + i * SMPFrame.smp_frame_row_edge.size) + + left, right = SMPFrame.smp_frame_row_edge.unpack_from( + data, outline_entry_position + ) + + # is this row completely transparent? + if left == 0xFFFF or right == 0xFFFF: + self.boundaries.push_back(boundary_def(0, 0, True)) + else: + self.boundaries.push_back(boundary_def(left, right, False)) + + # process cmd table + for i in range(row_count): + cmd_table_position = (self.info.qdl_table_offset + + i * SMPFrame.smp_command_offset.size) + + cmd_offset = SMPFrame.smp_command_offset.unpack_from( + data, cmd_table_position)[0] + self.info.bundle_offset + self.cmd_offsets.push_back(cmd_offset) + + for i in range(row_count): + self.pcolor.push_back(self.create_color_row(i)) + + cdef vector[pixel] create_color_row(self, Py_ssize_t rowid) except +: + """ + extract colors (pixels) for the given rowid. + """ + + cdef vector[pixel] row_data + cdef Py_ssize_t i + + first_cmd_offset = self.cmd_offsets[rowid] + cdef boundary_def bounds = self.boundaries[rowid] + cdef size_t pixel_count = self.info.size[0] + + # preallocate memory + row_data.reserve(pixel_count) + + # row is completely transparent + if bounds.full_row: + for _ in range(pixel_count): + row_data.push_back(pixel(color_transparent, 0, 0, 0, 0)) + + return row_data + + # start drawing the left transparent space + for i in range(bounds.left): + row_data.push_back(pixel(color_transparent, 0, 0, 0, 0)) + + # process the drawing commands for this row. + self.process_drawing_cmds(row_data, rowid, + first_cmd_offset, + pixel_count - bounds.right) + + # finish by filling up the right transparent space + for i in range(bounds.right): + row_data.push_back(pixel(color_transparent, 0, 0, 0, 0)) + + # verify size of generated row + if row_data.size() != pixel_count: + got = row_data.size() + summary = "%d/%d -> row %d, frame type %d, offset %d / %#x" % ( + got, pixel_count, rowid, self.info.frame_type, + first_cmd_offset, first_cmd_offset + ) + txt = "got %%s pixels than expected: %s, missing: %d" % ( + summary, abs(pixel_count - got)) + + raise Exception(txt % ("LESS" if got < pixel_count else "MORE")) + + return row_data + + cdef process_drawing_cmds(self, vector[pixel] &row_data, + Py_ssize_t rowid, + Py_ssize_t first_cmd_offset, + size_t expected_size): + pass + + cdef inline uint8_t get_byte_at(self, Py_ssize_t offset): + """ + Fetch a byte from the SMP. + """ + return self.data_raw[offset] + + def get_picture_data(self, main_palette, player_palette): + """ + Convert the palette index matrix to a colored image. + """ + return determine_rgba_matrix(self.pcolor, main_palette, player_palette) + + def get_hotspot(self): + """ + Return the frame's hotspot (the "center" of the image) + """ + return self.info.hotspot + + def __repr__(self): + return repr(self.info) + + +cdef class SMPMainFrame(SMPFrame): + """ + SMPFrame for the main graphics sprite. + """ + + def __init__(self, frame_header, data): + super().__init__(frame_header, data) + + cdef process_drawing_cmds(self, vector[pixel] &row_data, + Py_ssize_t rowid, + Py_ssize_t first_cmd_offset, + size_t expected_size): + """ + extract colors (pixels) for the drawing commands + found for this row in the SMP frame. + """ + + # position in the data blob, we start at the first command of this row + cdef Py_ssize_t dpos = first_cmd_offset + + # is the end of the current row reached? + cdef bool eor = False + + cdef uint8_t cmd + cdef uint8_t nextbyte + cdef uint8_t lower_crumb + cdef int pixel_count + + # work through commands till end of row. + while not eor: + if row_data.size() > expected_size: + raise Exception( + "Only %d pixels should be drawn in row %d " + "with frame type %d, but we have %d " + "already!" % ( + expected_size, rowid, + self.info.frame_type, + row_data.size() + ) + ) + + # fetch drawing instruction + cmd = self.get_byte_at(dpos) + + # Last 2 bits store command type + lower_crumb = 0b00000011 & cmd + + # opcode: cmd, rowid: rowid + + if lower_crumb == 0b00000011: + # eol (end of line) command, this row is finished now. + eor = True + + continue + + elif lower_crumb == 0b00000000: + # skip command + # draw 'count' transparent pixels + # count = (cmd >> 2) + 1 + + pixel_count = (cmd >> 2) + 1 + + for _ in range(pixel_count): + row_data.push_back(pixel(color_transparent, 0, 0, 0, 0)) + + elif lower_crumb == 0b00000001: + # color_list command + # draw the following 'count' pixels + # pixels are stored as rgba 32 bit values + # count = (cmd >> 2) + 1 + + pixel_count = (cmd >> 2) + 1 + + for _ in range(pixel_count): + + pixel_data = list() + + for _ in range(4): + dpos += 1 + pixel_data.append(self.get_byte_at(dpos)) + + row_data.push_back(pixel(color_standard, + pixel_data[0], + pixel_data[1], + pixel_data[2], + pixel_data[3])) + + elif lower_crumb == 0b00000010: + # player_color command + # draw the following 'count' pixels + # pixels are stored as rgba 32 bit values + # count = (cmd >> 2) + 1 + + pixel_count = (cmd >> 2) + 1 + + for _ in range(pixel_count): + + pixel_data = list() + + for _ in range(4): + dpos += 1 + pixel_data.append(self.get_byte_at(dpos)) + + row_data.push_back(pixel(color_player, + pixel_data[0], + pixel_data[1], + pixel_data[2], + pixel_data[3])) + + else: + raise Exception( + "unknown smp main frame drawing command: " + + "%#x in row %d" % (cmd, rowid)) + + # process next command + dpos += 1 + + # end of row reached, return the created pixel array. + return + + def get_damage_mask(self): + """ + Convert the 4th pixel byte to a mask used for damaged units. + """ + return determine_damage_matrix(self.pcolor) + + +cdef class SMPShadowFrame(SMPFrame): + """ + SMPFrame for the shadow graphics. + """ + + def __init__(self, frame_header, data): + super().__init__(frame_header, data) + + cdef process_drawing_cmds(self, vector[pixel] &row_data, + Py_ssize_t rowid, + Py_ssize_t first_cmd_offset, + size_t expected_size): + """ + extract colors (pixels) for the drawing commands + found for this row in the SMP frame. + """ + + # position in the data blob, we start at the first command of this row + cdef Py_ssize_t dpos = first_cmd_offset + + # is the end of the current row reached? + cdef bool eor = False + + cdef uint8_t cmd + cdef uint8_t nextbyte + cdef uint8_t lower_crumb + cdef int pixel_count + + # work through commands till end of row. + while not eor: + if row_data.size() > expected_size: + raise Exception( + "Only %d pixels should be drawn in row %d " + "with frame type %d, but we have %d " + "already!" % ( + expected_size, rowid, + self.info.frame_type, + row_data.size() + ) + ) + + # fetch drawing instruction + cmd = self.get_byte_at(dpos) + + # Last 2 bits store command type + lower_crumb = 0b00000011 & cmd + + # opcode: cmd, rowid: rowid + + if lower_crumb == 0b00000011: + # eol (end of line) command, this row is finished now. + eor = True + + # shadows sometimes need an extra pixel at + # the end + if row_data.size() < expected_size: + # copy the last drawn pixel + # (still stored in nextbyte) + # + # TODO: confirm that this is the + # right way to do it + row_data.push_back(pixel(color_shadow, + nextbyte, 0, 0, 0)) + + continue + + elif lower_crumb == 0b00000000: + # skip command + # draw 'count' transparent pixels + # count = (cmd >> 2) + 1 + + pixel_count = (cmd >> 2) + 1 + + for _ in range(pixel_count): + row_data.push_back(pixel(color_transparent, 0, 0, 0, 0)) + + elif lower_crumb == 0b00000001: + # color_list command + # draw the following 'count' pixels + # pixels are stored as rgba 32 bit values + # count = (cmd >> 2) + 1 + + pixel_count = (cmd >> 2) + 1 + + for _ in range(pixel_count): + + dpos += 1 + nextbyte = self.get_byte_at(dpos) + + row_data.push_back(pixel(color_shadow, + nextbyte, 0, 0, 0)) + + else: + raise Exception( + "unknown smp shadow frame drawing command: " + + "%#x in row %d" % (cmd, rowid)) + + # process next command + dpos += 1 + + # end of row reached, return the created pixel array. + return + + +cdef class SMPOutlineFrame(SMPFrame): + """ + SMPFrame for the outline graphics. + """ + + def __init__(self, frame_header, data): + super().__init__(frame_header, data) + + cdef process_drawing_cmds(self, vector[pixel] &row_data, + Py_ssize_t rowid, + Py_ssize_t first_cmd_offset, + size_t expected_size): + """ + extract colors (pixels) for the drawing commands + found for this row in the SMP frame. + """ + + # position in the data blob, we start at the first command of this row + cdef Py_ssize_t dpos = first_cmd_offset + + # is the end of the current row reached? + cdef bool eor = False + + cdef uint8_t cmd + cdef uint8_t nextbyte + cdef uint8_t lower_crumb + cdef int pixel_count + + # work through commands till end of row. + while not eor: + if row_data.size() > expected_size: + raise Exception( + "Only %d pixels should be drawn in row %d " + "with frame type %d, but we have %d " + "already!" % ( + expected_size, rowid, + self.info.frame_type, + row_data.size() + ) + ) + + # fetch drawing instruction + cmd = self.get_byte_at(dpos) + + # Last 2 bits store command type + lower_crumb = 0b00000011 & cmd + + # opcode: cmd, rowid: rowid + + if lower_crumb == 0b00000011: + # eol (end of line) command, this row is finished now. + eor = True + + continue + + elif lower_crumb == 0b00000000: + # skip command + # draw 'count' transparent pixels + # count = (cmd >> 2) + 1 + + pixel_count = (cmd >> 2) + 1 + + for _ in range(pixel_count): + row_data.push_back(pixel(color_transparent, 0, 0, 0, 0)) + + elif lower_crumb == 0b00000001: + # color_list command + # draw the following 'count' pixels + # as player outline colors. + # pixels are stored as rgba 32 bit values + # count = (cmd >> 2) + 1 + + pixel_count = (cmd >> 2) + 1 + + for _ in range(pixel_count): + # we don't know the color the game wants + # so we just draw index 0 + row_data.push_back(pixel(color_outline, + 0, 0, 0, 0)) + + else: + raise Exception( + "unknown smp outline frame drawing command: " + + "%#x in row %d" % (cmd, rowid)) + + # process next command + dpos += 1 + + # end of row reached, return the created pixel array. + return + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef numpy.ndarray determine_rgba_matrix(vector[vector[pixel]] &image_matrix, + main_palette, player_palette): + """ + converts a palette index image matrix to an rgba matrix. + """ + + cdef size_t height = image_matrix.size() + cdef size_t width = image_matrix[0].size() + + cdef numpy.ndarray[numpy.uint8_t, ndim=3] array_data = \ + numpy.zeros((height, width, 4), dtype=numpy.uint8) + + # micro optimization to avoid call to ColorTable.__getitem__() + cdef list m_lookup = main_palette.palette + cdef list p_lookup = player_palette.palette + + cdef uint8_t r + cdef uint8_t g + cdef uint8_t b + cdef uint8_t a + + cdef vector[pixel] current_row + cdef pixel px + cdef pixel_type px_type + cdef int px_index + cdef int px_palette + + cdef size_t x + cdef size_t y + + for y in range(height): + + current_row = image_matrix[y] + + for x in range(width): + px = current_row[x] + px_type = px.type + px_index = px.index + px_palette = px.palette + + if px_type == color_standard: + # look up the palette secition + # palettes have 1024 entries + # divided into 4 sections + palette_section = px_palette % 4 + + # the index has to be adjusted + # to the palette section + index = px_index + 256 * palette_section + + # look up the color index in the + # main graphics table + r, g, b, alpha = m_lookup[index] + + # TODO: alpha values are unused + # in 0x0C and 0x0B version of SMPs + alpha = 255 + + elif px_type == color_transparent: + r, g, b, alpha = 0, 0, 0, 0 + + elif px_type == color_shadow: + r, g, b, alpha = 0, 0, 0, px_index + + else: + if px_type == color_player: + alpha = 255 + + elif px_type == color_outline: + alpha = 254 + + else: + raise ValueError("unknown pixel type: %d" % px_type) + + # get rgb base color from the color table + # store it the preview player color + # in the table: [16*player, 16*player+7] + r, g, b = p_lookup[px_index] + + # array_data[y, x] = (r, g, b, alpha) + array_data[y, x, 0] = r + array_data[y, x, 1] = g + array_data[y, x, 2] = b + array_data[y, x, 3] = alpha + + return array_data + +cdef (uint8_t,uint8_t) get_palette_info(pixel image_pixel): + """ + returns a 2-tuple that contains the palette number of the pixel as + the first value and the palette section of the pixel as the + second value. + """ + return image_pixel.palette >> 2, image_pixel.palette & 0x03 + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef numpy.ndarray determine_damage_matrix(vector[vector[pixel]] &image_matrix): + """ + converts a palette index image matrix to an alpha matrix. + + TODO: figure out how this works exactly + """ + + cdef size_t height = image_matrix.size() + cdef size_t width = image_matrix[0].size() + + cdef numpy.ndarray[numpy.uint8_t, ndim=3] array_data = \ + numpy.zeros((height, width, 4), dtype=numpy.uint8) + + + cdef uint8_t r + cdef uint8_t g + cdef uint8_t b + cdef uint8_t a + + cdef vector[pixel] current_row + cdef pixel px + + cdef size_t x + cdef size_t y + + for y in range(height): + + current_row = image_matrix[y] + + for x in range(width): + px = current_row[x] + px_u1 = px.unknown1 + px_u2 = px.unknown2 + + # TODO: Correct the darkness here + px_mask = ((px_u2 << 2) | px_u1) + + r, g, b, alpha = 0, 0, 0, px_mask + + # array_data[y, x] = (r, g, b, alpha) + array_data[y, x, 0] = r + array_data[y, x, 1] = g + array_data[y, x, 2] = b + array_data[y, x, 3] = alpha + + return array_data diff --git a/openage/convert/texture.py b/openage/convert/texture.py index 98330aa6d1..5ccbe10084 100644 --- a/openage/convert/texture.py +++ b/openage/convert/texture.py @@ -1,4 +1,4 @@ -# Copyright 2014-2018 the openage authors. See copying.md for legal info. +# Copyright 2014-2019 the openage authors. See copying.md for legal info. """ Routines for texture generation etc """ @@ -100,21 +100,33 @@ class Texture(exportable.Exportable): # player-specific colors will be in color blue, but with an alpha of 254 player_id = 1 - def __init__(self, input_data, palette=None, custom_cutter=None): + def __init__(self, input_data, main_palette=None, + player_palette=None, custom_cutter=None): super().__init__() spam("creating Texture from %s", repr(input_data)) from .slp import SLP + from .smp import SMP if isinstance(input_data, SLP): - if palette is None: - raise Exception("palette needed for SLP -> texture generation") frames = [] - for frame in input_data.frames: + for frame in input_data.main_frames: for subtex in self._slp_to_subtextures(frame, - palette, + main_palette, + player_palette, + custom_cutter): + frames.append(subtex) + + elif isinstance(input_data, SMP): + + frames = [] + + for frame in input_data.main_frames: + for subtex in self._smp_to_subtextures(frame, + main_palette, + player_palette, custom_cutter): frames.append(subtex) @@ -134,16 +146,36 @@ def __init__(self, input_data, palette=None, custom_cutter=None): self.image_data, (self.width, self.height), self.image_metadata\ = merge_frames(frames) - def _slp_to_subtextures(self, frame, palette=None, custom_cutter=None): + def _slp_to_subtextures(self, frame, main_palette, player_palette=None, + custom_cutter=None): + """ + convert slp to subtexture or subtextures, using a palette. + """ + # TODO this needs some _serious_ performance work + # (at least a 10x improvement, 50x would be better). + # ideas: remove PIL and use libpng via CPPInterface + subtex = TextureImage( + frame.get_picture_data(main_palette, player_palette, + self.player_id), + hotspot=frame.get_hotspot() + ) + + if custom_cutter: + # this may cut the texture into some parts + return custom_cutter.cut(subtex) + else: + return [subtex] + + def _smp_to_subtextures(self, frame, main_palette, player_palette=None, + custom_cutter=None): """ - convert slp to subtexture or subtextures, use a palette. + convert smp to subtexture or subtextures, using a palette. """ # TODO this needs some _serious_ performance work # (at least a 10x improvement, 50x would be better). - # ideas: remove PIL and use libpng via CPPInterface, - # cythonize parts of SLP.py + # ideas: remove PIL and use libpng via CPPInterface subtex = TextureImage( - frame.get_picture_data(palette, self.player_id), + frame.get_picture_data(main_palette, player_palette), hotspot=frame.get_hotspot() ) @@ -162,7 +194,8 @@ def save(self, targetdir, filename, meta_formats=None): if not isinstance(targetdir, Path): raise ValueError("util.fslike Path expected as targetdir") if not isinstance(filename, str): - raise ValueError("str expected as filename, not %s" % type(filename)) + raise ValueError("str expected as filename, not %s" % + type(filename)) basename, ext = os.path.splitext(filename)