EotB – Grafikformat

I den tidigare artikeln om fil-formatet .pak så lyckades vi hitta filerna. Nu hade det varit trevligt om vi också kunde ladda grafiken från spelet. Om vi kikar på filen EOBDATA2.PAK så innehåller den bara två filer:

Här fick vi justera vår kod efter kommentaren i förra artikeln om att filformatet PAK i version 1 inte var så noga med vissa saker. Sista offset i header’n på PAK-filen kan i version 1 innehålla nonsens. Därför måste storleken på sista filen beräknas utefter offset i PAK-filen till hela PAK-filens slut. Tydligen så innehåller Eye of the Beholder EXE-filen hårdkodade offsets till PAK-filerna vid laddning.

Hursomhelst, nu har vi filerna DOOR.EGA och EOBPAL.COL. EGA-filer måste vara grafik-filer. EGA står för Enhanced Graphics Adapter och var ett nytt system för grafik 1984. Det erbjöd upplösningar upp till 640×350 pixlar med hela 16 färger!

Men 16 färger, vilka färger använde man då? Vi är rätt vana vid att ha i princip obegränsat med färger i våra digitala bilder då varje enskild pixel idag är minst 24 bitar, 8 bitar röd, 8 bitar grön och 8 bitar blå. När vi arbetar med grafik ovanpå annan grafik så lägger vi till 8 bitar alpha (genomskinlighet).

Tillbaka till problemet med begränsat antal färger. För att tala om vilka färger som en bild använder så använde man en palett av utvalda färger, d.v.s. att varje pixel kan ha ett värde mellan t.ex. 0 och 15 (16 olika färger) och det i sin tur måste kollas upp mot en tabell av förinställda färger, en palett.

Detta leder oss till att misstänka att filen EOBPAL.COL innehåller just färginformation om vilka färger som ska användas. Detta är också troligt då filen är så pass liten, 768 bytes.

I många gamla grafikformat så lagrade man såklart palett-informationen i samma bild, men för att spara plats så kan man också tänka sig att spara denna information separat och använda samma palett till flera bilder.

Westwood CPS

Efter lite testande med data i filen DOOR.EGA så är det klart att formatet innehåller någon slags kompression, alltså att man använder en algoritm för att krympa filens storlek. Nu finns det två alternativ:

  • Använda en disassembler på EXE-filerna och läsa assembler (förskönad maskinkod) för att lära oss hur det fungerar.
  • Googla

Så efter lite googling så hittar vi en bra källa: http://www.shikadi.net/moddingwiki/Westwood_CPS_Format

Vi lär oss snabbt att filerna troligen är i Westwood CPS-format. Det betyder att grafiken är låst till upplösningen 320×200 pixlar och använder 8-bitar (256) färger på PC-versionen.

Filhuvudet (eller ”header” på engelska) beskriver hur vi ska läsa filformatet. Enligt dokumentationen börjar den med:

Datatyp Namn Kommentar
UInt16 Filstorlek Filstorleken på filen. För metod 0 och 4 så beräknas endast efterföljande bytes. Värdet här blir då två bytes mindre än den riktiga filstorleken.
UInt16 Kompression Vilket typ av kompression som använts. Ett värde mellan 0-4.
UInt32 Okomprimerad storlek Storlek i bytes på en uppackade bilden.
UInt16 Palettstorlek Storlek på efterföljande palett. Kan också vara 0 eller 768 för en 256-färgers RGB palett.

Alla datatyper (och generellt sett all data) är lagrad som ”little endian”, den lägsta byten kommer först och den största sist.

Åter till filen DOOR.EGA. Hur ser den ut i HEX-editorn?

0x81 0x35 ger värdet 13697 vilket är två bytes mindre än filstorleken på 13699 bytes. Nästa två bytes ger värdet 4, vilket är metoden som använts för komprimeringen. Sedan följer 0x00 0xFA 0x00 0x00 som omräknat blir 64000, vilket råkar vara 320×200 som då passar väl in på bilder där varje pixel beskrivs med en byte. Sist anges också paletten till 0.

Frågan blir då hur fungerar kompressionsmetod nummer 4?

Kompressionsmetod 4, även kallad Westwood LCW är ett svar på LZW-algoritmen som används i bl.a GIF-formatet. Denna algoritm blev patenterad och dyr för företag att använda. Därför hittade man på en egen algoritm. Denna algoritm är även känd som ”Format 80” då all komprimerad data med denna metod slutar på bytevärde 0x80.

Westwood LCW

