Содержание
Введение
Возможная структура файла
"Компилятор"
"Загрузчик"
Хранение ресурса в рекции PE файла
Играем MP3 из
EXE
Введение
В предыдущей статье я
рассказал, как можно считывать растры напрямую из файла ( надеюсь информация
оказалась для Вас интересная). Теперь поговорим о том, как создать собственный,
удобный для нас, формат хранения графической информации. Рассматриваемый подход
пригодится не только для хранения графики, но и для совершенно различных
бинарных данных. Это могут быть и музыкальные треки в популярном формате MP3,
видео фрагменты, текстовые данные - в общем, любые данные Вашего приложения.
В конце предыдущей статьи, я изложил краткий алгоритм для создания таких
файлов, теперь я попытаюсь последовательно описать все его тонкости.
Возможная структура файла
Для начала набросаем приблизительную структуру нашего будущего формата
данных. Для примера, я создам файл для хранения обычных растров в не
компрессированном виде.
|
FileHeader TGameRusourceHeader
|
| Name |
String[32] |
Название файла |
| Version |
Integer |
Версия |
| ImageCount |
Integer |
Количество изображений | |
|
ResourceTable TGameResourceTable |
| ResName |
String[32] |
Название ресурса |
| Offset |
Integer |
Смещение от начала файла | |
|
ResourceData |
Графические данные |
Так выгладит заголовок файла. Количество элементов ResourceTable
соответствует количеству хранимых изображений. Сразу за последней записью в
ResourceTable начинаются данные изображений.
Для создания подобного форматы нам потребуется написать небольшой
"компилятор" или скорее "сшиватель" ресурсного файла. В его задачи будет входить
создание файла с описанным выше форматом из обычных BMP файлов. Сразу стоит
оговорится, что создание компилятора или упаковщика (это кому как нравится)
самый трудоемкий процесс. По этому я решил особо не выпендриваться и написать
его с использованием VCL, т.к. во первых это сугубо рабочая утилита, кроме нас
её никто видеть не будет, а во вторых тут совсем не принципиальна скорость
работы - один раз собрали ресурс и забыли про него. Хотя для больших работ,
когда суммарный объем обрабатываемых файлов переваливает за сотни МБ, стоит
подумать и о оптимизации.
Компилятор
Начнем создание компилятора с заготовки необходимых структур:
TGameRusourceHeader= packed record Name :
string[32]; Version : Integer; ImageCount : Integer;
end;
TGameResourceTable = packed record ResName
: string[32]; Offset : Integer; end;
|
Надеюсь тут все понятно. Есть только две небольшие тонкости. Первая нельзя
использовать просто String - только фиксированную длину строки! Иначе
SizeOf(TGameRusourceHeader) выдаст совершенно не верный результат. Вторая
тонкость по организации проекта. Т.к. нам нужно написать и компилятор ресурсов и
загрузчик, лучше вынести описание заголовков в отдельный модуль.
Как уже писалось выше, я буду использовать VCL компоненты и стандартный набор
классов. Это сильно сократит исходный код, да и сделает его понятным. Алгоритм
процедуры "сшивания" ресурсов следующий. Процедуре будем предавать список
файлов, и название выходного файла.
В переменные заведем три потока и другие необходимые переменные:
procedure
TDIBCompilerForm.CompileResource(const SourceFileList:TStringList;
const OutFileName: string); var // Поток для записи
окончательного файла OutStream : TStream; // Поток для хранения
таблицы смещений TableStream : TStream; // Поток для хранения
данных ResourceStream : TStream; // Кол-во изображений ImgCount
: Integer; // Смещение от начала файла текущее растра Offset :
Integer; // Размер заголовка и размер всей таблицы смещений
HeaderSize : Integer; AllTableSize : Integer; // Экземпляр для
записи в таблицу смещений GameResourceTable : TGameResourceTable; ...
|
Для начала работы процедуры проинициализируем все объекты и переменные:
... OutStream:=TFileStream.Create(OutFileName, fmCreate);
TableStream:=TMemoryStream.Create; ResourceStream:=TMemoryStream.Create;
ImgCount:=1; HeaderSize:=SizeOf(TGameRusourceHeader);
AllTableSize:=SizeOf(TGameResourceTable)*SourceFileList.Count;
Offset:=HeaderSize+AllTableSize; ... |
Самой сложной частью процедуры я считаю рассчет смещений, все остальное
достаточно прозрачно:
for I:=0 to SourceFileList.Count-1 do begin
Bitmap.LoadFromFile(SourceFileList[I]);
Bitmap.SaveToStream(ResourceStream);
GameResourceTable.ResName:=ExtractFileName(SourceFileList[I]);
GameResourceTable.Offset:=Offset; ...
TableStream.WriteBuffer(GameResourceTable, SizeOf(TGameResourceTable));
Offset:=HeaderSize+AllTableSize+ResourceStream.Position; Inc(ImgCount);
end; |
... на последок копирование данных в выходной поток и очистка занятых
ресурсов.
// Перемещаемся на начало
данных ResourceStream.Seek(0, soFromBeginning);
TableStream.Seek(0, soFromBeginning); // Устанавливаем количесво
добавляемых битмапов в заголовок GameRusourceHeader.ImageCount:=ImgCount;
// Запись выходного файла OutStream.WriteBuffer(GameRusourceHeader,
SizeOf(GameRusourceHeader)); OutStream.CopyFrom(TableStream,
TableStream.Size); OutStream.CopyFrom(ResourceStream, ResourceStream.Size);
// Блок финализации ResourceStream.Free; TableStream.Free;
OutStream.Free; Bitmap.Free; |
Вот собственно и всё. После успешного завершения процедуры у Вас получится
файл с описываемой структурой. При работе процедуры, создается файл с
расширением OutFileName.text, куда записывается вся информация о размерах
структур, смещениях и т.д. Смещения записываются как в обычном десятичном виде,
так и в шестнадцатеричной форме. Последняя форма записи очень помогает при
анализе полученного файла в любом HEX редакторе (WinHex, Hview и т.д.).
Не возможно не упомянуть об одной особенности - уменьшении размера
полученного файла. Поясню более подробно. Для примера я скомпилировал набор из
313 BMP файлов различного размера. Суммарный объем файлов 2, 359 Кб, после
сборки получился файл размером 2,428 Кб - оно и понятно, мы записываем лишнею
информацию. После сжатия архиватором ZIP отдельных BMP файлов получился архив
размером 697 Кб, а вот при сжатии выходного файла - 640 Кб. Выигрыш очевиден,
причем он растет с увеличением числа хранимых битмапов и уменьшения их размера.
При сборке ~500 картинок размером 16x16 выигрыш получается более чем в два раза.
Необходимо помнить, что для приложений распространяемых по сети размер
дистрибутива до сих пор критичен. И если Ваша игра или утилитка "весит" в 5-6
раз меньше, чем аналоги, шанс что пользователь выберет именно её повышается не
однократно.
"Загрузчик"
Надеюсь с созданием формата данных для хранения информации Вы разобрались,
теперь осталась самая легкая часть - написать загрузчик графики из нашего
формата. Как и с компилятором ресурсов, я напишу программу используя VCL (ну не
знает наш народ API, а при виде одного dpr файла впадает в ступор - "А где же
форма ? Где мой любимы TButton.OnClick ???"=) ).
Всё действо будет происходить в одной процедуре. Параметр ResourceFileName -
путь и имя к файлу, а ImageCount - номер изображения для загрузки (нумерация
начинается с 1). В процедуре нам понадобится всего четыре переменные:
procedure TLoaderForm.LoadBitmap(const
ResourceFileName: string; const ImageCount:integer);
var FileStream : TStream; Bitmap :
TBitmap; GameRusourceHeader: TGameRusourceHeader; GameResourceTable :
TGameResourceTable; |
Сама процедура чрезвычайно проста, обратите внимание только на получение
смещение для загрузки файла:
// Инициализация Bitmap:=TBitmap.Create;
FileStream:=TFileStream.Create(ResourceFileName, fmOpenRead); // Чтение
заголовка FileStream.ReadBuffer(GameRusourceHeader,
SizeOf(GameRusourceHeader)); // Перемещение на начало таблицы смещений
ресурса FileStream.Seek((ImageCount-1)*SizeOf(GameResourceTable),
soFromCurrent); // Чтение таблицы
ресурса FileStream.ReadBuffer(GameResourceTable, SizeOf(GameResourceTable));
// Перемещение к началу данных затребованного
ресурса FileStream.Seek(GameResourceTable.Offset, soFromBeginning); //
Непосредственная загрузка Bitmap.LoadFromStream(FileStream); ... //
Убираем за собой Bitmap.Free; FileStream.Free;
|
Процедура работает практически мгновенно, я имею в виду перемещение по файлу,
а за скорость загрузки самого изображения ответственность ложится на метод
LoadFromStream. Возможно, я приложу к статье пример, показывающий, как можно
избежать использования TBitmap и загружать ресурс самостоятельно. Хотя это
совсем не сложно сделать объединив материал предыдущей статьи и приведенный выше
код.
Остановимся на возможности оптимизации. Итак:
- Первая возможность оптимизации заключается в кэшировании таблицы смещений.
Такая структура занимает не очень много места в памяти, но позволит совершить
мгновенный переход не только по индексу изображения (что не удобно), но и по его
имени, если оно конечно хранится.
- Вторая - использование собственного загрузчика изображений. Это позволит
выиграть очень много времени, особенно если использовать оптимизированные
процедуры для загрузки 8 и 24-х битных изображений.
Защищенность ресурса от просмотра ниже средней - простой человек не
посмотрит, а для программиста средней руки разобрать такой формат раз плюнуть.
Но захочет ли он с этим возится?
Хранение ресурса в секции PE файла
Теперь настала пора разобраться, как поместить созданный ресурсный файл в
исполняемое приложение, т.е. просто "вшить" его в exe файл. Проблемы с
соединением не возникнет, а вот как с обращением к ресурсу стоит попотеть.
Как известно в PE файле есть различные секции, при этом ничего не мешает Вам
писать в секцию импорта свои данные, но есть специальная секция RCData. Она то и
предназначенная для записи собственных данных приложения, т.е. в неё можно
пихать всё, что угодно (в смысле бинарных данных :)), в разумных пределах
конечно.
Для примера я создам файл out (с помощью описанного ранее компилятора)
содержащий четыре 24-х битных растра. Количество не имеет значение, а 24-битные
растры я буду помещать по тому, что их проще загружать.
Итак создаем RC файл, например Resource.rc со следующим содержанием:
... GAMEDATA RCDATA out ... |
GAMEDATA - название ресурса, т.е. его идентификатор;
RCDATA - название
секции;
Out - имя файла, может быть только название файла, а может и целый
путь.
Создаем ресурсный файл вызывая компилятор ресурсов:
...ааа вот зачем программистам в Windows нужна командная строка! После
успешного завершения, мы получим бинарный ресурсный файл Resource.RES, его можно
подключать к проекту директивой компилятора:
Теперь при сборке проекта в получившемся exe файле появится секция RCDATA и в
ней ресурс с названием GAMEDATA.
Осталось совсем чуть-чуть - написать процедуру загрузки. Она будет достаточна
сложна, и если Вы не сильны в таких понятиях как указатели, дескрипторы, плоская
модель памяти … смело пропускайте данный материал. Бездумное копирование кода до
добра не доводит :)
Начнем как всегда с расшифровки переменных:
procedure TDibFromResForm.LoadBitmap(const
ImageCount:Integer); var // Указатели на структуры заголовка
файла GameRusourceHeader : PGameRusourceHeader; GameResourceTable :
PGameResourceTable; // Указатели на структуры растра BitmapFileHeader
: PBITMAPFILEHEADER; BitmapInfoHeader : PBITMAPINFOHEADER;
BitmapInfo : TBitMapInfo; BitmapBits : Pointer; // Handle заголовок
данных блока ресурса RSRC : HRSRC; // Handle на область памяти
ресурса RES : THandle; // Указатель на область памяти загруженного
ресурса P : Pointer; // Переменная для хранит начально адреса данных
ресурса StartAddr : Integer; // Счетчик смещения адресов I :
Integer; // Переменная для хранения полученного битмапа Bitmap :
HBITMAP; // Контекст устройства DC : HDC; ...
|
Ну как, не испугались ? Дальше интереснее будет:
RSRC:=FindResource(HInstance, 'GAMEDATA', RT_RCDATA); if
RSRC = 0 then begin MessageBox(Handle,'Ресурс не
найден.', MessageTitle, MB_ICONERROR+MB_OK); Exit; end;
RES:=LoadResource(HInstance, RSRC);
P:=LockResource(RES); |
Находим ресурс функцией FindResource по его идентификатору GAMEDATA в секции
RT_RCDATA. Загружаем ресурс функцией LoadResource, блокируем доступ к нему и
получаем область занимаемой им памяти в указатель P.
StartAddr:=Integer(P); I:=StartAddr;
GameRusourceHeader:=Ptr(I); Inc(I, SizeOf(TGameRusourceHeader));
Inc(I, SizeOf(TGameResourceTable)*(ImageCount-1));
GameResourceTable:=Ptr(I); I:=StartAddr+GameResourceTable.Offset;
BitmapFileHeader:=Ptr(I); Inc(I, SizeOf(TBitmapFileHeader));
BitmapInfoHeader:=Ptr(I); Inc(I, SizeOf(TBitmapInfoHeader));
GetMem(BitmapBits, BitmapInfoHeader.biSizeImage); BitmapBits:=Ptr(I);
BitmapInfo.bmiHeader:=BitmapInfoHeader^; |
В StartAddr получаем адрес памяти указателя P и устанавливаем счетчик I на
это же значение. Далее все опрерации будем производить только со счётчиком, так
нагляднее. Загружаем заголовок GameRusourceHeader, он находится прамо по адресу
I или StartAddr, т.к. в самом начале блока памяти загруженного ресурса.
Увеличиваем счетчик на размер структуры TGameRusourceHeader. Параметр функции
ImageCount хранит номер растра, который необходимо получить. По этому
высчитываем смещение для требуемой таблицы ресурсов:
SizeOf(TGameResourceTable)*(ImageCount-1). Получаем таблицу смещений. Из неё
можно вытащить смещение требуемого растра: StartAddr+GameResourceTable.Offset.
По этому смещению можно последовательно считать BitmapFileHeader,
BitmapInfoHeader и BitmapBits. Вот собственно и все!
Осталось создать Bitmap и очистить ресурсы:
... Bitmap:=CreateDIBitmap(DC, BitmapInfoHeader^, CBM_INIT,
BitmapBits, BitmapInfo, DIB_RGB_COLORS); ... UnlockResource(RES);
FreeResource(RES); |
Не так все и сложно, хотя я представляю лица ( масли, выражения, жесты ... )
тех, кто сел за Delphi месяц назад :))) На самом деле, достаточно сложный для
понимания материал. Хотя если Вы изучали C или ASM для Вас должно быть всё
тривиально.
Пример приведённый выше далеко не оптимален:
- Во первых, присутствует несколько лишних переменных. Это сделано только для
большей понятности кода.
- В коде всего одна проверка - это очень не правильно :) Нужно сопровождать
каждую операцию проверками на нулевой указатель, правильность размера структуры
и т.д. Самая простая - на существование запрошенного изображения, а то может
получится такая ситуация, что всего в ресурсе 10 растров, а запрашиваем 1001.
Процедура попытается читать данные из совсем "левой" области памяти. Мы ведь
работаем с нетипизированными указателями, напрямую с памятью и ошибки вызовут
крах всего приложения.
Еще хочется остановится на такой проблеме как хранение музыки и
другой мультимедиа информации в exe файле. После прочтения выше изложенного
материала проблем с этим возникнуть не должно. Я попробую изложить несколько
общих принципов, а конкретная реализация зависит от того какие ресурсы Вы хотите
поместить в файл и какие методы воспроизведения будите использовать.
Общий алгоритм такой - помещаем каждый из ресурсов в секцию RCDATA,
присваивая каждому уникальное имя (идентификатор). После этого получаем
указатель на начало блока памяти занимаемого ресурсом и выполняем
воспроизведение с помощью соответствующих функций.
Приведу небольшой пример. Допустим, необходимо подключить к exe файлу
композицию в формате MP3. Для маленьких игр это может быть музыкальный фон,
звуки спец. эффектов и т.д. Файл будет называться sample.mp3
Создадим ресурсный файл MusicRec.RC и в него добавим строчку:
Соберём бинарный файл ресурса командой:
и подключим к нашему приложению скомпилированный ресурсный файл:
После этих операций в нашем exe файле будет присутствовать MP3 фрагмент
sample c идентификатором MUSIC1.
Нам осталось только проиграть данный файл. Что для этого потребуется ?
Конечно проигрыватель. Все конечно же подумали про WinAMP, но это же
проигрыватель внешних файлов, к тому же не интересно привязывать нашу программу
к WinAMP'у.
Среди свободно распространяемых проигрывателей я выбрал библиотеку BASS. Во
первых она позволяет проигрывать не только MP3 файлы, но и файлы трекерных
форматов XM, MOD и т.д., что очень актуально для игр и демонстраций. Ведь эти
файлы занимают очень мало места, а качество музыки на очень приличном уровне.
Еще один большой плюс библиотеки BASS - её бесплатность для не коммерческого
использования. К тому же она распространяется в виде динамической библиотеки с
открытым интерфейсом и очень проста в использовании - я буквально за 10 минут
написал этот пример, при этом ранее библиотеку никогда не видел.
Собственно работа со звуком заключается в 4-х операциях:
Инициализация биьлиотеки.
Загрузка звукового
фрагмента.
Воспроизведение.
Освобождение ресурсов.
Нам интересен кусочек загрузки музыкального фрагмента из памяти, по этому я
рассмотрю только его.
var RSRC : HRSRC; RES : THandle;
P : Pointer; ... RSRC:=FindResource(HInstance, 'MUSIC1',
RT_RCDATA); ... RES:=LoadResource(HInstance, RSRC);
P:=LockResource(RES); Music:=BASS_SampleLoad(TRUE, P, 0, MusicSize, 3,
BASS_SAMPLE_OVER_POS); ... |
В качестве основных параметров, процедуре BASS_SampleLoad передается
указатель P и размер музыкального фрагмента в байтах (размер описывается к
константах). Значение остальных параметров описаны в файле bass.pas или в файле
справки.
Для лучшего осмысления работы библиотеки BASS посмотрите пример BassTest
входящий в комплект поставки.
Вот собственно и всё. В следующий части статьи я поробую рассказать о
хранении компрессованных ресурсов.