ReShade 注入式后处理 · 第 六 篇

Add-on API:事件模型、跨 API 资源交换与两种构建的分野

2026-10-1116,211 字24 张配图本文由@溯光 出品,转载请注明出处。

站点链接:技术博客页 | 公众号:溯光的图形笔记

题图:ReShade 的插件页
题图ReShade 的插件页(本机现开现拍):add-on 列表、插件搜索路径与每个插件的说明——那张事件表在界面上的落点就长这样。@ 本机现开现拍

切口:一个"插件"凭什么不导出函数、也不链接库

好了,前五篇把"它怎么进来""它用什么写""它每帧怎么跑""它怎么盖住六种图形 API"讲完了。到这里会冒出一个很自然的问题:如果我想在运行时插手更深的事,接口长什么样?

这个问题在别的软件里有个常见答案:写插件。而写插件的常见做法是——导出一批函数、链接对方的库、按对方的约定把结构体填好。插件与宿主之间那一套 ABI(application binary interface),是插件作者最先要背下来的东西。

ReShade 的 add-on 却给了一句反常识的说明。官方文档里关于 add-on 的定义是这么开头的(逐字):

A ReShade add-on is a DLL or part of the application that uses the header-only ReShade API to register callbacks for events and do work in those callbacks after they were invoked by ReShade. There are no further requirements, no functions need to be exported and no libraries need to be linked against (although linking against ReShade is supported as well by defining RESHADE_API_LIBRARY before including the headers if so desired).

"no functions need to be exported and no libraries need to be linked against"——一个 add-on 可以什么都不导出、什么都不链接。这句话不是营销措辞,它是这套接口最核心的一个设计后果,也是这一篇的入口。

但"不需要"不等于"没有"。 add-on 与宿主之间当然还是要握手的:它得找到宿主、得把回调交过去、得让宿主在合适的时刻调用它。只不过这套握手被藏进了头文件里——你写代码时看不到它,它却真实存在。

这一篇要回答的,就是这句话背后那三件事,每一件都有可以逐个字数出来的痕迹:

  1. 事件表长什么样——addon_event 这个枚举里到底有多少个事件、它们怎么编号、编号里有没有"历史地层"(第二、三章)。
  2. 握手在哪一层——add-on 不导出函数,那"注册"这件事靠谁完成?宿主与 add-on 之间的边界上到底站着几个符号(第四、五、六章)。
  3. 两种构建的分野——官方下载页并排放着两个安装器,一个签名、一个不签名;它们不是两套代码,而是同一个宏的两个取值。这个开关决定了那张事件表里有多少格子真的能被碰到(第七、八章)。

第四到六章按"回调从注册到被调用"的顺序走:签名怎么保证对、宿主怎么派发、边界上有几个符号。这三章是把"事件驱动"这四个字落到具体代码上的三处。

十一篇各占一层:本篇只占中间那一层
图 6-1十一篇各占一层:本篇只占中间那一层@ 本系列绘制

这一篇的地图就在这张边界图里:十一篇各占一层,本篇占的是"接口面"那一层。左边四篇讲"怎么进来、用什么写、每帧怎么跑、怎么统一六种 API",本篇讲"想插手更深的事,这套接口交出了什么",右边几篇才轮到深度工程、效果生态与自跑。

读这张图的正确姿势是从下往上看:先看最底下那句红线——本篇不涉及某个具体 add-on 的写法(那是 add-on 生态的题域)、不复述第五篇的跨 API 抽象、不碰商业游戏与反作弊。把边界先钉死,后面几章才敢往深里钻;不钉死的话,写到"事件回调"就会滑进 add-on 教程,写到"构建"就会滑进打包发布。

0.1 全系列目录总览(十一篇)

依然是惯例,先给系列地图。本系列沿"一个外部注入的运行时"从入口一路拆到效果生态,十一篇的落点如下(本篇即第六行):

# 篇目 这一篇要回答的那一问
一 总论:注入式后处理与管线内后处理的边界 它到底动的是哪一段?和引擎自带的后处理差在哪?
二 代理 DLL、加载顺序与导出表挂钩 它凭什么进得来?进来之后怎么把调用接过去?
三 ReShade FX:语言血统、独立编译器与纹理语义 效果是拿什么写的?编译器为什么能单独拿出去用?
四 effect runtime:technique / pass 图、render state 与运行时数据源 一个 effect 从源码到画面,中间过了几道?
五 跨 API 抽象:D3D9/10/11/12、OpenGL、Vulkan 的差异与统一 同一套东西怎么盖住六种图形 API?
六 本篇:Add-on API——事件模型、跨 API 资源交换与两种构建的分野 想在运行时插手更深的事,接口长什么样?
七 深度缓冲的工程学:自动关闭、MSAA、UI 缺失与子矩形错位 为什么深度类效果时灵时不灵?
八 效果生态与许可地图:同一份清单里的 MIT 与"禁止再分发" 那些效果到底能不能用、能不能转?
九 自跑一:注入本地 D3D11 demo——逐效果前后对比 真跑一遍是什么样子?
十 自跑二:效果栈与帧耗账,外加自写一个小 .fx 代价是多少?自己写一个要几步?
十一 合规与工程取舍的合集:把十一篇的账并到一处 这条路线值不值得用、边界在哪?

阅读路径:只想知道"add-on 到底是什么东西"的朋友,读第零章与第一章就够;要搞明白"回调是怎么被调起来的"的,第四、五、六章是核心;被"为什么我的 add-on 没被加载"卡住的,第七、八章各有一处直接答案。

0.2 本篇要讲清的一件事

先把结论放这儿:add-on API 不是一套"给 ReShade 加功能"的插件接口,而是一套把宿主调用栈让出一段给第三方的"拦截器契约"。 这套契约由三样东西组成:

  • 一张 100 个格子的事件表(addon_event),它决定了"哪些时刻能被插手";
  • 一组跨 DLL 边界的 18 个 C 导出(ReShade*),它决定了"注册这件事靠谁完成";
  • 一个三档取值的构建开关(RESHADE_ADDON = 1 / 2 / 未定义),它决定了那张表里有多少格子真的可达。

第三样最容易被忽略,也最值得单说。 官网下载页并排放着两个安装器:ReShade_Setup_6.8.0.exe 与 ReShade_Setup_6.8.0_Addon.exe;前者带签名(页面逐字给出 X.509 Digital Signature Thumbprint: 589690208A5E52FB96980C4A6698F50ACD47C49F),后者不签名。5.0 发布帖里 crosire 把两者的差别写死了(逐字):

Since this is an extremely powerful API, there is potential for abuse, so the main ReShade release with signed binaries has add-on support mostly disabled to ensure it continues to be safe to use in multiplayer games! It does not allow loading add-ons and internally only hooks a very limited set of graphics API functions, just enough to make the built-in depth buffer detection work in singleplayer games still. When it detects a multiplayer game, this too is disabled completely, like before.

However, to still make the add-on API available, there is now a separate ReShade release with full add-on support that is not whitelisted by anti-cheat providers and therefore is to be used with singleplayer games only. ... This build does not do any multiplayer detection to disable features (but as said, using it in multiplayer games may cause bans!).

这两段不是"两个版本功能多少"的问题,而是同一份工程里两组编译配置的问题。 第七、八章会把它落到一行预处理器定义上,并给出被编译期关掉的具体 30 个事件。"抽象层里有什么"和"我这个构建里能碰到什么"是两件事——这句话是本篇和第五篇的分界线。

官方文档里 add-on 的定义:不导出函数、不链接库,以及反初始化那个 only if
图 6-2官方文档里 add-on 的定义:不导出函数、不链接库,以及反初始化那个 only if@ 官方文档 REFERENCE.md(随篇归档)· 第 4/6/8/15 行

这张面板把那一句定义句、以及紧挨着它的两个可选入口一起摊开了。第二段是官方对 AddonInit/AddonUninit 的说明——注意其中那个 "only if":

Similarily it may also export an AddonUninit function, which will be called right before unloading (but only if initialization was successfull).

初始化失败就没有对应的反初始化。 这不是笔误,而是接口层的一条取向:add-on 的生命周期由宿主单方面判定,add-on 自己不能决定"我算不算起来了"。这条取向在第八章会以另一种形式再出现一次(加载期的那几条日志串)。

0.3 本篇不涉及什么(边界声明)

依然是惯例,先把边界摆在前面:

不涉及 为什么
怎么用这套 API 写一个具体的 add-on 那是 add-on 生态的题域;本篇只讲"接口面长什么样",不讲"你该怎么写代码"
跨 API 抽象的细节——句柄形态、格式转换、状态块 第五篇已逐条讲完;本篇只在"回调签名里为什么出现 api::resource_view"这类地方引用一句
深度缓冲的工程问题——自动关闭、MSAA、UI 缺失 这是第七篇的题域,本篇只在讲 09-depth 这个内置 add-on 时提一句
效果文件与许可地图 第八篇的题域
任何绕过做法——绕过深度限制、绕过网络检测、绕过反作弊 红线。本系列全部实验都在自有开源 demo 上做,商业游戏一次都不碰