Formatet bygger på 5 olika ”kommandon”. Varje kommando består av 1-5 bytes för att beskriva vad som ska ske. Här måste vi gå in på bit-nivå i varje byte för att se vilket kommando som ska utföras. Prefixet 0b betyder, även i C#, början på ett tal angivet i binär form. 8 bitar är en byte som du säkert vet. Skulle byten innehålla bitar markerade som x så är dessa varierande information som hör till kommandot.

Kommandona som listas kommer att jobba med två buffrar av bytes. En destination som kommer att vara så stor som den okomprimerade bilden som angavs i filhuvudet. Sedan en källa som kommer att vara den komprimerade data som innehåller kommandon. Vi kommer också att behöva hålla redan på en position som anger hur långt vi hunnit avkoda data och lagt i destination. Position är alltså där vi ska lägga nästa avkodade byte. Position ökar alltså i samma takt som vi lägger in avkodade bytes i denna buffert.

Startbyte

Om avkodningen börjar med en byte med värdet 0 så betyder det att kommando 3 och 5 är relativa. Mer om detta senare.

Kommando 1 – Kort kopiering

0b 10xxxxxx

Om en byte börjar med bitarna 10 så är det alltså kommando 1. De följande 6 bitarna (x) anger ett antal. Detta betyder; kopiera följande antal bytes från källa till destination.

Om antal skulle vara 0, vilket ger byten värdet 0x80, så betyder det att vi nått slutet på komprimerad data. Det som eventuellt sedan följer är en palett med färger.

Kommando 2 – Existerande block, relativ kopiering

0b 0xxxxxxx  xxxxxxxx

Första 3 bitarna + 3 = antal. Följande 12 bitar anger värde, pos, för att beräkna relativ position.
Kopiera antal bytes från position – pos i destination till position.

Genom att upprepa data som redan kodats av så kan man återanvända den. Vänder man på resonemanget så kan man säga att en bild som innehåller många liknande partier pixlar behöver bara kodas ned en gång för att sedan via kommando 2 kunna upprepas vid behov.

Kommando 3 – Existerande block, medium längd

0b 11xxxxxx xxxxxxxx xxxxxxxx

Första 6 bitarna + 3 = antal. Följande två bytes anger pos. Beroende på den startbyte som nämndes tidigare så är denna pos antingen relativ eller inte. Så detta kommando betyder antingen;

Kopiera antal bytes från position – pos i destination till position. Eller;

Kopiera antal bytes från pos i destination till position.

Kommando 4 – Upprepa värde

0b 11111110 xxxxxxxx xxxxxxxx xxxxxxxx

Kommando 4 börjar som synes med bytevärde 254. Sedan följer 2 bytes som anger antal. Sista byte anger det värde som ska upprepas. Kommandot blir således;

Kopiera värde antal gånger till destination.

Kommando 5 – Existerande block, lång kopiering

0b 11111111 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx

Kommando 5 börjar som synes med bytevärde 255. Därpå följer två bytes som anger antal. Sista två bytes anger pos. Detta kommando är väldigt likt kommando 3. Beroende på den startbyte som nämndes tidigare så är denna pos antingen relativ eller inte. Så detta kommando betyder antingen;

Kopiera antal bytes från position – pos i destination till position. Eller;

Kopiera antal bytes från pos i destination till position.

Avkodning LCW

Nu är det dags att koda något som kan hantera en buffert av LCW-kodade kommandot och i slutändan få fram en avkodad bild. Kodfilen CpsFile.cs gör ett försök till att koda av LCW.

CpsFile.cs
using System.Diagnostics;

namespace PakExtract
{
    enum CompressionType
    {
        Uncompressed = 0,
        WestwoodLZW12 = 1,
        WestwoodLZW14 = 2,
        WestwoodRLE = 3,
        WestwoodLCW = 4
    }
    class CpsFile
    {
        private byte[] _data;

        public int FileSize { get; private set; }
        public CompressionType CompressionType { get; private set; }
        public int UncompressedSize { get; set; }
        public int PaletteSize { get; set; }

        public bool IsRelative => _data[10] == 0;
        public byte[] RawData { get; set; }

