动机

最近在改善wangqr迁移的CMake 版本Aegisub构建系统。Aegisub 最开始的资源构建是借助一个脚本将二进制的资源文件打包成这样的代码:

#include "libresrc.h"
const unsigned char splash[] = {137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,1,164,0,0,0,240,8,2,0,0,0,192,70,135,55,0.....................................};

随后直接在代码中引用splash这样的符号。我觉得这样的方式不太neat,并且编译时需要借助lua,因此对于Windows系统我将这套资源嵌入的机制改成了基于Windows的Resource Compiler。并且我需要将这个机制做到CMake中。

一些基础介绍

下列文段摘自MSDN:

A resource is binary data that you can add to the executable file of a Windows-based application. A resource can be either standard or defined. The data in a standard resource describes an icon, cursor, menu, dialog box, bitmap, enhanced metafile, font, accelerator table, message-table entry, string-table entry, or version information. An application-defined resource, also called a custom resource, contains any data required by a specific application.

正如其所说,通过Windows的资源机制才是嵌入二进制数据的正确方法。这有些类似于macOS上的Bundle,而Linux上暂时没有发现类似的机制(然而我认为此种情况仍不应该直接就把二进制数据怼进elf里面,或许直接让文件散出来更好)。

.rc文件

在MSVC工具链中,为了嵌入资源,我们需要将资源脚本文件(.rc)编译为资源文件(.res)并在链接.exe或.dll时提供。这些资源将会被链接进.exe的.rsrc段中,并可在运行时获取。

Aegisub的资源主要由图片(.png)和默认配置文件(.json)组成。为了方便,在.rc文件中我们统一将其视为二进制文件(图片涉及到编码问题,且没有那么容易取出,因为我们不是GDI人,配置文件作为字符串涉及到本地化问题)。

rc里面为了定义一个资源,需要添加许多这样的语句:

resource_id type path

而后编译并链接res文件,即可在程序中访问资源。

关于资源ID与resource.h的问题

在某些教程甚至MSDN中,我注意到他们采用了这样的方式定义资源:

首先创建一个resource.h,并加入这样的语句:

IDC_MYAPP_POINTER     101

然后在.rc文件中这样写

#include "resource.h"
IDC_MYAPP_POINTER CURSOR f.cur

这样你就可以在代码中这样做

cursor = LoadCursor(hInstance, MAKEINTRESOURCE(IDC_MYAPP_POINTER));

这样实际上是以数字作为资源的ID。然而实际上,除了字符串资源,其他的资源均可使用字符串作为ID,且resource.h也不是必须的。这只是一个VS的习惯。因此我们只需生成使用字符串作为ID的rc文件即可。

CMake的操作

首先我们收集资源目录下所有的资源文件。

file(GLOB_RECURSE CONFIGURE_DEPENDS AEGISUB_BITMAPS resources/*.png)
file(GLOB AEGISUB_CONFIGS CONFIGURE_DEPENDS resources/configs/*.json)

这里为了方便我们使用了file(GLOB_RECURSE) 的方法。许多人指出以这种方法收集文件是不对的,而应该直接将所有列举在CMake文件中。这里暂时先不考虑这么多。

随后开始生成需要的.rc文件的内容:

foreach(BITMAP_FILENAME IN LISTS AEGISUB_BITMAPS)
  get_filename_component(BITMAP_NAME ${BITMAP_FILENAME} NAME_WE)
  string(APPEND AEGISUB_RES_WIN_RC_ENTRIES "bitmap_${BITMAP_NAME} RCDATA ${BITMAP_FILENAME}\n")
endforeach()

foreach(CONFIG_FILENAME IN LISTS AEGISUB_CONFIGS)
  get_filename_component(CONFIG_NAME ${CONFIG_FILENAME} NAME_WE)
  string(APPEND AEGISUB_RES_WIN_RC_ENTRIES "config_${CONFIG_NAME} RCDATA ${CONFIG_FILENAME}\n")
endforeach()

此时我们需要特别注意:如果直接将AEGISUB_RES_WIN_RC_ENTRIES的内容通过file(WRITE)等写入文件,会导致每次执行CMake的时候都重新生成这个文件(虽然内容没有变),并导致RC重新编译一次。

因此,我们应该使用configure_file()而非file(WRITE)来解决这个问题。而为了使用这个命令,我们需要先创建一个resources.rc.in文件,其中只有一行内容

@AEGISUB_RES_WIN_RC_ENTRIES@

随后直接执行:

configure_file(resources/resource.rc.in src/generated/windows/resource.rc)

即可自动将AEGISUB_RES_WIN_RC_ENTRIES变量的内容写入,且并不会在文件内容相同时碰文件。

最终,我们需要在编译的时候加入RC文件作为源文件,CMake和MSVC会自动编译出res并链接。

target_sources(Aegisub PRIVATE
		src/generated/windows/resource.rc)

在程序中获取资源

前面提到我们将所有的资源文件视为二进制文件(对应的资源类型为RCDATA)。因此根据MSDN的介绍,我们需要FindResourceLoadResourceLockResource来加载。完整代码如下:

std::unique_ptr<char> LoadBinary(const std::string& name, OUT size_t& outLength)
{
	auto nameStr = charset::ConvertW(name); // std::string -> std::wstring
	HRSRC resSrc = FindResource(NULL, nameStr.c_str(), RT_RCDATA);
	if (resSrc == NULL)
		throw BadResourceException("Failed to find resource " + name + ":" + util::ErrorString(GetLastError()));
	HGLOBAL res = LoadResource(NULL, resSrc);
	if (res == NULL)
		throw BadResourceException("Failed to load resource " + name + ":" + util::ErrorString(GetLastError()));

	DWORD size = SizeofResource(NULL, resSrc);
	if (size <= 0)
		throw BadResourceException("Failed to load resource " + name + ", the reported size was 0. " + util::ErrorString(GetLastError()));
	auto buf = std::unique_ptr<char>(new char[size]);
	LPVOID originBuf = LockResource(res);
	if (originBuf == NULL)
		throw BadResourceException("Failed to load resource " + name + ", failed to get resource buffer. " + util::ErrorString(GetLastError()));
	CopyMemory(buf.get(), originBuf, size);
	outLength = size;

	return buf;
}

顺道一提

我们可以先尝试以零散文件的方式加载资源,如果没有对应的文件,再去寻找嵌入在exe中的资源。虽然我们不是在写galgame,因此没有什么必要,但毕竟Linux是使用的此种方法,所以也无所谓。


不想被自己的惰性打败。