参数口径也说一句:系列里所有"本机读数"都来自同一台机器——Windows 11(build 26200)、RTX 3060 Laptop(6144 MiB 显存、驱动 560.70)、.NET 运行时 4.8 以上;帧耗类读数一律关垂直同步、不锁帧。第六篇不新做实验,全部读数分两类:一类是对随篇归档的官方源码原件做离线解析,一类是对官方仓库文件清单做清单级核对。

0.4 四条读法约定(沿用本系列体例)

好了,写这个系列的过程中,有四条约定帮我们避开了四类常见误读,先摊在这里:

  1. 官方文字优先,二手转述只作线索。 凡涉及版本、行为与立场的说法,一律追到官网、发布说明或代码仓库原文;二手内容明标为二手。本篇特别要守这一条——"add-on"这个话题上,中文圈里最流行的一句是"ReShade 的插件系统导出一堆函数给你调",而官方那句 "no functions need to be exported" 正好相反。两句话不可能都对,本篇照源码写。
  2. 「文档说了什么」与「我这个构建里能碰到什么」必须分开说。 官方文档描述的是全量接口;而签名版构建里,那张表的一大半格子是被编译期关掉的。看清这一层,才不会得出"文档骗人"或者"装个 ReShade 就能玩 add-on"这种过头结论。
  3. "没复现"就是"没复现"。 本机做不到的,一律写"测不了",不拿别人的结论替自己作证。这一条在本篇尤其重要:本机没有签名版与 Addon 版两份二进制,所以「=1 构建下某个 add-on 会怎么失败」这类运行期问题,本篇不给结论,只给"编译期被关掉"这一层。
  4. 读数的口径必须写在读数旁边。 本篇会出现很多"多少个事件""多少处 #if"——这些数全部由脚本从归档原件现读,不是估算、不是转述。凡是引用别人的数字,一律注明转述。

这四条在后面五篇里都会再用到。

一、最小的 add-on 长什么样

先来看看最容易被跳过的一件事:一个 add-on 到底得写多少东西,才能算"跑起来了"?

官方在定义那句之后,紧接着给了三条要求,读起来像"什么都没要求":

  • 不用导出任何函数(no functions need to be exported);
  • 不用链接任何库(no libraries need to be linked against);
  • 只用头文件(header-only ReShade API),把 include 目录加进工程、#include "reshade.hpp" 就能开始。

但这三条都有"但是"。 本节就把那个"但是"摊开——它同时也是后面五章的引子。

1.1 一次注册,三处动作

先来看看顺序。reshade.hpp 里有一处注释把顺序写死了(Doxygen 页面上标的是 Definition: reshade.hpp:250):

Registers this module as an add-on with ReShade. Call this in 'AddonInit' or 'DllMain' during process attach, before any of the other API functions!

"before any of the other API functions"——加感叹号的那种语气。 为什么必须是第一步?因为在这之前,add-on 与宿主之间连话都还没说上:那 18 个 C 函数(第六章)还没拿到,事件回调也没有地址可交。所以一个 add-on 的最小形态,其实是一条很短的链:

  1. 被加载——宿主按目录扫描,把 .addon 文件映射进进程(第八章会讲它的判定与日志);
  2. register_addon——add-on 主动找宿主,并把自己的 API 版本号递过去(第六章讲这次握手到底递了什么);
  3. register_event<ev>(&cb)——按事件类型登记若干个回调(第四、五章讲签名与派发)。

注意中间那一步是 add-on 主动发起的。 官方说法是"this will look for the ReShade instance in the current process and initialize the API when found"——add-on 去找宿主,不是宿主去找 add-on。这个方向决定了后面很多东西:比如 add-on 可以在 DllMain 里做这件事(DLL_PROCESS_ATTACH 那一支),也可以在更晚的时机做。

一个 add-on 的最小合法形态:四步,每一步都能单独停
图 6-3一个 add-on 的最小合法形态:四步,每一步都能单独停@ 本系列绘制

这张图把那条链画全了:加载 → 注册 → 登记回调,三步之外都是可选。右侧那一列"失败会怎样"才是关键——每一步失败都不是"报个错继续跑",而是整条链断掉:注册失败就不该再调任何 API(注释里那个感叹号就是这个意思);登记回调失败则那个时刻永远不会被通知。

1.2 回调是什么:一次"把函数地址交出去"

"注册回调"这四个字在中文技术写作里被用得太顺口了,顺口到容易忘掉它字面上做了什么。它做的事只有一件:把你函数的内存地址,交到宿主手里,让宿主在某个时刻替你调用它。

这件事在别的地方有名字——回调(callback)。一个描述得比较准的说法是:某段代码把"接下来该我上场了"这个时机,交给了另一段代码去判断。 回调的形态可以很不一样:同步回调是"你调我,我在你返回之前就把活干完";异步回调是"你先把我的地址记下来,等条件满足了再来调我"。

回调的三方关系:主程序、软件平台、回调函数
图 6-4回调的三方关系:主程序、软件平台、回调函数@ Wikimedia Commons · TuukkaH(Public domain)

这是外面找的一张真图(Wikimedia Commons,Public domain,512×253,只作原理小图):回调的三方关系——主程序、软件平台、回调函数。

把这张图和 ReShade 对着看,能看出一个差别:这张图上,主程序是主动方——它调用平台,并且把回调登记给平台;而在 ReShade 这一侧,"主程序"是那个被注入的游戏,它压根不知道自己被登记了什么。登记这个动作发生在 add-on 与 ReShade 之间,游戏不知情。 这就是"寄生式接口"与"平台式接口"的第一个不同点:回调链的建立不需要宿主应用配合。

这一处差别会在第八章收口:它能解释为什么这套接口必须"承认自己会漏"——它没有资格要求游戏配合。

1.3 为什么是"事件"而不是"接口"

到这里可以回答本节标题那个问题了:为什么 ReShade 选择让 add-on 登记事件回调,而不是暴露一组接口函数让 add-on 去调?

答案是信息流的方向不同:

  • 接口模式下,方向是"add-on 想知道什么,就去问什么"——add-on 主动轮询宿主的状态;
  • 事件模式下,方向是"宿主什么时候发生了什么事,就通知一遍"——宿主主动推。

对这套接口来说,事件模式是唯一可行的。 因为它插在别人的进程里:它无法要求游戏"在画完 UI 之后停一下,让我去问一句"。它只能在游戏自己会走到的地方提前布点——比如 Present 被调用的那一刻(第五篇讲过九个入口)。先有"那个时刻",才有"通知";先有通知,add-on 才有地方插手。

所以"100 个事件"这个数字不是设计者拍出来的,而是"游戏会走到的地方"被逐个盘点出来的结果。 下一章就来看看这 100 个格子是怎么数出来的,以及它的编号里藏着什么。

二、事件表:100 个格子

言归正传,本篇的第一个硬读数是关于这张表的。addon_event 定义在 include/reshade_events.hpp 里,是一个 enum class addon_event : uint32_t。本机用脚本逐行数,得到 100 个成员。

2.1 100 个成员怎么数出来的

先来看看数法本身,不然数字容易被当成"大约"。这一节的数法与结果如下(脚本 evidence/build/ep06_probe_enum_values.py,输入是随篇归档的 include__reshade_events.hpp):

读数 值
成员数(不含末尾 max 哨兵) 100
取值区间 0 – 100
显式赋值 23 个
重复取值 无
被跳过的值 7(没有任何成员用它)
末位成员 reshade_overlay_technique = 88
末尾哨兵 max = 101

先解释那个 max = 101。 枚举体最后两行是这样的(逐字):

#if RESHADE_ADDON
        max = 101 // Last value used internally by ReShade to determine number of events in this enum
#endif

这个哨兵只存在于 RESHADE_ADDON 为真的构建里——签名版构建里连"事件总数"这个值都不存在。它与本章后面的三档计数是同一件事的两面(第八章展开)。

addon_event 的 100 个成员:取值 0–100,值 7 空缺,末位只到 88
图 6-5addon_event 的 100 个成员:取值 0–100,值 7 空缺,末位只到 88@ 官方源码 include/reshade_events.hpp(随篇归档,脚本现读)

这张面板把 100 个格子铺在 0–100 这条线上。看三处:值 7 是空的(没有任何成员占用);末位成员只到 88,而不是 100;值 89 到 100 这一段全是后补的——dispatch_mesh = 89、dispatch_rays = 90、build_acceleration_structure = 92、finish_present = 100 这些,都是后来加进去的。

"100 个成员"与"取值到 100"并不是一回事:成员 100 个,值域 101 个槽位,中间空了一个。这个差额本身就是下一章的全部内容。

2.2 四组事件的分工

100 个事件不是一堆同类。按名字前缀粗分,本机现数的分布是这样的:

类 个数 例子
init_ / create_ / destroy_ 30 init_device、create_resource、destroy_pipeline
reshade_ 前缀(ReShade 专有) 15 reshade_present、reshade_begin_effects、reshade_open_overlay
copy_ / clear_ / resolve_ / generate_ 14 copy_texture_region、generate_mipmaps
bind_ 9 bind_pipeline、bind_descriptor_tables
map_ / unmap_ / update_ 9 map_texture_region、update_descriptor_tables
query 相关 8 begin_query、get_query_heap_results
draw / dispatch 类 6 draw_indexed、dispatch_mesh、dispatch_rays
present / finish_present 2 ——
100 个事件按用途粗分成八类:生命周期类最多,绑定类次之
图 6-6100 个事件按用途粗分成八类:生命周期类最多,绑定类次之@ 官方源码 include/reshade_events.hpp(随篇归档,脚本现数)

这张分布图里最值得看的是第一行与第四行。 第一行(生命周期类)30 个,占了三成——这套接口给"对象被创建/销毁"留的格子最多。而第四行(绑定类)9 个,恰好是第八章那份"被编译期禁掉"的清单的主体——为什么"绑定"这一类在签名版里最先被关掉,第八章会给答案。

同一次注册,四个看台:事件表的四组
图 6-7同一次注册,四个看台:事件表的四组@ 本系列绘制

把它们归成四组之后,这张表就变成了"四个不同的看台"。同样是 register_event,站在这四个看台上能做的事完全不一样:

  • 生命周期组:只能在对象诞生与消亡时看两眼,甚至能在诞生前改描述(create_* 系列返回 bool,返回 true 就接管了原调用);
  • 命令与状态组:能看到每一次绑定与绘制,但看到的是一串已经发出的命令;
  • 资源与拷贝组:能被要求"把这段数据搬一下",是唯一能替应用干活的一组;
  • ReShade 专有组:与图形 API 无关,讲的是 ReShade 自己的时刻(效果跑前、跑后、叠加层被画)。

这四组的划分不是官方的,是本篇为了回答"这套接口能碰到什么"而做的归类。 官方的注释比这细——每个事件条目都写着 "Called after/before …" 与一行回调签名,第四、五章各引几处。

2.3 它常被类比成什么

说到这里得先清一个概念上的坑,因为后面五章都要用到它。

中文技术圈讲这套接口时,最常见的两个类比是"观察者模式"和"发布订阅"。 这两个说法都不算错,但它们都会让人以为"回调只是被通知一下"——而这套接口的一半格子不止是通知。

观察者模式类图:Subject 与 Observer 的一对多通知
图 6-8观察者模式类图:Subject 与 Observer 的一对多通知@ Wikimedia Commons · Teriyaki(Public domain)

这是外面找的一张真图(Wikimedia Commons,Public domain,740×320,只作原理小图):观察者模式的类图。图上的关系很干净——Subject 记着一批 Observer,状态一变就挨个 update()。

它的语义是"通知":被通知的一方看得到发生了什么,但改不了发生了什么。update() 的返回值在这里通常没人管。

这套接口在哪些地方确实像它? 那些 init_* 事件——对象已经建出来了,你去登记个"我知道了",返回值是 void,改不了任何事。这一部分确实是观察者。

但另一半不是。 下一章开头会给出证据:create_device、create_swapchain 这些事件的回调返回 bool,返回 true 的含义是"我接管了,你别再调原函数了"。这已经不是"通知",而是拦截。第五、八章会各给一处对照,把"通知"与"拦截"分开说。

三、编号只增不废:事件值的"历史地层"

先来看看上一章留下的那个差额:成员 100 个,值域 101 个槽位,中间空了一个。

这不是我没数对。把 23 个显式赋值单独抄出来看,一切都清楚了。

3.1 编号里留着的三处痕迹

reshade_events.hpp 里带显式赋值的成员一共 23 个。其中三组对照最有意思(逐字):

        destroy_device = 1,
        ...
        create_device = 96,
        destroy_swapchain = 8,
        ...
        create_swapchain = 97,
        finish_present = 100,

第一组就足够说明问题:destroy_device 的值是 1——它是枚举里的第三个成员,值紧跟 init_device = 0;而 create_device 的值是 96,排到了接近队尾的位置。可是在语义上,"创建"显然发生在"销毁"之前。

为什么会这样? 因为这套枚举是按"加入时间"排的,不是按"语义顺序"排的。init_*/destroy_* 这一对是 5.0 就有的,create_* 是后来补的——补的时候不能插队,只能拿一个新的、更大的值。第二组、第三组是同一个道理:create_swapchain = 97、update_buffer_region_command = 98、update_texture_region_command = 99,这四个值连号,一看就是同一批补进去的。

编号里留着的三处痕迹:1 对 96、8 对 97、100 封顶
图 6-9编号里留着的三处痕迹:1 对 96、8 对 97、100 封顶@ 官方源码 include/reshade_events.hpp(随篇归档)· 行号为该文件真实行号

这张面板把三组对照并排放了。看出规律之后,还能读出第四件事:23 个显式赋值里,值最小的那一批(1、8、24、25、26)全是早期成员;而值在 84 以上的那一大批(84、89–100),全是后补的。值的大小在这里等于"资历"。

一个更反直觉的例子:reshade_present = 75,而 create_device = 96。ReShade 专有事件里的 present,比通用的 create_device 资历老 21 位。 这恰好说明编号与"重要性"无关——create_device 显然比 reshade_present 更基础,但它是后加的。

3.2 槽位 7 为什么空着

第二处痕迹更细,也更说明问题:值 7 是空的——没有任何成员占用它。而末尾的哨兵仍然写着 max = 101,也就是按"0–100 都给出去"来分配。

把两件事放一起看:值 89–100 这一段是新增的位,值 7 是报废的位。一个是"往里加",一个是"减掉了却留着坑"。

编号纪律的两侧:把值钉死,与把槽位空着
图 6-10编号纪律的两侧:把值钉死,与把槽位空着@ 本系列绘制

为什么不能回收? 因为add-on 是编译产物:一个三年没更新的 add-on,里面硬编码着"事件值 7 是某件事"。如果 ReShade 把 7 号位让给一个新事件,这个老 add-on 会在不报任何错的情况下认错事件——它注册的是 A,被通知的却是 B。这种错误在运行期极难排查,因为两边都不会崩。

所以唯一安全的做法是:删掉的事件,编号留着不用。 值 7 就是这么来的。

这里呼应第五篇的一处读数:device_api 的六个成员全部带显式赋值(0x9000–0x20000),当时的结论是"把数值钉死,以后再加成员也不会挪动已有的值"。同一个习惯,在这张 100 个格子的表上重复了 23 次。 这不是洁癖——这是给编译产物留的兼容底线。

3.3 读这张表的正确姿势

好了,把这一章收一下。看 addon_event 这张表,不要按值的大小去猜顺序:值小只代表"来得早",不代表"更基础";值大只代表"来得晚",不代表"更边缘"。它是按年份长的地层,不是按重要性排的目录。

这条认识在第八章会变成一个有实用价值的判据:当你看到某个事件的值在 90 以上时,可以确定它是最近几个版本才加的——一个给老版本 ReShade 写的 add-on,不可能用到它。

3.4 一处对照:6 个值 vs 100 个值,钉法完全不同

把第五篇那处读数与本篇这处摆在一起,能看出一个有意思的差别。

device_api(第五篇) addon_event(本篇)
成员数 6 100
显式赋值 6(全部) 23(不到四分之一)
钉的意图 把这六个值彻底钉死,一个都不许动 只把后补的那批钉死,早期成员顺其自然
能读出什么 「这是一份封闭的小清单」 「这是一张一直在长的表」

为什么两个枚举的钉法不一样? 因为它们的变更压力不同:device_api 代表"支持几个图形 API"——这个数字几乎不会变(第六个后端加进来了,短期内不会有第七个),所以六个值一次性钉死最省事;而 addon_event 是跟着"游戏会走到的地方"长的,每加一类新能力(网格着色器、光追、finish_present)就要补几个格子,只能"加一个、钉一个"。

换句话说:显式赋值的比例,反过来能当"这个枚举有多活跃"的指标。 device_api 是 6/6 = 100%,addon_event 是 23/100 = 23%——后者那 23 个,就是它历史上的每一次扩容留下的桩子。

四、签名不是手写的

下面进入第四个问题:回调函数的签名是怎么保证对上的?

这个问题听起来平平无奇,但它是"事件驱动接口"最容易出错的地方。想一下:如果签名靠人记,那么"我以为 present 回调收两个参数,实际是三个"这种错,会以什么形式暴露?答案通常是:在运行期,以栈被搅乱的形式暴露。 这是最难查的一类 bug。

4.1 一次宏展开=一个事件的签名

reshade_events.hpp 的末尾有一段宏,它把这件事变成了编译期的(逐字):

    template <addon_event ev>
    struct addon_event_traits;

#define RESHADE_DEFINE_ADDON_EVENT_TRAITS(ev, ret, ...) \
    template <> \
    struct addon_event_traits<ev> { \
        using decl = ret(*)(__VA_ARGS__); \
        using type = ret; \
    }

    RESHADE_DEFINE_ADDON_EVENT_TRAITS(addon_event::init_device, void, api::device *device);
    RESHADE_DEFINE_ADDON_EVENT_TRAITS(addon_event::create_device, bool, api::device_api api, uint32_t &api_version);
    RESHADE_DEFINE_ADDON_EVENT_TRAITS(addon_event::destroy_device, void, api::device *device);