        private CpsFile(byte[] data)
        {
            _data = data;
            int index = 0;

            FileSize = _data[index++] + (_data[index++] << 8);
            CompressionType = (CompressionType)(_data[index++] + (_data[index++] << 8));
            UncompressedSize = _data[index++] + (_data[index++] << 8) + (_data[index++] << 16) + (_data[index++] << 24);
            PaletteSize = _data[index++] + (_data[index++] << 8);
            RawData = new byte[UncompressedSize];

            if (IsRelative)
                index++;

            int destIndex = 0;

            while (index < _data.Length)
            {
                switch (_data[index++])
                {
                    // Command 1: Short copy
                    case byte c when ((c >> 6) == 2):
                        int count = c & 0x3F;
                        for (int i = 0; i < count; i++)
                            RawData[destIndex++] = _data[index++];

                        Debug.WriteLine($"Command 1 - count {count}");
                        break;

                    // Command 2: Existing block relative copy
                    case byte c when ((c >> 7) == 0):
                        count = (c >> 4) + 3;
                        int pos = _data[index++] + ((c & 0xF) << 8);
                        int destPos = destIndex - pos;

                        for (int i = 0; i < count; i++)
                            RawData[destIndex++] = RawData[destPos++];

                        Debug.WriteLine($"Command 2 - count {count} pos {pos}");
                        break;

                    // Command 5: Existing block long copy
                    case byte c when (c == 255):
                        count = _data[index++] + (_data[index++] << 8);
                        pos = _data[index++] + (_data[index++] << 8);
                        destPos = (IsRelative) ? destIndex - pos : pos;

                        for (int i = 0; i < count; i++)
                            RawData[destIndex++] = RawData[destPos++];

                        Debug.WriteLine($"Command 5 - count {count} pos {pos}");
                        break;

                    // Command 4: Repeat value
                    case byte c when (c == 254):
                        count = _data[index++] + (_data[index++] << 8);
                        byte val = _data[index++];

                        for (int i = 0; i < count; i++)
                            RawData[destIndex++] = val;

                        Debug.WriteLine($"Command 4 - count {count} value x{val:X2}");
                        break;

                    // Command 3: Existing block medium-length copy
                    case byte c when ((c >> 6) == 3):
                        count = (c & 0x3F) + 3;
                        pos = _data[index++] +(_data[index++] << 8);
                        destPos = (IsRelative) ? destIndex - pos : pos;

                        for (int i = 0; i < count; i++)
                            RawData[destIndex++] = RawData[destPos++];

                        Debug.WriteLine($"Command 3 - count {count} pos {pos}");
                        break;
                }
            }
        }
        public static CpsFile FromData(byte[] data)
        {
            return new CpsFile(data);
        }
    }
}

DOOR.EGA

Vi lyckas faktiskt koda av bilden DOOR.EGA. Problemet är att vi inte vet vilka färger som ska användas. Troligtvis så ligger paletterna sparade som separata .COL filer som vi redan skymtat. Tills vidare kan vi göra ett test bara genom att slumpa fram så många olika färger som vi kan tänkas behöva. Just i detta exempel så räcker det med 16 färger. Vi gör ett test!

Program.cs
using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;

namespace PakExtract
{
    class Program
    {
        static void Main(string[] args)
        {
            var pak = PakFile.FromFile("EOBDATA2.PAK");

            foreach(var file in pak.Files)
                Console.WriteLine($"{file.FileName,-12}\toffset: {file.OffsetInPak}\tsize: {file.FileSize} bytes");
            Console.WriteLine($"{pak.Files.Count} files\n");

            File.WriteAllBytes(pak.Files[0].FileName, pak.Files[0].RawData);
            var cps = CpsFile.FromData(pak.Files[0].RawData);
            SaveAsPng("test.png", cps.RawData);
            File.WriteAllBytes("test.raw", cps.RawData);

            Console.WriteLine("Filesize: \t\t" + cps.FileSize);
            Console.WriteLine("CompressionType: \t" + cps.CompressionType);
            Console.WriteLine("UncompressedSize: \t" + cps.UncompressedSize);
            Console.WriteLine("PaletteSize: \t\t" + cps.PaletteSize);
            Console.WriteLine("IsRelative: \t\t" + cps.IsRelative);

            Console.ReadKey();
        }

        public static void SaveAsPng(string filename, byte[] rawData)
        {
            Bitmap bmp = new Bitmap(320, 200);
            var palette = new Color[rawData.Max() + 1];
            Random rnd = new Random();

            for (int i = 0; i < palette.Length; i++)
                palette[i] = Color.FromArgb(rnd.Next(256), rnd.Next(256), rnd.Next(256));

            for (int i = 0; i < rawData.Length; i++)
                bmp.SetPixel(i % 320, i / 320, palette[rawData[i]]);

            bmp.Save(filename, ImageFormat.Png);
        }
    }
}

Avslutning

Genom att modifiera vårt huvudprogram lite med metoden SaveAsPng så lyckas vi få ett synbart resultat!

Detta känns lite som arkeologisk utgrävning. Äntligen, efter många år, kan vi skåda delar av grafiken! Bifogar koden i sin helhet nedan.

I nästa artikel så får vi försöka få ordning på färgerna och hitta rätt palett.

Nästa artikel

EotB - Färger

Hur fungerar färgsystemet? Vilka bilder använder vilken palett?

Scroll to top