逐行读一遍这三行实例,收获比读十页文档多:

  • init_device 的返回类型是 void,收一个 api::device *——通知型:设备已经建好了,你来登记一句;
  • create_device 的返回类型是 bool,收 api::device_api api 与 uint32_t &api_version——拦截型:设备还没建,你可以改 API 版本号,返回 true 还能接管原调用;
  • destroy_device 又是 void——通知型:销毁正在发生,你只能看。

三条相邻的宏调用,两种完全不同的语义。 这就是第二章末尾说的那件事:"通知"与"拦截"在这张表里是混着的,而区分它们的唯一线索就是返回类型——void 是通知,bool 是拦截。

一次宏展开=一个事件的签名:101 次出现,1 处定义 + 100 个特化
图 6-11一次宏展开=一个事件的签名:101 次出现,1 处定义 + 100 个特化@ 官方源码 include/reshade_events.hpp(随篇归档)· 行号为该文件真实行号

本机现数:这个宏在 reshade_events.hpp 里一共出现 101 次——其中 1 次是宏定义本身,剩下 100 次是展开,与上一章数出的 100 个成员正好对上。

这意味着"有哪些事件、每个签名长什么样"是一张写死在头文件里的表。 它不是散落在文档里的约定,而是代码。

4.2 写错是编译错误

这张表怎么被用起来?看 register_event 的签名就够了(逐字,include/reshade.hpp):

template <addon_event ev>
inline void register_event(typename addon_event_traits<ev>::decl callback)

参数类型不是"某个函数指针",而是 addon_event_traits<ev>::decl——也就是上面那张表里为这个事件生成的、唯一的那个类型。于是:

  • 你写 register_event<addon_event::present>(&my_cb),编译器会去查 addon_event_traits<present>::decl,然后要求 my_cb 的类型与它完全一致;
  • 少一个参数、多一个参数、返回类型写成 int,编译直接失败。

官方文档里那句话说的就是这个(逐字):

This registers a callback for the 'present' event, which occurs every time a new frame is presented to the screen. The function signature has to match the type defined by 'reshade::addon_event_traits::decl'.

签名写错是编译错误:检查发生在编译期
图 6-12签名写错是编译错误:检查发生在编译期@ 本系列绘制

这张图把检查点画出来了。它带来一个很实在的后果:因为签名在编译期就被钉死,运行期不存在"认错事件"这件事——签名不对的代码根本编不过去。

但注意它挡不住什么:它挡不住"用对了签名、却对事件含义理解错了"。比如有人以为 create_resource 是"资源建好之后"的通知,于是在回调里直接去读资源描述——编译完全通过,运行时行为却和他想的不一样。类型系统管得住形状,管不住语义。

五、宿主怎么派发

说白了,前面三章都在说"add-on 这一侧",这一章换到宿主那一侧:登记进来的回调,宿主是什么时候、按什么顺序调起来的?

5.1 注册、分桶、按序调用

先来看看宿主那一侧的代码在哪。它在 source/addon_manager.{hpp,cpp} 里。这一段是本篇唯一"文档里没有、只能读源码"的部分——官方文档讲了怎么注册,但没讲怎么派发。

从源码里能读出三件事:

第一件:回调是按事件分桶存的。 宿主维护一个按事件索引的监听器列表(addon_event_list 一类的结构),注册就是把你的函数地址追加到某个桶的尾部。这是"分桶"而不是"一条链表"——原因很直接:宿主派发时要按事件取,分桶才能 O(1) 找到。

第二件:同一个事件上的多个回调按注册顺序调用。 这个顺序在源码里是可以读出来的(invoke_addon_event 的实现就是遍历那个桶)。这一点在工程上有后果:两个 add-on 抢同一个 create_* 事件时,谁先注册谁先看到、也谁先有机会接管——而注册顺序取决于加载顺序(第八章)。

第三件:宿主自己也在跟踪"add-on 有没有全都就位"。 源码里有一个全局标志:

138:    bool reshade::addon_all_loaded = true;
152:    addon_all_loaded = true;
208:    addon_all_loaded = false;
...
323:    addon_all_loaded = false;
派发内核:一张按事件分桶的列表 + 两处 static_assert 的名单
图 6-13派发内核:一张按事件分桶的列表 + 两处 static_assert 的名单@ 官方源码 source/addon_manager.hpp 与 .cpp(随篇归档)· 行号为各文件真实行号

这个 addon_all_loaded 值得停一下。 它在加载成功时置真、在任何一处失败时置假(本机现数,共 5 处置假)。为什么宿主需要知道自己有没有加载完? 因为在那之前,任何"提前跑起来"的 add-on 都可能读到不完整的状态。这是宿主对"加载期"这个特殊阶段做的自我保护。

5.2 一次 present 的路径

把上面三件事串成一条线,就是一次普通事件的完整路径。以 present 为例:

  1. 注册期:add-on 在 DllMain 里调 register_event<addon_event::present>(&cb),宿主把它追加到 present 那个桶的尾部;
  2. 宿主触发:游戏调用 Present(第五篇讲过九个入口),ReShade 的挂钩接住它,走到 invoke_addon_event<present>(...);
  3. 派发:宿主遍历 present 桶,按注册顺序逐个调用;
  4. 返回值汇合:present 的返回类型是 void,没有返回值可汇合;但如果换成 create_device 这类 bool 事件,宿主就要处理"有人返回了 true"——那时它跳过原调用。
一次 present 事件:从宿主到每一个回调
图 6-14一次 present 事件:从宿主到每一个回调@ 本系列绘制

第 4 步是"通知"与"拦截"在派发器里的分界。 对一个 void 事件,宿主调完就完了;对一个 bool 事件,宿主得收集几十个回调的返回值,再决定"原调用还要不要发"。这也是为什么签名必须编译期钉死——宿主得知道该不该期待一个返回值。

一处官方答复可以印证这条路径对调用方的影响。 官方论坛那条讲"D3D12 下描述符表"的帖子里,crosire 指出:D3D12 下 ReShade 只恢复绑定的 pipeline,描述符 layout/set、渲染目标这些要由 add-on 自己跟踪并重绑。bind_* 系列事件只通知你"有人绑了",不替你记"上一次绑的是什么"——记账这件事,是 add-on 自己的活。

5.3 三种类比的差别

好了,这一章最后把第二章末尾那个"概念上的坑"收掉。这套接口常被类比成三种东西,本篇的做法是三种都摆出来,然后说清哪个最接近。

发布/订阅的两侧:发布者与订阅者通过 topic 中介对等通信
图 6-15发布/订阅的两侧:发布者与订阅者通过 topic 中介对等通信@ Wikimedia Commons · Mathieu.clabaut(CC BY-SA 4.0)

这是外面找的一张真图(Wikimedia Commons,CC BY-SA 4.0,1088×414,只作原理小图):发布/订阅模式的两侧。图上有 Writer/Reader/Topic: Temperature——发布者和订阅者是对等的,中间隔着一个 topic。

这就是它和 ReShade 最大的不同:ReShade 的 add-on 与宿主不对等。 游戏(真正的"事件源")既不知道有 add-on,也不是这套订阅关系的参与者;add-on 订阅的是 ReShade 的事件,而事件的触发者是第三方。订阅关系与触发关系被拆成了两截——这是"寄生式接口"的必然形状。

把三种类比放在一起:

类比 通知方向 回调能否改变结果 与 ReShade 的差距
观察者模式 一对多通知 不能(void) 只覆盖了这套接口里"通知"的那一半
发布/订阅 经 topic 对等 不能 多了一层"订阅者↔触发者分离",且缺"拦截"语义
拦截器链 沿调用链依次过手 能(bool 可接管) 最接近:回调运行在宿主调用栈中间,能改参数、能阻止后续

结论是:它更像一条"拦截器链",不是"通知列表"。 第八章会用职责链的那张图把这个说法补完——那一张图上的 handleRequest 可以决定要不要往下传,与 create_* 返回 bool 是同一个形状。

5.4 回调跑在谁的线程上

这一节是"怎么派发"的最后一问,也是最容易被忽略的一问:回调被执行时,它在哪个线程上、能不能阻塞、能不能再触发事件?

第一个问题的答案很简单:宿主的线程。 这套接口没有自己的调度——没有工作队列、没有线程池、没有"回调队列"。宿主在哪个线程上遇到了那个时刻,回调就在哪个线程上被执行。一个直接的后果是:游戏主线程调用 Present,你的 present 回调就在游戏主线程上跑;你在这里睡一毫秒,游戏的这一帧就慢一毫秒。add-on 的开销是直接记在宿主帧时间上的(这也是第九、十篇要单独算帧耗账的原因)。

第二个问题要看具体对象。 api_object 那一族的文档里有一条很硬的警告(逐字):

This function may NOT be called concurrently from multiple threads!

这条挂在 set_private_data 上——"往对象上存自定义数据"这件事不是线程安全的。而这类数据恰恰是 add-on 最常用的东西:api_object 的类注释写着它是为了"store and retrieve custom data with objects, e.g. to be able to communicate persistent information between event callbacks",也就是在回调之间传状态。所以要跨回调记东西,就得自己管同步。

第三个问题用一处对照来回答。 device 的类注释里写着(逐字):

This class is safe to use concurrently from multiple threads in D3D10+ and Vulkan.

注意它只说了 D3D10+ 与 Vulkan,没提 D3D9 与 OpenGL。 这不是遗漏,而是底层事实的照实转述:D3D9 的设备对象与 OpenGL 的上下文都带线程绑定语义(第五篇 2.3 讲过这一处)。"同一个 device 句柄能不能多线程用"这个问题,答案取决于它背后是哪个 API。

把三件事合起来,就是这个 API 的线程模型:回调在宿主线程上同步执行,没有额外调度;对象上的自定义数据要自己加锁;能不能多线程用取决于底层 API。 这三条都写在头文件注释里,但都不在"怎么注册事件"那一类教程的射程内——它们属于"注册完之后才会遇到"的问题。

六、跨 DLL 边界:18 个符号

前面说过一句"add-on 什么都不导出"。但边界上总得站着点什么——这一章就来数清楚,那上面站了多少个符号。

6.1 不导出函数,但边界上站着 18 个符号

OK,先把那句话拆成两半来看。它的两个半句都成立,只是说的不是同一件事:

  • "add-on 不导出函数"——官方那句说的是 add-on 这一侧:你不用 __declspec(dllexport) 任何东西给宿主调;
  • "边界上站着 18 个符号"——那是 ReShade 这一侧导出的,由 add-on 去调。

本机现数:include/reshade.hpp 里由 ReShade 侧导出的 C 函数共 18 个(逐条):

ReShadeLogMessage · ReShadeGetBasePath · ReShadeGetConfigValue · ReShadeSetConfigValue
ReShadeSetConfigArray · ReShadeRegisterAddon · ReShadeUnregisterAddon
ReShadeRegisterEvent · ReShadeRegisterEventForAddon · ReShadeUnregisterEvent
ReShadeUnregisterEventForAddon · ReShadeRegisterOverlay · ReShadeRegisterOverlayForAddon
ReShadeUnregisterOverlay · ReShadeUnregisterOverlayForAddon · ReShadeCreateEffectRuntime
ReShadeDestroyEffectRuntime · ReShadeUpdateAndPresentEffectRuntime
边界上站着的 18 个符号:注册、事件、叠加层、运行期四组
图 6-16边界上站着的 18 个符号:注册、事件、叠加层、运行期四组@ 官方源码 include/reshade.hpp(随篇归档)· 行号为该文件真实行号

这 18 个按用途分四组:注册与注销(RegisterAddon/UnregisterAddon)、事件(RegisterEvent/UnregisterEvent + 两个 ...ForAddon 变体)、叠加层(RegisterOverlay/UnregisterOverlay + 两个 ...ForAddon 变体)、effect runtime 手动管理(CreateEffectRuntime/DestroyEffectRuntime/UpdateAndPresentEffectRuntime)。

有三处细节要说:

  • ...ForAddon 变体是给"一个模块注册多个 add-on"用的——带上模块参数,事件/叠加层就记在那个模块名下(第六章末尾会看到它和 addon_info 的关系);
  • 最后三个 EffectRuntime* 是 6.0 才加的(6.0 发布说明:"Added API to manually manage ReShade effect runtime instances when graphics hooks are disabled")——它对应一个很特殊的场景:挂钩被关掉时,add-on 得自己把 effect runtime 的更新与呈现调起来;
  • ReShadeSetConfigArray 是这一族里唯一的"写配置"重载——add-on 可以往 ReShade.ini 里写数组型配置。

6.2 两条链接路径

说白了,这一节讲的是同一个头文件的两种用法。 reshade.hpp 里有一段明确的开关(逐字注释):

Optionally import ReShade API functions when 'RESHADE_API_LIBRARY' is defined instead of using header-only mode

  • 不定义它(默认):header-only 模式。那 18 个函数不由链接器解析,而是 add-on 在运行期按名字去宿主模块里找(GetProcAddress 那一族做法)。这就是"不用链接任何库"的字面含义。
  • 定义 RESHADE_API_LIBRARY:直接链接模式。那 18 个函数变成正常的导入符号,由链接器处理。
两条链接路径:header-only 与直接链接
图 6-17两条链接路径:header-only 与直接链接@ 本系列绘制

这里要澄清一个流行误解:「header-only 就等于没有 ABI」。恰恰相反——header-only 只是把 ABI 从链接期推到了运行期:符号名、调用约定、参数类型一个都不能错,只是"谁来检查"变了——不再是链接器,而是你自己。这也是为什么上面那 100 个 traits 特化那么重要:它们是这套运行期约定在编译期的替身。

6.3 API 版本:加载即校验

两条路径之后还有一道坎:版本。

include/reshade.hpp 里有一个版本宏(逐字):#define RESHADE_API_VERSION 20。而 register_addon 会把它交给宿主,并且要求宿主确认自己支持这个版本——注释写得很直白:

Check that the ReShade module supports the used API

不支持就返回失败(register_addon 返回 false),add-on 应该就此收手,不再调任何 API。这就是第一章那句"第一步"背后的实际动作:这一次调用就是握手本身。

宿主这一侧,source/addon.hpp 里的 struct addon_info 有一个字段 uint32_t api_version = 0;(本机现读,默认值 0)——注册时被填上。每个 add-on 的版本号是被单独记下来的,这一点后面有用。

为什么版本要逐 add-on 记? 因为宿主得为新老 add-on 做兼容分派。source/addon_manager.hpp 里就有一处:对 begin_render_pass/end_render_pass 这两个事件,注释写着 "In older ABI versions these still had a void return type, so ignore and do not skip",判定条件形如 api_version < 20。同一个事件,老 add-on 的回调没有返回值,新 add-on 的有——宿主必须按注册者的版本来处理。

一处官方佐证:6.0 发布说明里有一条 "Updated imgui to 1.90 (add-ons may now use 1.90, 1.89.7 or 1.86)"——add-on 能用的第三方版本是有限集合,不是任意版本都能加载。"能编译"与"能被加载"之间还有一道版本门槛。

6.4 跨 API 资源交换,实际交换的是什么

标题里那个词组到这一节才真正落地:add-on 与宿主之间"交换资源",交换的到底是什么?

先给答案:交换的是抽象句柄,不是原生对象。 一个 present 回调里能拿到的背缓冲,在接口上是一个 api::resource_view;一个 bind_descriptor_tables 回调里能看到的表,是一个 api::descriptor_table。它们都是第五篇讲过的"不透明句柄"——一个 uint64_t,加一组被钉死的比较运算符,里面是什么由后端自己填。

那"我想拿到原生对象"怎么办? 有一扇明门:api_object 上有个虚函数(逐字注释):

Gets the underlying native object for this API object.

它的 <remarks> 按类型把能拿到什么列全了(逐字摘):

For device this will be be a pointer to a 'IDirect3DDevice9', 'ID3D10Device', 'ID3D11Device' or 'ID3D12Device' object or a 'HGLRC' or 'VkDevice' handle.
For command_list this will be a pointer to a 'ID3D11DeviceContext' (when recording), 'ID3D11CommandList' (when executing) or 'ID3D12GraphicsCommandList' object or a 'VkCommandBuffer' handle.

这段注释本身就是第五篇那张"六种形态"表的续集——句柄的注释里列的是"同一个概念在六个 API 上是什么",这里列的是"同一个抽象对象在六个 API 上能拆出什么"。两处合起来,就是"跨 API 资源交换"的全部机制:

  1. 默认路径:不拆。 拿到句柄直接往下传——resource_view 递给 ImGui::Image,descriptor_table 递给 bind_descriptor_tables。这条路径在六个 API 上完全一样,也是这套接口存在的理由;
  2. 需要原生对象时:显式拆。 走上面那个函数拿到底层指针,代价是这段代码立刻变成 API 相关的——你得先知道现在跑在哪个 API 上(device::get_api()),然后按那个 API 的规则写分支。

一处很硬的证据把"默认路径"钉死了。 reshade_overlay.hpp 里有一行 static_assert(逐字):

// Check that the 'ImTextureID' type has the same size as 'reshade::api::resource_view'
static_assert(sizeof(ImTextureID) == 8, "missing \"#define ImTextureID ImU64\" before \"#include <imgui.h>\"");

它要求 ImTextureID 恰好是 8 字节——也就是恰好是 resource_view 的宽度。 换句话说:当一个 add-on 把纹理交给 ImGui 去画时,交给它的就是一个不透明句柄。编译期就规定了这一点,不给你传别的形状的机会。

而官方对"该传哪个句柄"有一条明确答复:官方论坛那条讲 ImGui 渲染的帖子里,答案是要传 resource_view(着色器资源视图),不能直接传 resource。这条答复解释了一个初学者常见的困惑:为什么同一个资源在接口里有两个句柄?——因为它们的粒度不同:resource 是"这块内存",resource_view 是"按这个格式、这个范围去看这块内存"。能给 GPU 用的从来是后者。

代价也是真的。 同一个论坛的另一条帖子里,crosire 指出 D3D12 下 ReShade 只恢复绑定的 pipeline,描述符 layout/set、渲染目标这些要由 add-on 自己跟踪并重绑。这是"跨 API 资源交换"最实际的一笔账:D3D12 里描述符的可见性靠堆与绑定关系维护,宿主不能替你猜"你接下来要用哪张表"——记账这件事,在显式世界永远是调用方的活(这与第五篇 3.3 节那条"状态归谁管"的结论是同一件事)。

七、两种构建的分野

言归正传,回到第 0.2 节那个结论:官网并排放着两个安装器,它们不是两套代码。

这一章把这句话落到一行预处理器定义上,并给出三组可数的证据。

7.1 工程文件里的 10 个配置

先来看看工程文件本身。ReShade.vcxproj 里一共有 10 个配置(Debug/Debug App/Release/Release App/Release Signed,各配 Win32 与 x64)。逐个看它们的预处理器定义,会看到这样的三行(逐字,只摘 RESHADE_ADDON 那一段):

Debug|Win32        ...RESHADE_API_LIBRARY_EXPORT;RESHADE_ADDON=2;RESHADE_VERBOSE_LOG;...
Debug|x64          ...RESHADE_API_LIBRARY_EXPORT;RESHADE_ADDON=2;RESHADE_VERBOSE_LOG;...
Debug App|Win32    ...RESHADE_API_LIBRARY_EXPORT;RESHADE_ADDON=2;RESHADE_LOCALIZATION;RESHADE_TEST_APPLICATION;...
Debug App|x64      ...RESHADE_API_LIBRARY_EXPORT;RESHADE_ADDON=2;RESHADE_LOCALIZATION;RESHADE_TEST_APPLICATION;...
Release|Win32      ...RESHADE_API_LIBRARY_EXPORT;RESHADE_ADDON=2;RESHADE_LOCALIZATION;...
Release|x64        ...RESHADE_API_LIBRARY_EXPORT;RESHADE_ADDON=2;RESHADE_LOCALIZATION;...
Release Signed|Win32  ...RESHADE_API_LIBRARY_EXPORT;RESHADE_ADDON=1;RESHADE_LOCALIZATION;...
Release Signed|x64    ...RESHADE_API_LIBRARY_EXPORT;RESHADE_ADDON=1;RESHADE_LOCALIZATION;...
Release App|Win32  ...RESHADE_TEST_APPLICATION;RESHADE_VERBOSE_LOG;...        ← 没有 RESHADE_ADDON
Release App|x64    ...RESHADE_TEST_APPLICATION;RESHADE_VERBOSE_LOG;...        ← 没有 RESHADE_ADDON

这张表就是"两种构建"的全部秘密:

配置组 RESHADE_ADDON 个数 对应什么
Debug ×2、Debug App ×2、Release ×2 = 2 6 全量 add-on 能力
Release Signed ×2 = 1 2 签名发布版——有限能力
Release App ×2 未定义(#if 取 0) 2 带测试程序(RESHADE_TEST_APPLICATION)的构建

与官网那两句话严丝合缝:签名版(Release Signed)是 = 1;full add-on support 版是 = 2。"两个安装器"在代码里就是这一行的两个值。

ReShade.vcxproj 的 10 个配置:6 个 =2、2 个 =1、2 个未定义
图 6-18ReShade.vcxproj 的 10 个配置:6 个 =2、2 个 =1、2 个未定义@ 官方工程文件 ReShade.vcxproj(随篇归档)· 行号为该文件真实行号

面板把那 8 行有赋值的配置逐行抄出来了,另外两行(Release App)特意留空——"未定义"也是一个状态,它意味着 #if RESHADE_ADDON 里的东西全部编译不进去。那份测试构建里连事件表都是不完整的。

7.2 不是两套代码,是一个取值范围

为什么这件事值得单独说? 因为它决定了"改一个构建有多难"。

如果两种构建是两套源文件,那么加一个新事件就要写两遍,两边迟早会漂移。而现在的做法是:一份源码,靠一个宏分出三条路。看宏的三种写法就明白了——

  • #if RESHADE_ADDON:"有没有 add-on 能力"(>0 即为真);
  • #if RESHADE_ADDON == 1:"只有有限能力"(签名版那一档);
  • #if RESHADE_ADDON >= 2:"能力全开"(Addon 版那一档)。

"== 1" 与 ">= 2" 这一对是关键:它们不是"开/关",而是两档能力。写成 == 1 的地方,逻辑是"如果能力受限,那么……";写成 >= 2 的地方,逻辑是"只有全量能力才好挂钩"。用 == 1 而不是 #if !defined(...),说明作者当初就预见了将来会有第三档。

同一个宏的三个取值:两种构建的分野
图 6-19同一个宏的三个取值:两种构建的分野@ 本系列绘制

这张图把三条路并排放了:未定义(连事件表都不完整)、= 1(表完整,但一大类事件被编译期挡掉)、= 2(全开)。注意中间那一档不是"阉割版",而是"只留通知型能力"——第八章会把这句话说实。

7.3 三档计数:428 / 25 / 291

判断"这个宏在代码里有多重要的"最直接办法,是数它出现了多少次。本机把 include/ 与 source/ 下的原件取回来逐份扫(取回 219 份,共 228 份中的 96.1%,缺口见下),结果:

写法 处数 落在几个文件
#if RESHADE_ADDON 428 36
#if RESHADE_ADDON == 1 25 8
#if RESHADE_ADDON >= 2 291 38

这三行数字的关系值得读一下:>= 2 有 291 处,== 1 只有 25 处——说明代码的主要写法是"全量能力才做这件事",而"受限时怎么办"只写了 25 处。这是一个很合理的设计取向:全量构建是"正常路径",受限构建是"少数几个开关"。

再看 哪些文件最依赖 >= 2(本机现数,前六名):

文件 >= 2 处数
d3d11_device_context.cpp 53
d3d12_command_list.cpp 44
d3d9_device.cpp 42
d3d12_device.cpp 39
d3d10_device.cpp 31
vulkan_hooks_device.cpp 21

这六个文件全是后端的"设备与命令列表"实现,而被这个宏包住最多的,正是"替应用发命令"那一段。这与第八章那份"被编译期禁掉的事件清单"是同一件事的两种量法。

而 == 1 最多的文件是另一批:ws2_32.cpp(9 处)、addon_manager.cpp(5 处)、addon_manager.hpp(3 处)、runtime_gui.cpp(3 处)——注意 ws2_32.cpp:这是 ReShade 用来做网络活动检测的那一个(第五篇 7.5 提过"检测到联机即关闭")。受限构建里出现了 9 次网络检测的判断,与本篇第八章那套"加载被跳过"的逻辑是同一条链。

三档 #if 的仓库级计数:428 / 25 / 291,覆盖 96.1% 的原件
图 6-20三档 #if 的仓库级计数:428 / 25 / 291,覆盖 96.1% 的原件@ 219 份官方原件仓库级计数(随篇归档,脚本现读)

一处缺口必须写明:228 份文件里 9 份没取到(opengl_hooks.cpp、vulkan_hooks*.cpp、platform_utils.cpp、input_gamepad.*、openvr|openxr/*)——它们在两个源上都返回 404。所以上面的三档计数覆盖率是 219/228 = 96.1%,不能写成"全仓"。这 9 份都是 hook 层文件,不在本篇的引用清单里,故不追。

7.4 一处边界:这个宏的定义不在源码里

顺带说一句:我在 include/ 与 source/ 里找不到 RESHADE_ADDON 的 #define。它的定义就在上面那张表的工程文件里——由构建系统注入。

这一点在工程上有含义:读源码的人看不出"这个构建的能力边界",得去读工程文件。所以一个只看源码的分析,很容易得出"文档里 100 个事件都能用"的结论——而实际能不能用,取决于你手上那个 .dll 是按哪一档编出来的。这也是本篇把"文档"与"构建"分开说的原因。

7.5 为什么 25 处开关能决定一整类事件的存亡

上面那张三档计数的表里有一个比例值得单独拎出来:== 1 只有 25 处(8 个文件),而被它挡掉的事件有 30 个。

25 处代码,管 30 个事件——这不是算术错误,而是"中心化开关"的正常形态:那 30 个事件不是被 25 处分头关掉的,而是被一处关掉的——addon_manager.hpp 里那一处 static_assert(下一章第一节逐字给出)。一处判断,覆盖一整片能力。

这正是"用一种写法管住全局"的好处:要加一个新事件到"受限构建里禁用"的名单上,只需要往那一个列表里加一行,不需要去后端实现里找所有挂钩点。代价是对读代码的人不友好——你得先知道"这件事是被集中关掉的",才会去找那个列表;只看某个后端文件,会以为那个事件在受限构建里也能用(源码里它明明被 #if RESHADE_ADDON 包着,而 == 1 是 >0 的)。

一句话记法:#if RESHADE_ADDON 管"有没有能力",== 1 管"受限时怎么办",而"受限时具体禁用哪些"集中在 addon_manager.hpp 一张清单里。

7.6 这套接口的演进:从 5.0 到当前 main

依然是惯例,这一节补一个"时间坐标"——因为本篇讲的所有结构都不是一开始就有的。这套接口的演进轴不是年份,而是版本号(ReShade 的发布说明以版本为纲):

版本 与 add-on 相关的变化 出处
5.0 add-on API 首次出现(发布说明第一句就是 "Added add-on API",作者自己加了句"这值得写一整段,但还是算了");同版把深度缓冲检测改写成内置 add-on("Reworked depth buffer detection into a built-in add-on");两种构建也是这一版一起带出来的 5.0 发布说明
6.0 加了三个 EffectRuntime* 导出(挂钩被关掉时手动管理 effect runtime);add-on 名进预处理器定义(形如 ADDON_GENERIC_DEPTH=<version>);安装工具加了 add-on 选择页;imgui 支持集合为 1.90/1.89.7/1.86 6.0 发布说明
6.5 create_device 事件被用于 API 版本升级 6.5 发布说明
6.8 多条带 "(Add-on support)" 标注的变更:begin_render_pass 的回调签名允许跳过原调用、内存堆枚举值改名 6.8 发布说明
当前 main RESHADE_API_VERSION = 20;imgui 钉在 19250;事件表 100 个成员、23 个显式赋值、值 7 空缺 include/reshade.hpp/reshade_events.hpp

这条演进线解释了两件事:

  1. 为什么事件编号里会有那么多种"资历"(第三章)——因为这张表是五六年里一格一格长出来的:5.0 那一批拿的是小数字,6.x 补的是 84 以上的数字,而 create_device = 96 这种"创建类事件排在销毁类之后"的错位,正是"后来补的必须拿新值"的必然结果;
  2. 为什么"两种构建"不是后来打的补丁——它是 5.0 与 add-on API 一起出生的:这套接口从第一天起就带着"最强大的那一档不给签名版"这个前提,不是先放开、后来才收紧。

八、有限构建下做不到什么

好了,这一章回答那个最实用的问题:签名版 vs Addon 版,差在哪 30 个事件上?

8.1 被编译期禁掉的 30 个事件

先看这段代码在哪。答案在 source/addon_manager.hpp 里。那段代码是一个 static_assert,它的作用不是"运行时检查",而是在编译期把某些事件直接判成错误——末行的错误串写着(逐字):

Event that is disabled with limited add-on support was used!

也就是说:如果你在 = 1 构建里注册了这些事件之一,编译会直接失败,而不是运行时悄悄不生效。 被点名的 30 个事件是(逐字列出):

barrier、begin_query、bind_descriptor_tables、bind_index_buffer、bind_pipeline、
bind_pipeline_states、bind_stream_output_buffers、bind_vertex_buffers、
build_acceleration_structure、copy_acceleration_structure、copy_buffer_region、
copy_buffer_to_texture、copy_descriptor_tables、copy_query_heap_results、copy_resource、
copy_texture_region、copy_texture_to_buffer、end_query、generate_mipmaps、
get_query_heap_results、map_buffer_region、map_texture_region、push_constants、
push_descriptors、resolve_texture_region、unmap_buffer_region、unmap_texture_region、
update_buffer_region、update_descriptor_tables、update_texture_region
签名版构建里被编译期禁掉的 30 个事件:按前缀归成六组
图 6-21签名版构建里被编译期禁掉的 30 个事件:按前缀归成六组@ 官方源码 source/addon_manager.hpp(随篇归档,脚本现数)

这 30 个不是随便挑的。 把它们按功能归一下类,规律立刻出来:

类 个数 例子
绑定类 7 bind_pipeline、bind_descriptor_tables、bind_vertex_buffers
拷贝类 7 copy_texture_region、copy_buffer_region、copy_descriptor_tables
映射类 5 map_buffer_region、map_texture_region、unmap_*
推送类 4 push_constants、push_descriptors、update_descriptor_tables
查询类 4 begin_query、end_query、get_query_heap_results
屏障与杂项 3 barrier、resolve_texture_region、generate_mipmaps

一句总结:被砍掉的全是"能改渲染命令本身"的那一类。 留下的那批以生命周期事件(init_*/create_*/destroy_*)与 reshade_* 专有事件为主——也就是"能看、能在创建前改描述、能领到自己那一份",但不能替应用发命令。

四组事件 × 三档构建:被编译期关掉的是哪一片
图 6-22四组事件 × 三档构建:被编译期关掉的是哪一片@ 本系列绘制

这张矩阵图把第三章那张"四组事件"的表与本章的 30 个被禁事件叠在一起,得到一个可以直接用的判断:签名版能拿到"生命周期组"的全部、'ReShade 专有组'的全部,以及"命令与状态组"里只读的那一部分;拿不到的是"资源与拷贝组"的大半与"命令与状态组"的改写能力。

8.2 与官网那两句话对上

回头看官网两句,会发现它们说的正是这张矩阵:

  • 受限构建"does not allow loading add-ons"——注意这不等于"功能少":它连外部 add-on 都不加载(第八章第三节的日志串会给出实证),只在内部保留"够让内置深度检测在单机里工作"的那一小部分 hook;
  • 全量构建"does not do any multiplayer detection to disable features"——它把安全策略整个拿掉了,代价是"not whitelisted by anti-cheat providers"。

所以"两种构建"的实质是一次取舍:要么放弃 add-on,要么放弃反作弊白名单。中间的第三条路——"我既要 add-on 又要白名单"——在这套设计里不存在。这不是技术做不到,而是反作弊厂商的判定标准不由 ReShade 决定。

这一处关系,是本篇最该守住"官方文字优先"的地方。 中文圈里有一种说法是"装个 Addon 版就等于开挂",另一种是"签名版功能被故意阉割了"。两种说法都过头了:前者把"未签名"读成了"必然作恶",后者把"受限"读成了"故意削弱用户"。官方原文说的是:受限是为了"在多人大厅里继续可用",全量版的存在是为了"让 API 仍然可用"——两句话都写在 5.0 发布帖里,谁也不比谁少。

8.3 加载期会发生什么

下面进入执行层。 前面几章一直说"宿主加载 add-on",这一节把加载具体到文件名、配置键和日志串。这三样都能逐字核到。

第一样:文件名后缀。 官方文档那句(逐字):

After building an add-on DLL, change its file extension from .dll to .addon and put it into the add-on search directory configured in ReShade (which defaults to the same directory as ReShade). It will be picked up and loaded automatically on the next launch of the application.

本机从 source/addon_manager.cpp 现读到的识别后缀是三个:.addon、.addon32、.addon64——后两个按位数区分(这就是"从 .dll 改名"在多架构下的完整形态)。

第二样:两个配置键。 同一个文件里现读到的配置读取是 [ADDON] AddonPath(自定义搜索目录)与 [ADDON] DisabledAddons(屏蔽名单)。后者很实用:装了 Addon 版但某个 add-on 出问题时,可以按名字把它屏蔽掉——不用删文件。

第三样:加载期的五条日志串。 本机在 addon_manager.cpp 里现读到 14 处加载相关日志,其中五条最能说明问题(逐字,附行号):

行 日志串 什么时候出现
241 Skipped loading add-on from '%s' because this build of ReShade has only limited add-on functionality. 签名版遇到了外部 add-on——直接跳过并告警
290 Failed to load add-on from '%s' with error code %lu! 文件映射失败
310 Failed to load add-on from '%s' because initialization was not successful! AddonInit 返回了失败
324 No add-on was registered by '%s'. Unloading again ... 文件进去了,但没调 register_addon——卸载掉
285 / 305 > Add-on failed to initialize or is disabled. Skipped. 初始化失败或列在屏蔽名单里

第 241 行那条就是"为什么我的 add-on 没被加载"最常见的答案。 而第 324 行那条对应第一章那句"第一步"——如果 add-on 忘了调 register_addon,宿主不会假装它不存在,而是把它卸掉。

加载顺序也在这里定下来:先加内置 add-on(本机从官方示例清单里可以点出两个:09-depth 与 15-effect_runtime_sync),再按目录遍历加载外部 add-on,最后统一置 addon_all_loaded。内置优先这一条有实际后果——第五章说过"同一事件上按注册顺序调用",那么内置 add-on 永远排在外部 add-on 前面,也就永远先看到、先有机会接管。

8.4 三种设计模式类图的对照:职责链

本章最后回到第五章那个结论:这套接口更像拦截器链。把它和"最接近的那个类比"摆在一起看。

职责链模式类图:handleRequest 在链上依次传递
图 6-23职责链模式类图:handleRequest 在链上依次传递@ Wikimedia Commons · Vanderjoe(CC BY-SA 4.0)

这是外面找的一张真图(Wikimedia Commons,CC BY-SA 4.0,700×240,只作原理小图):职责链(chain of responsibility)的类图。图上每个处理者都实现 handleRequest,并且可以选择"我处理了,不往下传"或者"交给下一个"。

这正是 create_* 系列事件返回 bool 的形状:返回 false 等于"我不管,继续",返回 true 等于"我接管了,别再往下"。第五章那张对比表里的第三行"拦截器链",说的就是这个。

但职责链也有一处对不上:职责链上每个处理者通常只关心自己那一段,处理完就结束;而 ReShade 这套接口是广播式的——同一个事件上注册的回调全都会被调用(void 型事件),只有在 bool 型事件上,"有人返回 true"才会截断本次调用。

所以准确的说法是:它是"广播 + 可截断"的混合体。 用它自己的名字说就是——一个事件总线,其中一部分事件带拦截语义。这个说法比"观察者"或"发布订阅"都更贴。

8.5 一处流传说法的纠正

顺手纠一个中文圈里流传较广的说法:"add-on 就是把 ReShade 的导出函数拿来调用,所以它跟普通插件一样有 ABI。"

这句话里"有 ABI"是对的,但"跟普通插件一样"是错的,差别有两处:

  1. 方向反了——普通插件是"插件导出一批函数给宿主调";add-on 是"add-on 调 ReShade 导出的 18 个函数",add-on 自己什么都不用导出(第一章那句原话);
  2. 两边都不用"同一个运行时"——register_event 传过去的是一个函数指针,而不是一个接口对象。这意味着回调的地址在进程内直接可调,不需要 COM 那种虚表约定,也不需要 C++ 名字修饰(name mangling)——这也是它能用纯头文件实现的前提之一。

这两处差别合起来,解释了为什么这套接口对 add-on 作者那么"轻":一个 DllMain + 一次注册 + 若干个静态函数,就是完整的一个 add-on。轻的代价在第 0.4 节第 2 条那里:文档是全量的,你手上的构建未必是。

8.6 叠加层这一族:ImGui 是怎么被交出去的

好了,本章最后补一块前面几章一直绕着走的内容:add-on 想在屏幕上画界面,靠的是什么?

答案是 register_overlay。它的注释(逐字):

Registers an overlay with ReShade. The callback function is then called when the overlay is visible and allows adding Dear ImGui widgets for user interaction.

inline void register_overlay(const char *title, void(*callback)(api::effect_runtime *runtime))

三处细节:title 传 nullptr 时是"给这个 add-on 注册一个设置叠加层"(官方参数说明原话);回调收到的是 api::effect_runtime *——也就是第四篇讲的那个运行时对象;界面只在叠加层可见时才会被画,不可见就不调。

配套的两个事件也在这张 100 个格子的表里(本机现数,属于 reshade_ 那一族):reshade_overlay_uniform_variable 与 reshade_overlay_technique——"某个 uniform 变量/某个 technique 的控件即将被加上去"。官方对后者的说明是 "To prevent the normal widget from being added to the overlay, return true"——又是一个 bool 型事件,又一处"可以拦下来"。

但这一族里最有意思的不是注册,而是 ImGui 本身怎么被送到 add-on 手里。 打开 include/reshade_overlay.hpp,会看到一件在 C++ 项目里不常见的事:

  • 文件开头就把版本钉死了(逐字):
#if IMGUI_VERSION_NUM != 19250
#error Unexpected ImGui version, please update the "imgui.h" header to version 19250!
#endif
  • 然后定义了一个函数表结构体:struct imgui_function_table_19250,里面是一长串函数指针(GetIO、GetStyle、Begin、EndChild……);
  • 最后在 namespace ImGui 里把 ImGui 的 API 重声明了一遍,而实现全是转发:
inline const imgui_function_table *&imgui_function_table_instance()
...
namespace ImGui
{
    inline ImGuiIO& GetIO() { return imgui_function_table_instance()->GetIO(); }
    inline bool Begin(const char* name, bool* p_open, ImGuiWindowFlags flags) { return imgui_function_table_instance()->Begin(name, p_open, flags); }

把它读懂,就懂了一件本来会让人困惑的事:add-on 不是把 ImGui 静态链接进去的,而是通过 ReShade 提供的函数表调用 ImGui。这正是那行 #error 存在的原因——如果 add-on 自己带一份别的版本的 imgui.h,两边对不齐(函数表里的成员顺序是按 19250 排的),调下去就是召唤错函数。用一个 #error 在编译期拦住,比让它运行期崩掉好得多。

这里有一处与官方发布说明的"时间差":6.0 发布说明写的是"add-ons may now use 1.90, 1.89.7 or 1.86",而当前 main 上的头文件只接受 19250 一个版本。两句话都对,只是时点不同——发布说明记录的是当时的支持集合,头文件钉的是当下的版本。这也解释了为什么 add-on 与 ReShade 的版本关系比一般插件更紧:不只是 API 版本(RESHADE_API_VERSION = 20)要对上,连第三方 UI 库的版本都在编译期被钉住。

最后是一处边界,照实写在墙上:官方论坛有一条高频提问是"能不能直接把 ImGui 的 context 拿出来自己用",而答复是 API 不暴露 ImGui context。这与第五篇 1.2 节那条"给寄生者用的接口面"是同一个取向:凡 add-on 不需要碰的,就不给。能画控件,但不能接管界面框架——这条线画得很清楚。

九、三条结论与路线图

9.1 本篇三条结论

结论一:add-on API 的三样东西是"表、符号、开关"。 一张 100 个格子的事件表(addon_event)、一组跨 DLL 边界的 18 个 C 导出、一个三档取值的构建开关(RESHADE_ADDON)。三者缺一,"add-on 能用"这句话就不成立:表决定"哪些时刻能被插手",符号决定"注册靠谁完成",开关决定"那张表里有多少真的可达"。

结论二:这套接口是"广播 + 可截断"的混合体,不是观察者。 100 个事件里,返回 void 的是通知、返回 bool 的是拦截(create_device、create_swapchain 就是后者);同一个事件上注册的回调全都会被调用,只有在 bool 型事件上"有人返回 true"才截断本次。把这条分清,才不会把"我收到了事件"当成"我能改变它"。

结论三:编号与开关都是"给编译产物留的兼容底线"。 23 个显式赋值把值钉死、值 7 报废而不回收、max = 101 按 0–100 分配——这三件事服务的是同一个目标:三年没更新的 add-on 不能因为新增特性而认错事件。而三档构建开关服务的是另一个目标:在"多人大厅里继续可用"与"add-on 能力全开"之间做取舍,中间路线不存在。

9.2 从这一篇往后怎么走

四段交接,都用文字说清(这里特意不发图——"与后几篇的接口"属于系列导航,按写作硬规则不占图位):

  • 第七篇(深度缓冲的工程学)接"内置深度 add-on"这条线——本篇只在 8.3 节提到 09-depth 是内置 add-on,而"内置"这件事正是它能在受限构建里活下来的原因(第五篇 7.5 讲的那次行为变更也落在它身上);
  • 第八篇(效果生态与许可地图)接"两种构建"这条线——本篇讲的是构建层面的取舍,第八篇讲的是效果文件层面的许可;
  • 第九、十篇(自跑)接"回调真的被调到了吗"这个问题——本篇只给到"注册与派发"的机制,真跑起来是什么样,留给自跑篇;
  • 第十一篇收总账。

9.3 三类读者的最短路径

  • 只想知道"add-on 到底是什么":本篇第零、一章,加第四篇的第三章(effect runtime 是什么)就够;
  • 正在写 add-on、被"没被加载"卡住:直接跳到第 8.3 节那五条日志串,再看第七章那张 10 个配置的表;
  • 想搞清楚"事件驱动接口怎么做才对":第四、五章是核心——签名在编译期钉死(traits 表)+ 按事件分桶派发 + 用返回类型区分通知与拦截,这三条是可以直接搬到别的项目里的。

9.4 本篇与后面五篇的接口

  • 本篇讲接口面,第七篇讲一类效果的工程条件:本篇只说清"深度 add-on 是内置的、它注册在哪个事件上",为什么深度类效果时灵时不灵、MSAA 下深度为什么会错位,一律留给第七篇;
  • 本篇讲构建的取舍,第八篇讲分发的规则:本篇讨论"签名版不加载外部 add-on",第八篇讨论"哪些效果文件能分发、哪些不能";
  • 本篇不给运行期结论:本机没有两份二进制,所以"某个 add-on 在受限构建下会怎么失败"只在编译期层面回答(会被 static_assert 挡下),运行期一律不写。

资源清单

官方一手来源

官方论坛(含 crosire 本人的答复)

横向参照与中文资料

图片素材(Wikimedia Commons,逐张核过许可与图内文字语言)

下篇预告

下一篇是《深度缓冲的工程学:自动关闭、MSAA、UI 缺失与子矩形错位》——把"为什么深度类效果时灵时不灵"讲透。本篇提到的那两个内置 add-on 里的 09-depth,会在下一篇里被拆开:它怎么找到应用的深度缓冲、为什么在 MSAA 下会错位、为什么 UI 一画上去深度就没了。

写在最后

这一篇是系列里最"接口"的一篇:它讲的不是画面,而是一套让第三方能插手的约定。按表注册、按签名编译、按构建取舍——三件事说清了,add-on 这件事就不再神秘;反过来,这三件事里任何一件没搞清,写出来的 add-on 都会以"没被加载""不生效""编不过"这三种形式回报你。希望能对大家有所帮助——尤其是那句"no functions need to be exported"背后,其实站着一整套必须自己搞清楚的约定。