由于我的博客是通过 Hexo-Git 部署的,所以这次一提交,也掉坑里了。
直接 hexo d
,首先显示的是如下的错误消息(截取开头):
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
接着阅读可以发现是检测到密钥不匹配,而且开启了强制密钥验证,为了防止中间人攻击,所以就报错了。因为我以前通过 SSH 方式进行过 Git 提交,所以本地的密钥数据库(.ssh/known_hosts
)存在主机记录。所以先按那篇文章所说的,删除旧的密钥:
ssh-keygen -R github.com
执行完成后我再尝试推送,但遇到了另一个问题:
Error: Host key verification failed.fatal: Could not read from remote repository.Please make sure you have the correct access rightsand the repository exists.
因为此时我还没有手工通过 SSH 方式提交,所以数据库不存在记录。为了解决这个问题,我在 GitHub 上加回了本机的私钥(忘了是什么原因没掉了,之前是2017年设置的,呜呜)。但是这个问题仍然存在。有没有能不通过 SSH 进行一次 Git 拉取/提交而刷新 SSH 密钥的方法呢?(明显,直接 SSH Shell 上 GitHub 是不可能的。)
我对 SSH 工具不熟,所以还是找了一下。根据这篇文章的指示,运行一下 ssh-keyscan
就行:
ssh-keyscan github.com
然后将输出的内容加入到 known_hosts
里面。之后就正常了。
此外,还可以通过 -F
查询主机是否已知,或用 -l
列出所有已知主机:
ssh-keygen -F github.comssh-keygen -l
]]>如果你使用的是 UGUI,项目中仅使用了 Input System(Player
- Configuration
- Active Input Handling
选择了 Input System
),需要将其默认的 Input Manager 后端换为 Input System 后端。这里有操作指南。简单来说,直接找到场景中的 EventSystem
对象(通常是这个名字),将上面的 Input Module
换为 Input System UI Input Module。默认情况下,直接点击那个迁移按钮就行。
大多数情况下,你是不需要关心控制方案的,直接针对平台绑定输入源(按键、轴向输入)就行了。我一开始就是这么做的。这里以我做的玩意儿简化后的情况为例:
这张图值得注意的是,每个平台都分配了惯例的确认和取消按键(PlayStation 上用的是亚洲方案),即使它们的方位不一样。另外,所有控制都共享一个控制方案,没有独立的控制方案,这样的 input actions 资产应用后是这个样子:
这个文件粗看上去没什么问题。本地测试中,我测试了键盘和 XBox 手柄,两种控制器可以同时响应,手柄也是即插即用,不需要专门做控制切换的设置页面。(这是一个单人游戏,所以不用考虑本地多人之类的事情。)
然而,彩喵拿到 Switch 上测试之后反馈说,ABXY 根本没有响应。嗯?那 Unity 整一个专门的 Switch Pro Controller 控制器子类干什么呢?我不是针对这个 controller 分配了键 AB 吗?你怎么就设置设置得上,运行运行不起来呢?
答:你不能直接使用 ABXY 绑定,而是需要用 GamePad.buttonEast
、GamePad.buttonSouth
、GamePad.buttonNorth
、GamePad.buttonWest
来访问。也就是说整个 controller 必须退化成一个 generic D-pad controller,不能使用 Switch Pro Controller 子类。但是,如果你直接这么把原先分配给 Switch 的 A 和 B 替换成 buttonEast
和 buttonSouth
,就会发现一个问题:虽然我们的目标是仅在 Switch 上使用它,但它在 PC 上也抢了 XBox 手柄的键位,导致 XBox 手柄的 A 和 B 变成了未定义行为。
到此直接分设备设置按键的尝试就失败了。怎么解决呢?我想到了分多钟控制方案。所以,第二版的是这样的:
首先在左上角添加四种控制方案:Keyboard
、XBox
、PlayStation
和 Switch
。添加时记得把对应的设备需求选上。这里以 XBox
为例:
然后,在主编辑界面将每个按键分配给对应的设备。这里还是以 XBox 为例:
设置到 Player Input 上之后:
可以注意到在 Actions
下多了两项:Default Scheme
和 Auto-Switch
。这两项的意义可以阅读工具提示来理解。前者控制的是默认的控制方案,如果未指定(<Any>
)则会寻找第一个匹配到的;后者决定如果某个方案因为设备不满足而失效(例如,手柄断线),是否会尝试寻找并应用另一个可以匹配的。一般情况下,如果是全局只存在一个 Player Input,那选择 <Any>
和 auto-switch 是完全足够的。关于匹配过程可以参考 Input System 的文档,一句话概括就是匹配成功项最多者胜出。
看起来好了,那运行起来吧。等等,为什么手柄没反应,只支持键盘了?刚才不是好好的吗?我不是设置了 XBox 手柄的控制方案吗?为什么它没有同时启用呢?于是我开始找诸如“Unity input system control scheme”之类的网页,可惜基本没有解释。就算是 Unity 自己的页面,里面的演示视频也只是简单添加了两种 control scheme 然后就正常运行了,看起来能动态切换,和我实测的并不一样。
直到我找到 Unity 官方论坛上一个讨论多个控制方案的帖子。其中一个开发者的回帖解答了我的疑问。(我之前也找过 Input System 相关的帖子,也见过这位老哥,他应该就是 Input System 的主要开发者之一。)以下是帖子节选,我加粗了重要部分:
… Control schemes are only really relevant if you want to separate multiple different means of controlling your game/app from each other such that they are mutually exclusive and such that you can know which one is active at any one time (e.g. to display control hints).
So in effect, the intention for having two control schemes, one for keyboard and one for gamepad, would be that keyboard input is not meant to be used concurrently with gamepad input. While the keyboard bindings are active, the gamepad bindings will not be. If both are in the same control scheme, however, they will concurrently be active side by side. …
本质上,不同控制方案之间是互斥的,同时只能采用一种。如果你玩过一些老的主机移植游戏,就很好理解:那些游戏不支持同时使用键鼠和手柄,而且也未必支持动态插拔,如果你想换控制器就需要进入游戏里专门的设置页面去切换。
到此问题也就有了个答案。如果希望同时支持键盘和手柄,而且不想做专门的手动切换,那么就应该是按照游玩设备分类,而非控制器分类。将 Keyboard
方案重命名为 PC
方案,然后添加可选设备:
接着,将 PC 上可用的手柄输入也选上。这里以 XBox 为例:
图中 XBox 手柄的 A 不仅在 XBox 上有效,也在 PC 上有效。而在 PC 上,键盘也是有效的。(PlayStation 控制器同理。)所以,在 PC 上,你就可以同时接收到键盘和手柄输入了。
然而设置完毕后还有一个问题。可以见到对于“确认”行为,我们为手柄指定了三种按键:
<XInputController>/buttonSouth
)<DualShockGamePad>/buttonEast
)buttonEast
(<GamePad>/buttonEast
)如果我没设置控制器需求的话,由于 XInputController
是 GamePad
的子控制器类型,所以通用 GamePad
的设置也会被响应,就出现了前文的未定义行为问题。但是理论上,既然我设置了控制器需求,而且我没有接入 Switch Pro 控制器,那最后一条应该不能响应才对。但是在实测中,虽然 input system 自动选择了 PC 方案,但 Switch 方案仍然会被响应。我认为这是 input system 的一个 bug。
这个bug要完美解决的话比较麻烦。不过在我的目标工程里不需要搞手动选择也不需要考虑多人,所以可以通过以下方法绕过去:保持 Default Scheme
为 <Any>
,但是把 Auto-Switch
关了。该方法在 PC 和 Switch 上测试通过。(所以为什么 Switch 上又能识别 Switch Pro 控制器需求——HID,但直接配置特化不行呢?神秘。)
Input System 是带有调试工具的,可以方便地看到当前连接的设备、采用的方案、按键映射等等,还有一些其他功能(给虚拟设备和远程设备用的),直接单击任意 Player Input 右下角的按钮即可。比如,正确设置后,运行时可以看到如下的界面:
从图中可以看到:
.inputactions
资产中定义的行为,实际被映射到了哪些控制器的哪些键/轴。Confirm
和 Cancel
两个行为都同时绑定了 buttonSouth
和 buttonEast
,因此按下后实际触发哪个是不确定的。04-10: 配置的 control scheme 还是不能生效,跟 Auto-Switch
没关系。最后还是改加了一个手工过滤器,如下(健壮性不强,不过暂时够用):
using System;using UnityEngine;using UnityEngine.InputSystem;namespace Example{ public static class InputActionExtensions { public static bool MatchesExpected(this InputAction action) { // if (!PlayerInput.isSinglePlayer) // { // Debug.LogWarning("Multiple players detected. Please fix PlayerInput setup."); // return false; // } var player0 = PlayerInput.GetPlayerByIndex(0); if (player0 == null) { return false; } var activeControl = action.activeControl; var bindings = action.bindings; var schemeName = player0.currentControlScheme; foreach (var binding in bindings) { if (binding.groups.IndexOf(';') >= 0) { var groups = binding.groups.Split(GroupSeparator, StringSplitOptions.RemoveEmptyEntries); var found = false; foreach (var group in groups) { if (group == schemeName) { found = true; break; } } if (!found) { continue; } } else { if (binding.groups != schemeName) { continue; } } var bindingPath = binding.effectivePath; if (InputControlPath.Matches(bindingPath, activeControl)) { return true; } } return false; } private static readonly char[] GroupSeparator = { ';' }; }}
使用示例:
public void OnAction1(InputAction.CallbackContext context){ if (context.performed && context.MatchesExpected()) { // Logic here }}
]]>再次开机,发现登录界面也无法用键盘控制(包括输入)了。没办法,先用屏幕键盘凑合一下。进去后打开设备管理器,发现键盘消失了,即插即用设备倒是多了一个无法读取设备描述符的设备(错误代码 2B
)。禁用、卸载、重启数次依然如此。
由于我还有一个台式机,用的是 USB 键盘,所以先拿它来救急。我发现,在进入 BIOS 后,外置键盘是可以用的,笔记本自己的键盘倒是不行(CapsLk、NumLk 也不亮)。那么是不是固件问题呢?手动从 BHCN42WW
更新到 BHCN44WW
,键盘依然无法使用。
这时候我发现了一个有趣但无法解决问题的事实。Y540 的键盘是有背光的,使用 Fn+空格调整亮度。现在,无论是在 Windows 下还是在 BIOS 中,这个功能都是可用的。另外,我能进入 BIOS,说明至少 F2 也是在那之前好好工作了的,要不根本没法进。也就是说,键盘的硬件(电路板、键帽、排线等等)应该是没问题的,大概率是软件出了意义不明的问题。
然后在联想自己的用户论坛上找到了这么一个帖子,让人把电池耗尽再开机。我试了试,断开电源,低电量时 Windows 自动休眠了。接回电源,再开机,键盘仍然没有恢复。我觉得这个方法行不通。但是在我第二天早上重启之后,NumLk 亮起来了。再一试,登录界面有响应了。也就是说,在接入电源后,你得再重启一次,让某个固件刷某个东西才行。
后来搜索发现,联想笔记本的键盘问题发生频率不低,断电法这种让人费解的办法甚至是一些网站推荐的在换修前的最终手段。也有因为联想自己固件更新导致硬件错误(而且无法回滚)的愤怒老哥,还有其他愤怒老哥(1、2)。究竟如何,还是大家评判吧。
]]>I found this problem when writing a piece of code. An example is demonstrated below:
using System;using System.Collections;using System.Collections.Generic;class Program{ static void Main() { var e = new SomeEnumerable(); foreach (var x in e) { Console.WriteLine("({0}, {1})", x.First, x.Second); } }}class SomeEnumerable : IEnumerable<(int First, int Second)>{ public IEnumerator<(int First, int Second)> GetEnumerator() { throw new NotImplementedException(); } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }}
The code compiles. That’s expected. But then I began to think, that the tuple ((int First, int Second)
) is such a burden, because it spreads everywhere. Several classes have the similar definitions and usages, and those tuples all have the same semantics. According to the C# syntax, tuples equivalence are compared structurally. Element names are just a syntax sugar backed by attributes. So, it should be okay to erase the names in the generics of interface, but keep them in the returned value to provide their semantics to the invoker. In other words, the code should be like this:
// ↓ notice the tuple hereclass SomeEnumerable : IEnumerable<(int, int)>{ // ↓ and here public IEnumerator<(int First, int Second)> GetEnumerator() { throw new NotImplementedException(); } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }}
Guess what? I got a compile error. But what if I use the returned tuple without explicitly mentioning their element names?
// ↓ directly deconstruct the tupleforeach (var (f0, f1) in e){ Console.WriteLine("({0}, {1})", f0, f1);}
When SomeEnumerable
is unchanged, the code above can be compiled without any error.
Strange, isn’t it? It seems sometimes the tuples are structually equivalent, and sometimes they are not. Does it mean I have to keep all the frustrating (int First, int Second)
pieces? Is it a negligence in language design, or some other problem?
Therefore I searched the specification and other documents. Unfortunately, by now the specification is only updated to language version 5.0. Microsoft Docs only has the full reference until the draft of version 6.0 (and it is not even a specification). Tuples were introduced in C# 7.0 and there is only an incremental language reference (only mentioned the lowest language version but not in an explicit branch). Well, C# 9.0 will be out soon. Unfortunately, there is no documentation of the compiler behaviors I presented above.
Then, according to the error code (CS8141
) I was able to find dotnet/orleans#3421. One of its discussion mentioned dotnet/roslyn#20838, a pull request (PR) that relaxed the matching rules for tuple element names.
In C# 6.0, the types System.ValueTuple<...>
are supported but the compilation will fail if the referened assembly is authored in C# 7.0 and custom tuple element names are used (dotnet/roslyn#20528). dotnet/roslyn#20838 fixed that problem by allowing names mismatch under certain situations. That is to say, the code below (taken from dotnet/orleans#3421) could not compile before, but can compile after that PR was merged:
using System.Threading.Tasks;public interface ISomeGrain{ Task<(bool someBool, string someString)> Func();}class Impl : ISomeGrain{ public Task<(bool, string)> Func() { return Task.FromResult((true, "")); }}
However, this doesn’t solve my problem. My case is different from above and it can’t compile. According to dotnet/orleans#3421, this problem was fixed in .NET Core 2.1, but I still can’t compile on .NET Framework 4.7.2 and .NET Core 3.1 SDK. Is it a software regression?
I couldn’t find other PRs related to fixing tuple behaviors, so I read dotnet/roslyn#20838 carefully. The thing that interests me is the testing code added in this PR. Its snapshot (of the full file) in July 2020 is here.
After reading the tests I think I understand why it is like that: it is an error-tolerant design. All tuples (the ones that the syntax sugar use) are System.ValueTuple<...>
types, and they can be assigned whenever implicit casts succeed. Moreover, you can use whatever name you like for the tuple elements (after C# 7.0). And those, may break code contracts and introduce subtle but deadly errors. Consider the code illustrated below:
interface SomeInterface{ (string Name, string Address) GetContactInfo();}class SomeClass : SomeInterface{ // notice the element names of the tuple here // and the one defined in the interface public (string Address, string Name) GetContactInfo() { // ... }}
Calling GetContactInfo()
via SomeClass
or SomeInterface
will give you confusing results. So the best solution is prohibiting the change in element names.
Let’s head back to the problem at starting point. Unfortunately, if I want to preserve the semantics in code, I have to keep (int First, int Second)
everywhere. I can’t omit some of them and write (int, int)
instead. So be it.
In the unit tests of CodeGenTupleTest.cs
there are several error codes. After reading the invesigation process above, you should have understood why they are designed like that. However, the error messages are still unclear and confusing. They would be better with hints, like what GCC or Clang does. So here I will explain them with some examples. For each error there may be more than one way to be triggerred, and the examples below are the most simple ones.
CS8139
: Checked when overriding a method whose signature contains tuples. Thrown when element names in base and child classes differ.
class Base{ public virtual (int, int) TupleAsReturn() { // ... } public virtual void TupleAsParam((int A, int B) value) { // ... }}class Derived : Base{ // ↓ CS8139: cannot change tuple element name when overriding method public override (int A, int B) TupleAsReturn() { // ... } // ↓ CS8139: cannot change tuple element name when overriding method public override void TupleAsParam((int, int B) value) { // ... }}
CS8140
: Checked when one of a interface I
‘s generic parameter T
is instantiated with a tuple. Thrown when the I
‘s implementation implements multiple I
s but T
s are the same tuple with different element names.
interface IGeneric<T>{}// ↓ CS0528:IGeneric<(int A, int B)> already exists in the implementation listclass Class1 : IGeneric<(int A, int B)>, IGeneric<(int A, int B)>{}// ↓ CS8140:IGeneric<(int A, int B)> already exists in the implementation list, with different tuple element namesclass Class2 : IGeneric<(int A, int B)>, IGeneric<(int C, int D)>{}
CS8141
: Checked when one of a interface I
‘s generic parameters T
is instantiated with a tuple. Thrown when T
s have different element names.
interface IGeneric<T>{ T Get(); void Set(T t);}class Class1 : IGeneric<(int A, int B)>{ // ↓ CS8141: tuple element names must match with interface method public virtual (int, int) Get() { // ... } // ↓ CS8141: tuple element names must match with interface method public virtual void Set((int, int) tuple) { // ... }}class Class2 : Class1, IGeneric<(int C, int D)>{ // ↓ CS8141: tuple element names must match with interface method public override (int C, int D) Get() { // ... } // ↓ CS8141: tuple element names must match with interface method public override void Set((int C, int D) tuple) { // ... }}
CS8142
: Checked when defining a partial method whose signature contains tuples. Thrown when element names differ in declaration and definition.
partial class Class{ partial void Method((int A, int B) tuple);}partial class Class{ // ↓ CS8142: both parts of partial method must use the same tuple element names partial void Method((int, int) tuple) { // ... }}
]]>这是我在写一段代码时偶然发现的。示例代码如下:
using System;using System.Collections;using System.Collections.Generic;class Program{ static void Main() { var e = new SomeEnumerable(); foreach (var x in e) { Console.WriteLine("({0}, {1})", x.First, x.Second); } }}class SomeEnumerable : IEnumerable<(int First, int Second)>{ public IEnumerator<(int First, int Second)> GetEnumerator() { throw new NotImplementedException(); } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }}
以上的代码编译通过,没有问题。然后我就想,这个元组((int First, int Second)
)写起来太麻烦了,几个类里都有相似的使用的地方,语义都是一样的。而且根据 C# 的语法,元组是可以判断结构等同的,字段的名字只不过是个有特性支持的语法糖而已。那么,我就想将实现的接口相关类型的字段名称抹除,只在返回值提示字段意义。也就是说,改成下面这个样子:
// ↓ 注意这里的签名class SomeEnumerable : IEnumerable<(int, int)>{ // ↓ 和这里不一样 public IEnumerator<(int First, int Second)> GetEnumerator() { throw new NotImplementedException(); } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }}
你猜怎么着?编译失败了。但是如果我使用的时候不加名字呢?
// ↓ 直接解构foreach (var (f0, f1) in e){ Console.WriteLine("({0}, {1})", f0, f1);}
在保持 SomeEnumerable
为原状的情况下,上面这一段代码也可以正常编译。
怎么这么奇怪,有时候是结构等同的(只需要看字段顺序,不看字段名称),有时候不是结构等同的(字段顺序和名称都要看)?那岂不是我每个地方都得写上冗长的“(int First, int Second)
”了吗?这是语言设计的缺漏还是其他什么问题?
于是我去找了这部分的规范和文档。可惜,标准目前只到 5.0,Microsoft Docs 上只到 6.0 的草稿。而元组是在 C# 7.0 引入的,相对正式的文档也只有一个参考。另外,不久之后,C# 9.0 就要出来了。很遗憾,没有任何文档记录了我上面演示的编译器行为。
接着,我根据编译器输出的错误信息(CS8141
),找到了 dotnet/orleans#3421。在其回复中提到了 Roslyn 的修复 PR dotnet/roslyn#20838。这个 PR 的内容是放宽对元组名称匹配的限制,使得元组能在不支持元组字段命名、但是能使用元组类型本身的时候(例如 C# 6.0,见 dotnet/roslyn#20528),允许元组字段名称的部分或者全部不匹配。
也就是说,以下这段代码(dotnet/orleans#3421 中的例子)在这个 PR 之前会报告编译错误,但是之后是通过的:
using System.Threading.Tasks;public interface ISomeGrain{ Task<(bool someBool, string someString)> Func();}class Impl : ISomeGrain{ public Task<(bool, string)> Func() { return Task.FromResult((true, "")); }}
但是这仍然不能解决我的问题,因为我的代码不是这种情况,并不能编译通过。根据 dotnet/orleans#3421 的描述,在 .NET Core 2.1 SDK 及之后应该是得到修复的。可是我在 .NET Framework 4.7.2 和 .NET Core 3.1 SDK 上编译,仍然无法通过。难道它是一个回归错误(regression)?
我没有找到类似的解决元组相关的 PR,所以就去看上面那个 PR 的内容。比较感兴趣的是加入的测试代码。现时点的快照是这样的。阅读了这些测试用例后才明白,原来这些编译错误就是防呆设计。理由也不难推测:元组的类型其实都是 System.ValueTuple<...>
,而且可以任意修改元组字段名称;如果类型隐式转换通过的话,按照结构相等性,编译就会通过;但是修改字段名称就意味着修改代码约定,会引入难以排查的错误。考虑以下代码:
interface SomeInterface{ (string Name, string Address) GetContactInfo();}class SomeClass : SomeInterface{ // 这里的返回值和接口规定的意义不一致 public (string Address, string Name) GetContactInfo() { // ... }}
在通过 SomeClass
和 SomeInterface
分别调用 GetContactInfo()
的时候就会产生令人困惑的结果。因此最好的方式就是禁止元组字段名称发生变化。
回到出发点的问题。所以很不幸地,如果要在代码中保留语义的话,我必须在每个地方都写上“(int First, int Second)
”,而没法在一部分地方简写成“(int, int)
”。
在 CodeGenTupleTest.cs
的单元测试中出现了若干元组相关错误码。在阅读了上面的探索过程后,你应该理解了为什么它们这么设计。但是错误信息还是有点让人搞不清在说什么(GCC:我比你强),所以在这里简单做个整理。对于每一种错误,可能有多种触发方式,这里只举出简单直接的例子。
CS8139
:在类继承时,被覆盖的方法签名中含有元组时检查。如果元组各字段名称在基类和子类中不同,则输出这个错误。
class Base{ public virtual (int, int) TupleAsReturn() { // ... } public virtual void TupleAsParam((int A, int B) value) { // ... }}class Derived : Base{ // ↓ CS8139:覆盖时不可以更改元组元素名称 public override (int A, int B) TupleAsReturn() { // ... } // ↓ CS8139:覆盖时不可以更改元组元素名称 public override void TupleAsParam((int, int B) value) { // ... }}
CS8140
:某个泛型接口的泛型的实例化类型为元组时检查。如果实现了相同的泛型接口,只有元组字段名称不同时,输出这个错误。
interface IGeneric<T>{}// ↓ CS0528:IGeneric<(int A, int B)> 已经在实现列表中class Class1 : IGeneric<(int A, int B)>, IGeneric<(int A, int B)>{}// ↓ CS8140:IGeneric<(int A, int B)> 已经在实现列表中,只不过元组元素名称不同class Class2 : IGeneric<(int A, int B)>, IGeneric<(int C, int D)>{}
CS8141
:某个泛型接口的泛型的实例化类型为元组时检查。如果实现时元组字段名称不同则输出这个错误。
interface IGeneric<T>{ T Get(); void Set(T t);}class Class1 : IGeneric<(int A, int B)>{ // ↓ CS8141:元组元素名称必须和接口方法匹配 public virtual (int, int) Get() { // ... } // ↓ CS8141:元组元素名称必须和接口方法匹配 public virtual void Set((int, int) tuple) { // ... }}class Class2 : Class1, IGeneric<(int C, int D)>{ // ↓ CS8141:元组元素名称必须和接口方法匹配 public override (int C, int D) Get() { // ... } // ↓ CS8141:元组元素名称必须和接口方法匹配 public override void Set((int C, int D) tuple) { // ... }}
CS8142
:分部方法签名中含有元组时检查。如果定义和实现中的元组字段名称不同则输出这个错误。
partial class Class{ partial void Method((int A, int B) tuple);}partial class Class{ // ↓ CS8142:分部方法的各个部分必须使用相同的元组元素名称 partial void Method((int, int) tuple) { // ... }}
]]>我在土豆二周年之后就没玩土豆了。但是从2019年8月开始,陆续接到一些土豆文件变化的 issue。Unity 版本更新引起的问题这里不讨论。一个大的变化是土豆的动作文件不再公布源文件,只有“运行时”的文件。大多数动作还是有源文件的,仅有“运行时”文件的只有少数,估计是复杂舞蹈的技术限制(运行时生成内存爆炸?)或者是资源发布错误。总之两种情况都必须处理。
在老的资源列表中,这两个文件都会给出。比如,dan_shtstr_01.unity3d
(Shooting Stars 的动作)就会附带一个 dan_shtstr_01.imo.unity3d
。在这个 .imo.unity3d
中就可以看到序列化了的源文件。将它转换成完整的帧序列还是很简单的。
但是新列表就不再带这些 .imo.unity3d
了。在 issue 中提到了 Rebellion(dan_rebell_01.unity3d
)。于是我只能去看这个文件的内容是什么。原来它是几个 AnimationClip
,而且从结构和命名上分析,应该就是源文件“编译”之后的。也就是说,之前那些 .imo.unity3d
可能是不小心放进去的(自动构建时忘记排除)。也有可能是选择性地下载,如果网络差就下载源文件并在第一次播放时本地“编译”,这样可以减少数据传输量;如果网络好则直接下载成品。不管是因为什么,现在只有成品了。
那么第一步就很明显了:读取需要的 AnimationClip
。这倒不难。而且由于土豆一直用的是 Generic
而不是 Humanoid
,所以动作数据保存的是整个时间轴上的完整骨骼变换。也就是说,每一帧的所有变换的值都在。
接下来就有点麻烦了。可以看到骨骼动画中有180条曲线(curve);那些源文件的曲线数量也是180,所以很大概率是一一对应的。但是这些曲线只有一个简单的索引值,并不像源文件那样写明对应的是哪个关节。这个索引肯定是在制作骨骼动画的时候自动按照某种规则指定的,而且应该是各文件统一的。所以,我们应该能找到一个静态的映射方式(映射表),得知某个索引对应的是哪个关节的什么变换。
我们先来看一下原来的顺序:
(POSITION, AngleX)(POSITION, AngleY)(POSITION, AngleZ)(MODEL_00, AngleX)(MODEL_00, AngleY)(MODEL_00, AngleZ)(MODEL_00/BODY_SCALE/BASE, AngleX)(MODEL_00/BODY_SCALE/BASE, AngleY)(MODEL_00/BODY_SCALE/BASE, AngleZ)...(MODEL_00, PositionX)(MODEL_00, PositionY)(MODEL_00, PositionZ)(MODEL_00/BODY_SCALE/BASE, PositionX)(MODEL_00/BODY_SCALE/BASE, PositionY)(MODEL_00/BODY_SCALE/BASE, PositionZ)
先试试这个顺序吧。但是结果很不妙。为了避免精神污染,我就不放图了。
嗯……怎么找正确的顺序呢?又到了找弱点的时间了。我认为,在上文静态表的基础上,还应该有附加条件:
MODEL_00
的三个旋转分量会成一组,三个位移分量会成一组。MODEL_00/BODY_SCALE/BASE/MUNE1/MUNE2
和 MODEL_00/BODY_SCALE/BASE/MUNE1/MUNE2/SAKOTSU_L
,前者是后者的父关节。如果前者在后者之前,那么所有的父节点应该在其子节点之前,反之则是之后。但是不确定对称关节的顺序,可能是左前右后,也可能是左后右前。根据老的数据推测,是左前右后。我试了几个写起来简单的顺序,但是都不对。这下我就有点头疼了,毕竟可是有180条曲线啊。
突然,在调试的时候,我发现动画帧的数据好像有着很明显的模式。一个帧存着这帧对应时间点的所有关节的变换数据,所以看这个帧里的数组的时候相当于在对各个变换进行横向的比较。于是我就想到了一种方法:能不能通过分析这种模式来找出新顺序呢?
说干就干。我写了两个小方法,将动画的帧输出到 CSV 文件中。用的分别是 dan_hmt001_01.imo.unity3d
(Blooming Star)和 dan_rebell_01.unity3d
。在 Excel 中打开:
可以看到,列之间显示出了很强的分组倾向。然后我们再将这些列的模式写出来(这一切发生在我的脑中,这里写出来更容易理解):
[rebell]#1: (~-0.00x approx, 0 const, ~-0.07 approx)#2: (0 const, 0 const, 0 const)#3: (~-0.00x approx, 0 const, ~-0.07 approx)#4: (0 const, ~+0.87 approx, 0 const)#5: (0 const, 0 const, 0 const)...#60: (? var, ? var, ? var)[hmt001]MODEL_00#rotation: (0 const, 0 const, 0 const)MODEL_00#translation: (~-0.005 const, 0 const, ~-0.03x approx)BASE#rotation: (~+0.754 const, ~-78.6 const, ~-3.35 const)BASE#translation: (0 const, ~+0.87 approx, 0 const)...SCALE_POINT#translation: (0 const, 0 const, 0 const)
有些分量是常数,有些是变化较小的(因舞蹈不同可能会有不同值,变化范围和速度作为分析依据不是很有用),有些是自由变化的(无法用作分析依据)。源文件中指明了一些曲线是常数,而且它们是0(比如 POSITION
的旋转分量);这些是确定不会变化的,可以作为映射表结构的“基准点”使用。
考虑“是否为常量0”这点和基准值、变化速度,观察可以发现,中间一大块(人体关节)是有着完全相同的模式的。人体关节只有旋转。因此先假设这些关节顺序都不变。需要重点关注的是这几个关节:POSITION
、SCALE_POINT
、MODEL_00
和 BASE
,它们一共有7个组(4个位移3个旋转)。只要搞清楚了这7个组的顺序,应该就能得到正确的结果。
接近了。
rebell
的最后分量都不是常量组,所以仅有的会出现常量的组(就是上面这几个)一定是排在最前面的。再考虑到组之间的逻辑关系,开头的应该是 POSITION
(模型子空间)而不是 MODEL_00
(实际的模型根关节),否者就会将 MODEL_00
的层次给破坏掉(因为必定造成 POSITION
或者其子关节 SCALE_POINT
和后面的身体关节相邻)。
那么第1组是 POSITION
的位移还是旋转呢?观察数据的模式((?, 0, ?)
),是位移。
第2组是什么呢?根据上面的大前提,有两种可能:POSITION
的旋转,或者是 SCALE_POINT
的位移。不知道,先放着。
这时候可以看到第4组的模式((0, ?, 0)
)——而这个模式,在7个组中只有一种可能:BASE
的位移。
于是我们现在可以得到这样的一个顺序:
#1: POSITION#translation#2: ?#3: ?#4: MODEL_00/BASE#translation#5: ?#6: ?#7: ?#8: MODEL_00/BODY_SCALE/BASE/KOSHI#rotation(各个身体关节开始)...
到这里应该很明显了。想想上面的大前提。如果在第1组和第4组之间插入任何的旋转,就会破坏整个逻辑。因此只有一种可能:
#1: POSITION#translation#2: POSITION/SCALE_POINT#translation#3: MODEL_00#translation#4: MODEL_00/BODY_SCALE/BASE#translation#5: POSITION#rotation#6: MODEL_00#rotation#7: MODEL_00/BODY_SCALE/BASE#rotation
将这个顺序代入,就可以发现我们得到了正确的结果:
代码在这里。
]]>create mode
的时候我就有一种不好的预感,一看出现了个 forced update,就知道完了。上一次删除 .deploy
导致博客的 repo 被 forced update 还是在2014年末。所以整个博客的提交记录相当于又被清零了。这次就吸取了教训,给这个 repo 也加上了提交保护。]]>昨天晚上本来是计划做乐曲改编的。虽然源用的是网易云音乐,但是播放器我就不想用它,而是 Foobar2000。但是当我尝试下载的时候,却被提示仅允许付费/会员下载。
行吧,你是哪天买的版权(如果真买了的话,笑)我不知道;但是反正我没有很高的码率要求,默认播放的 128 Kbps 就行;而且如果这也不行,还有别的手段。这时候我就想到,虽然不可以下载,但是还是可以播放的啊。既然能播放,那么应该就缓存在本地了,除非产品设计抽风。
于是我就看了看缓存目录。结果发现了很多大小接近 128 Kbps MP3 的文件。(是不是有一种既视感?)主体内容应该是音频没错,但是经过了某种形式的加密。不过这次不是 CTF 了,所以就直接 Google 吧。已经有人搞定了。果然还是屈服于性能,只采用了简单的异或。哈。
那么现在的目标就是把这个逻辑封装为一个 Foobar2000 的组件,这样就可以直接在 Foobar2000 中播放了。虽然我写过简单的组件,不过这次不一样,用到的东西比 input_stub
要多一些。
这次稍微触及到了 Foobar2000 组件系统的核心之一,service_base
。工程的目标是:实现一个文件流,对原文件流做一个读写封装。其实这次还算简单,是无状态的,简单异或每个字节就行了。但是麻烦在于 service_base
的设计。如果在 .NET 里面实现这个可是太简单了,继承并重写,重写中调用父类方法就行。service_base
是引用计数的,有点像 COM(接口查找和接口升级更像),但是(在 release 模式下)并没有虚表(V-table),成员查找完全依赖于模板。成员一般不使用原生指针,而是使用 service_ptr_t<T>
。我本来就没做过 COM 开发,而且这玩意儿还是没有虚表的。
根据 SDK 附带的简单说明,继承自 service_base
的“类”实际上是接口(类似 IUnknown
),不应该有任何字段和方法实现,实现细节相关应该在在对应的 *_impl
类中提供。等等,那我该怎么实例化我的 service
呢?我的指针模板应该填什么类型呢?怎么转换接口呢?
看一下相关的定义。如果你知道 COM 的话这个看起来绝对不陌生。
class NOVTABLE service_base{public: virtual int service_release() throw() = 0; virtual int service_add_ref() throw() = 0; virtual bool service_query(service_ptr & p_out,const GUID & p_guid) = 0; // ...}class NOVTABLE file : public service_base, public stream_reader, public stream_writer{ // ...}
但是要注意的是那些 NOVTABLE
修饰,这是和 COM 最大的不同。在 VC++ 的 release 模式下,它会被扩展为 __declspec(novtable)
。这样尽管在逻辑上,关于 file
继承自 service_base
,但是在编译后它们并没有 V-table,因此不可以进行虚函数调用,dynamic_cast
也应该会失效。__declspec(novtable)
对虚表和 vptr(虚指针)的影响可以参考这个回答。简单说,就是修饰 __declspec(novtable)
没关系,RTTI 和虚函数照常工作(除了在构造/析构函数中)。所以,直观的结论是,要让代码正常工作的话,抽象类(也就是 Foobar2000 中的接口)可以修饰,但是抽象类的非抽象子类不可以;抽象类自身也不能在构造/析构函数中使用虚表或虚指针相关。
怎么实例化呢?一种方法是使用工厂类(service_factory_base
),不过过程就非常麻烦了。对于简单的接口,SDK 提供了一个非常方便的函数 fb2k::service_new()
。只需要创建实现的实例,赋值给接口指针就行了。
在代码中,我定义了一个自定义文件流类型和它的实现:
class NOVTABLE mapped_file : public file{ // ...}// 注意这个 NOVTABLE,可以这么写是因为 mapped_file_impl_t 还是抽象的,实例化由 service_impl_t 负责,见下文class NOVTABLE mapped_file_impl_t : public mapped_file{ // ...}
在使用上就可以这样:
service_ptr_t<mapped_file> ptr = fb2k::service_new<mapped_file_impl_t>(...);// 或者service_ptr_t<file> ptr = fb2k::service_new<mapped_file_impl_t>(...);
有趣的是,service_new()
内部使用了 service_impl_t<T>
。这个类型继承自 implement_service_query<T>
,而正是 implement_service_query<T>
提供了新的 service_query()
的默认实现。service_impl_t<T>
则进一步提供了 service_add_ref()
和 service_release()
的默认实现。所以在写自己的类型实现(比如 mapped_file_impl_t
)时,是不需要提供这三个方法的。因此,此时这个“类型实现”对于编译器而言还是抽象类型,不可以直接使用 new
实例化——将所有实例的创建过程进行托管,可以有效地消除某些 bug——这是一个附加的好处。service_impl_t<T>
正好可以作为使用模板来创建 mixin 的一个例子。
指针模板应该填什么类型呢?由于巧妙的向上查找方式(见下文),所以可以使用实现类型或者其任意的父类型。
怎么转换接口呢?其实这个组件的功能并没有涉及到这个问题,但是在开始摸索的时候,我也是实现了整个 mapped_file
,所以大略知道一点。每个继承自 service_base
的类都要提供一个静态成员 GUID class_guid
(用于特征萃取),在 service_query()
(1.4 以后是 handle_service_query()
)中进行判断。查询整体就是一个沿着继承链往上找的过程。思想则是,一个子类声明它能被转换为哪些父类。而这个继承链,实际上是编译期的,利用模板而不是虚表。可以看 handle_service_query()
的实现:
static bool service_query_walk(service_ptr &, const GUID &, service_base *) { return false;}template<typename interface_t> static bool service_query_walk(service_ptr & out, const GUID & guid, interface_t * in) { if (guid == interface_t::class_guid) { out = in; return true; } typename interface_t::t_interface_parent * chain = in; return service_query_walk(out, guid, chain);}template<typename class_t> static bool handle_service_query(service_ptr & out, const GUID & guid, class_t * in) { typename class_t::t_interface * in2 = in; return service_query_walk( out, guid, in2 );}
其中 t_interface
和 t_interface_parent
在接口的 FB2K_MAKE_SERVICE_INTERFACE
中被自动定义。其实就是宏的那两个参数。这里实现了自动沿着继承链向上查找的语义,终止则利用是模板参数的退化(不是无参特化,但是我不确定该怎么称呼)用一个重载实现的。
看来 Peter 也是把 C++ 吃透了。这些 API 的设计非常巧妙。
最终的成果是个小玩意儿。使用 VS 2019 编译。
]]>本文内容涉及剧透,建议自行游玩后再阅读。同时,故事之间有一些关联,阅读一部或多部可能会带来不同的游戏体验。
我一开始是在 QooApp 上浏览最近发行的游戏,偶然间被标题和图标画风的某种组合给吸引了。看了简介之后我决定下载下来玩一玩。
我玩过的游戏中,针对单人、强调情感体验的不多;而让我称奇,甚至是给予我某个方面启蒙的,则是屈指可数。GRIS 和 Sayonara Wild Hearts 聚焦于短期心理状态,To the Moon 和本(系列)作品则偏向于展示某种长期的“异常”。(Sayonara Wild Hearts 我虽然很感兴趣但是没玩,因为光是看实况录播,它的美术风格就已经让我生物意义上地头疼了。)
故事的三个主角:
マナミ和ハル是青梅竹马,和エリカ是高中同学。三人对“少数”的一致认同和宽容,才使得故事顺利展开。
游戏系统并不复杂,比 AVG 还要纯粹的 AVG。叙事并没有什么出人意料的地方。不过有意思的是,虽然很多选项被划了删除线而且是灰色的,是一般意义上的“不可用”状态,但是实际上那些才是让游戏进入正轨的选项。我想,这大概是作者 npckc 设计的隐藏表现方式,意思是说在角色心中这个选项就是默认不可选择的。这可能是个亮点吧。
剧情其实没那么复杂,不需要怎么解释。マナミ无法理解“亲吻”的特殊含义(在现代语境下),因此无法推断出另外两人关系的变化——更重要的是,无法真正理解自己的处境。给我冲击力的是角色所面临的问题。是的,我知道有的人是不擅长理解暗示,或者一些词汇的含义(常见于自闭症),但这些都是作用域更广泛的社会行为的,我并不知道更“私人”的感情受到这样影响是怎么样的。我曾经听说过“无性恋”,但我只是以为这代表“不喜欢任何人/对任何人都保持着中立的态度”。读到了这个故事之后我才知道,我错了。
タツヤ,您真是勇士。虽然在故事里,マナミ得到了所有人的接受(毕竟她们都有着各自的“异常”),但是现实生活中,恐怕活得要艰难太多了。
整个游戏令我震惊的就是新概念的引入。而且是实事求是的平铺直叙,不是被包装的拜金主义,因此才能给(就算不是这样的)人以共鸣。词条的解释是很可能客观公正的,但是它没有活生生的人。当你从一个人的视角,而不是从上帝视角,去看的时候,才会真正地萌生一种想法:“这个困难的根源在哪里?”进而再去思考需求和生产力的关系等等。
后来我把前两作读了一遍。这一读不要紧,第三部的印象崩塌了大半。不,不是因为歧视,而是因为总体的人物和剧情设置。
这次来总结一下,在看完三部曲后,完整的人物描述。虽然像是贴标签,但是对不起了,政治正确的气息太浓厚了。
她们都希望正常地活着——不是“矫正”,而是和其他人一样相互尊重。
但是一些剧情就有点……匪夷所思,或者说,如果真的发生了,想想还是可以理解的,但是概率太小了。
(第一部)ハル告诉エリカ说自己喜欢マナミ,不过マナミ已经有男朋友了所以说不出口。ハル可是认知性别为女的。……是心理上的百合倾向?还是生理上的激素作用?不得而知。而且喜欢很久了。
(第二部)与ハル的对话中,选错一个就会导致故事提早结束(bad end)。和另外两部不同,这里ハル在闹别扭,总觉得自己不值得参加活动,所以一旦选择了坦诚的选项,ハル就要不不接电话不回消息,要不就说干脆所有的活动都取消算了。但是老妹,你之前不是都去过温泉了吗?从那次事件中就可以见到人家品行如何。你这么缩着,说着不要再麻烦人家了,不是更给人添麻烦吗?
这么看来,第二部的真的是死亡选项。其他两部就友好一些,第一部有“心”的提示(左上角;可以关闭),第三部选项状态就是提示(见前文),就是这第二部麻烦多了。
(第一部/第二部)エリカ费老劲地照顾ハル,从化解ハル(不想见到マナミ的男朋友)的尴尬提出和ハル一起去市内转(最后真的去了),到给ハル准备生日活动,到真情流露,最后甚至说想亲上去了。而且经历了上面的死亡选项后,明显能看出,虽然エリカ认为自己是在尽一个朋友的责任,但是这负担起的已经超出了朋友的范围……更何况エリカ还是个双(很早就跟ハル说过)。然而结局是 as a friend。(摊手)
第二部的后日谈中,ハル表示要真诚地跟マナミ谈一谈。虽然不知道是想谈第二部里各种别扭的根本原因,还是谈她对マナミ的感情。不管是哪一种,总之从第三部可以看到,作为本故事的引子,ハル和エリカ在一起了。等等?嗯?这跳跃是怎么回事?跃迁吗?
(第三部)ハル引导マナミ去寻找解答。虽然于情于理这个指引者都应该是ハル,但是考虑到ハル的想法……作者真喜欢插刀子。
ハル的话可以引起思考,也大致体现了真实的想法:
ハル:我在各个意义上都是破碎的……怎么还会有人会被这样的我所吸引呢?
以及:
エリカ:(“悠人”)是你从前男生时的名字吗?
ハル:从前也不是男生……
有意思的是,一些词汇被意译的情况下,另一些作为专有概念被音译了。温泉(温泉)就被普普通通地翻译成了“hot spring”。而旅馆(旅館)、公共澡堂(銭湯)、蜜柑(みかん)则分别被翻译成了“ryokan”、“sentou”和“mikan”。我想,这些玩意儿在西方语境下,哪一个都不是新鲜事物或者说异域风情啊。问问你们的先祖,要不听听人家吐槽也可以。精罗震怒
本文涉及的主题可能是目前我所有的博文中最容易引起争议的。游戏中说了这么多,其实都是陈述一些客观事实,以及中立的处理方式。真要从法律法规层面上实施起来,就开始碰到主观观点、碰到蛋糕了,和其他行为公认的(但是根据各种标准再细分,或“先进”或“落后”的)行为准则一冲突,也是很麻烦的事。比如,奥巴马的公共厕所指导条例,就是十分粗暴的尝试。如果你要问我怎么解决,我只能耸耸肩,说,我不是神,不知道最优解是什么,也不知道那个最优的平衡在哪里;但是也许可以渐进,近似出这个特定的函数。退一步来说,这个群体内部也是分裂的——就像是多组相互独立、每组内容易相互排斥的选项,就算是第一组都选了同一个,第二组不同,就会看不顺眼。从宏观到微观,人就是这么分裂。感受不到的话……可能是很幸运地选到了大众选项。
不管如何,我依然将其视为社会生产力发展的证明。这个观点来自于《在时间的长河里》中对于毕达哥拉斯学派素食规定的解读。小众的特化需求得到满足,必须是以高生产力为条件的。否则,他们就会成为主流(提供人类这一物种生存的保障的人)的负担,从而遭到排挤。
]]>ConcurrentHashMap<K, V>
不支持 null
值?因为这样就无法区分给定的键不存在还是对应值就是 null
。ConcurrentHashMap<K, V>
不支持 null
键?可能只是 Lea 自己的喜好——因为从逻辑上来说,允许也没有什么问题(虽然有潜在的语义错误)。文章内容不赘述了。
我看完之后想到的是,.NET 的并发容器(ConcurrentDictionary<TKey, TValue>
等等)的 API 设计和它的普通容器有着巨大的不同。把它们和这个 ConcurrentHashMap<K, V>
的坑联系在一起,可能会发现什么。
以前我笑话过 .NET 的容器 API 设计。我们知道,.NET 的 Stack<T>
和 Queue<T>
是基于数组的。这给予了它们一定的随机访问能力(不过没什么卵用,想想它们是做什么的),保持内存连续性(提高缓存命中率),但是坏处就是不适合频繁的扩容和收缩。(在提高数组的利用率上,Stack<T>
比较好办,Queue<T>
用的是循环缓冲区。)我想实现基于 LinkedListNode<T>
的版本,却发现 .NET 并没有提供 IStack<T>
和 IQueue<T>
。简单搜索就可以找到 StackOverflow 上的问题。其中有那么一句话:
As it pointed
Queue
andConcurrentQueue
don’t have that much similar to make them implement single interface.
我就顺着看了一下 ConcurrentQueue<T>
的 API,接着就被震撼到了。其他的容器也是类似的。这是什么玩意儿!相比之下,人家 Java,ConcurrentHashMap<K, V> implements Map<K, V>
,多么优雅。也就是说,理论上,我可以将某个类型为 Map<K, V>
的成员字段啊参数啊变量啊,从原来赋值为 HashMap<K, V>
的实例改为 ConcurrentHashMap<K, V>
的实例,然后瞬间就不担心这里的多线程问题了(假设其余部分正确实现)。这不是很爽吗?而如果我使用了 Dictionary<TKey, TValue>
,由于其和 ConcurrentDictionary<TKey, TValue>
没有共同的基类或者适合的接口,所以我无法只使用一个变量就兼容二者。太麻烦了,就为了这个区别,也得开始使用策略模式吗……
今天突然理解了。ConcurrentHashMap<K, V>
中的 null
是一个坑,而且“这种限制”这样的无聊问题会成为 Java 开发者懂不懂并发的其中一个筛子,很大程度上就是因为它实现了 Map<K, V>
,而后者在设计之初就计划使用 null
作为键不存在的表示,从而没有其余携带信息的手段。这是不是设计失误我不敢下结论(不知道 Bloch 当初是怎么想的),但是一定是有问题的。
我们先来看看 ConcurrentHashMap<K, V>
的存取 API。由于实现了 Map<K, V>
,所以签名是一样的:
interface Map<K, V> /* ... */ { // ... public V get(Object key); public V put(K key, V value); // ...}class ConcurrentHashMap<K, V> /* ... */ implements Map<K, V> /* ... */ { // ... public V get(Object key); public V put(K key, V value); public V putIfAbsent(K key, V value); // ...}
“无法使用 null
值”可以从“需要实现 Map<K, V>
”这个前提推断出来:
Map<K, V>
,因此存取必须使用 get(K)
和 put(K, V)
。Map<K, V>
的大多数实现中,get(K)
对于不存在的键会返回 null
而不是抛出异常。null
是代表键不存在,还是代表设置的值就是 null
呢?那就得在获取值之前先使用 contains(K)
检查了。null
值。一样会遇到2所示的问题。那么此时怎么判断是哪一种情况呢?还是先用 contains(K)
吗?这可是可能处于并发状态下的啊,两次操作之间或许这个容器状态就改变了,引发别的问题。因此,不可以使用预先判断。null
(有经验都知道使用特殊值一般不是个好主意),用它来表示键不存在。null
返回值在这个操作的上下文中有了“键不存在”的特殊意义,为了不引发冲突,容器内的所有值都不允许为 null
。可以注意到,状态附加、原子性保证,使得应用前提发生了变化。因此,ConcurrentHashMap<K, V>
不适合实现 Map<K, V>
。
再来看看 .NET 这边。
interface IDictionary<TKey, TValue> /* ... */ { // ... void Add(TKey key, TValue value); bool TryGetValue(TKey key, out TValue value); // ...}class ConcurrentDictionary<TKey, TValue> : IDictionary<TKey, TValue> /* ... */ { // ... public bool TryGetValue(TKey key, out TValue value); public TValue GetOrAdd(TKey key, TValue value); public bool TryAdd(TKey key, TValue value); public bool TryUpdate(TKey key, TValue newValue, TValue comparisonValue); // ...}
(或许你要问,其余对 IDictionary<TKey, TValue>
的方法实现,比如 Add(TKey, TValue)
呢?答案是它们都成了显式接口实现。)
可见,虽然同样是实现了字典接口,但是由于 .NET 这边并未采用特殊值,所以 TryGetValue(TKey, out TValue)
有很高的并发接口适应性。开发者不需要关心特殊值的问题,而是可以和普通的 Dictionary<TKey, TValue>
一样,使用 null
作为项的值,不需要抓破脑袋。
.NET 这样设计的一部分原因也可能是不得已而为之。因为在 CTS 中是有严格的值类型和引用类型的区分的。ConcurrentDictionary<T>
并没有泛型类型约束,因此可以用于这两种类型。如果想像 Java 那样找 null
这样的特殊值,是找不到的——值类型没有“空”(null)的概念,但是不排除有“空”(empty)的值,包括默认的全零值。因此,为了携带状态,就得多加参数。
这个设计带来的一个(可能是)意想不到的好处是,它将“检测并获取”作为一个原子操作(API 层面,当然不是指令层面),而且没有副作用。这样开发者过渡到并发反而容易了。
我们再来看看在队列上的区别。
interface Queue<E> /* ... */ { // ... // 失败抛出异常 public boolean add(E e); public E remove(); public E element(); // 失败返回特殊值 public boolean offer(E e); public E poll(); public E peek(); // ...}// 和 Queue<E> 一样,不赘述class ConcurrentLinkedQueue<E> /* ... */ implements Queue<E> /* ... */ { // ...}
class Queue<T> /* ... */ { // ... public void Enqueue(T item); public T Dequeue(); public T Peek(); // ...}class ConcurrentQueue<T> /* ... */ { // ... public void Enqueue(T item); public bool TryDequeue(out T result); public bool TryPeek(out T result); // ...}
在这个设计上,Java 提供了两套令人迷惑的 API。它们提供相同的功能,但是在失败时的行为完全不同。一套是快速失败(fail-fast),抛出异常,另一套是安全失败(fail-safe),返回特殊值 null
。(说实话,如果不是这次我看了文档,否则我也不会知道两套的区别。)很明显,抛出异常适用于简单开发,而在复杂的并发系统中返回“失败”这个状态更好。但是由于使用的特殊值也是个合法值(null
),因此 Queue<E>
一般是不允许其中的项是 null
的。(但是也有例外:LinkedList<E>
是允许 null
项的,因为用内部的 Node<E>
包装了。新的坑。)且不论两套 API 是否让人头大,针对 null
的行为已经让人头大。不过,和 Map<K, V
到 HashMap<K, V>
/ConcurrentHashMap<K, V>
的暗坑,Queue<E>
到 LinkedList<E>
/ConcurrentLinkedQueue<E>
反而没什么坑。
当然,你也可以说 null
本身就是个 billion dollar mistake。在这种意义上,null
本来就不该出现。可惜广泛应用的语言大多都没有禁止 null
……
.NET 这边的设计就有意思了。对于普通场景的 Queue<T>
,失败都是抛出异常的。而 ConcurrentQueue<T>
入队失败抛出异常,后两者失败时是返回 false
的(从命名就可以看出来)。而且 ConcurrentQueue<T>
和 Queue<T>
没有关系(见上文),因此不提供相同的 API。失败行为和 API 对于是否支持并发时线程安全(和性能!)完全不同——这虽然“适应”了不同场景,让开发者只需要“自然地”使用,但这差异同样增加了认知的负担。
另外还有一个有意思的讨论。我们知道有 ConcurrentHashMap<K, V>
,但是线程安全的随机访问列表实现只有 Vector<E>
(或者使用 Collections.synchronizedList()
包装),而且原理还是全部加锁的。为什么没有 ConcurrentArrayList
?(原文链接已经失效,所以只好引用译文。)
回答是:
很难去开发一个通用并且没有并发瓶颈的线程安全的
List
。像
ConcurrentHashMap
这样的类的真正价值并不是它们保证了线程安全,而在于它们在保证线程安全的同时不存在并发瓶颈。……
比如,调用 contains()
或者 indexOf()
的时候,最坏的情况下需要搜索整个列表,所以必须锁定整个列表。这个时候如果要修改列表(增删改)那必然会引发瓶颈。
相比之下,ConcurrentHashMap<K, V>
在修改/搜索的最坏情况下最多只需要锁住其一部分(如果你疯狂构造哈希冲突;另参考 Java 7 到 Java 8 的实现变更)。ConcurrentLinkedQueue<E>
/ConcurrentLinkedDeque<E>
则更简单,无法搜索,修改也只有一个/两个方向。因此它们的使用不会构成瓶颈。
我本来还想吐槽一下 wildcard capture 的,因为我记得以前在哪里看到过,它的不恰当应用会得出合乎语法却毫无意义的结果。我写这篇文章的时候找了很久,可是就是找不到了。不过反而有了其他的发现,比如这个和这个。
]]>先简单介绍一下背景。
这门课是 Visualization,由两个作业组成。第一个作业的代码部分,要求补完一个体渲染器(volume renderer)。这个渲染器是完全软件渲染的,而且需要支持几种渲染方式(也就是几种软件着色器):切片(代码已给出)、最大密度投影(MIP)、体元合成(compositing)。不管哪一种,核心都是 ray casting。严格来说,每一个都有“合成”步骤,不过最后一个实际上指的是最常见的、使用 Phong 模型的着色。
今年我就被一个认识我的人叫过去帮忙了。去年我修这门课的时候,用的是 Java。今年据说因为 Java 入门(一个 master level 的课程,本来在 Q1,也就是 visualization 的前一个 quartile)移到了 bachelor level 3,所以在改革之后这门课默认所有选课的人都没有 Java 基础。因此,使用的语言改为了 Python。
我不得不严重吐槽今年的老师,虽然我并不认识他。内容比较长而且跟题目切合度不算太大,所以就放在文末。
好,接着就讲优化的事。下文的渲染时间以提供的橘子的数据为准。
由于各种坑爹因素的综合作用,切片的每帧渲染时间就已经超过了2秒。至于体元合成,因为所有体元基本都要至少被投射一次,总时间可达每帧10分钟(600秒)左右。其他的已经实现了这个的小组也反馈说,用时10分钟“是非常正常的”。
正常个毛!作为读者而不是这个坑爹软件的使用者,你可能不是很清楚它对人精神的打击有多大。首先,切换到体元合成模式,渲染初始帧,10分钟。接着,你会发现默认的迁移函数(transfer function)效果很差,需要调整曲线。当你调整第一个数值的时候,新的渲染开始了,又是10分钟。在这段时间内,你对迁移函数的调整完全不会反映出来——因为上一帧还没有渲染完成,所有更改都不会应用。你根据密度直方图大致估摸出了一个曲线,但为了它能起作用,你必须在上一帧渲染完成后,再次触发更改(比如调整颜色)才能获得正确的渲染结果,于是又要等10分钟。然后,你会看到默认的视角并不能看到什么有意义的东西,于是你得尝试调整视角。跟迁移函数一样,如果正在渲染,则新的视角不会应用。这就是多个10分钟。在你获得最后的满意的图像之前,精神上要受到长时间的折磨。说“折磨”,是因为这个时间根本就是因为种种错误导致的浪费生命,而且不止是一个人的生命。
怎么办呢?我们来想想方案的必要条件:
补充条件:
其实说到底,就是加速。加速,无外乎就是提高硬件利用率、合理利用硬件。例如,并行(CPU、GPU)、更高效率的代码(设计上、编译后)、提高缓存命中等等。
各位或许听说过 Cython。但是很可惜,虽然 Python 代码是合法的 Cython 代码,但是为了让 Cython 达到最高效率,必须要将代码改写成其方言。这就违反了非侵入式的条件,代码提交后别人也不容易运行这些代码。
Python 以性能为代价提供了极高的自由,而这给解释器设计让生成高效率的机器码带来的巨大的困难。所以不可能寄希望于 Python 解释器自身给我们提供极致性能。
我也试过用 multiprocessing
模块来加速。然而这并没有什么卵用。需要优化的函数是一个虚函数,而且位于事件回调,跟 GUI 有相当的耦合。这就不像一些常用 multiprocessing
优化的模块,那些模块基本是用于数值计算。而且它们的函数是全局的(对于子进程来说是入口),参数也利于封送,运行时产生的都是后台进程。当我简单使用 multiprocessing
的时候,这个程序直接弹出了好几个窗口,把我吓了一跳——而且它们没有一个在干活。
threading
?别忽悠人了。
还有一些其他的方法,不过投入的成本太高,对于一个作业,精力有限,做不起。(见文末讨论。)
这时候偶然搜索到了 Numba。它和 Cython 的思路是相似的,在一定条件下,转换成 C 代码后编译运行,利用 C 的速度优势(“C 的速度优势”不是很准确,但是在此不展开了),提高代码执行效率。在满足一些苛刻的条件时,甚至还可以应用并行化,进一步提高执行效率。
就使用体验而言,Numba 总体来说还是相当友好的。在正式用到作业项目之前,我用一篇文章中的示例简单测试了一下优化效率。
import timefrom typing import Callableimport numpy as npfrom numba import jitdef pairwise_python(X: np.ndarray) -> np.ndarray: M = X.shape[0] N = X.shape[1] D = np.empty((M, M), dtype=np.float) for i in range(M): for j in range(M): d = 0.0 for k in range(N): tmp = X[i, k] - X[j, k] d += tmp * tmp D[i, j] = np.sqrt(d) return D@jit(nopython=True)def pairwise_numba(X: np.ndarray) -> np.ndarray: M = X.shape[0] N = X.shape[1] # https://github.com/numba/numba/issues/3993 # Use float_/int_ instead of float{XX}/int{XX} D = np.empty((M, M), dtype=np.float_) for i in range(M): for j in range(M): d = 0.0 for k in range(N): tmp = X[i, k] - X[j, k] d += tmp * tmp D[i, j] = np.sqrt(d) return Ddef pairwise_numpy(X: np.ndarray) -> np.ndarray: return np.sqrt(((X[:, None, :] - X) ** 2).sum(-1))def run_python() -> None: X = np.random.random((1000, 3)) pairwise_python(X)def run_numba() -> None: X = np.random.random((1000, 3)) pairwise_numba(X)def run_numpy() -> None: X = np.random.random((1000, 3)) pairwise_numpy(X)def time_it(name: str, func: Callable[[], None]) -> None: time_start = time.time() func() time_end = time.time() print(f"{name} used {time_end - time_start} second(s)")if __name__ == "__main__": time_it("raw python", run_python) time_it("numba", run_numba) time_it("numpy", run_numpy)
在我的机器上运行,结果:
raw python used 5.295958518981934 second(s)numba used 0.3148195743560791 second(s)numpy used 0.05596804618835449 second(s)
可见,仅仅是转换成等价 C 代码(还没有并行)并编译,就已经获得了巨大的效率提升。当然,这还是比不过 NumPy 的向量化操作(ufunc)。
不过,在这个作业里,每个最终像素点的实际生成过程是异构(heterogeneous)的,因此无法将其向量化,自然无法充分利用 NumPy。使用 Numba 并行是可能的;考虑到作业中的这个函数远比上面的简单例子复杂,尤其是输入的参数,所以尽管理论上可能,我也没这么多精力去适配它的条件了。
我们继续来看 Numba。Numba 提供了两种模式,一种是 nopython
模式,一种是 object
模式。前者不需要涉及 Python 的解释器,后者需要。什么时候需要呢?访问除了特定受支持的数据类型(int
、float
、numpy.ndarray
等等)之外的,尤其是自定义的复杂类型(简单类型可以用 @jitclass
)。很明显,一旦和 Python 解释器扯上关系,那么效率就得大打折扣。再加上 JIT 是有开销的,所以最后总体甚至会慢于直接在解释器中执行。因此,一般都得用 nopython
模式。
接下来就是痛苦的转换了。毕竟 Numba 要能应用,是有限制条件的。
所以第一步,就是尽量做到与 OO 无关,而是回退到面向过程的方式。毕竟文档自己说了“越像 C 越好”(笑)。拆!拆!拆!好在原程序大部分都是不需要跟对象进行交互的,因此将主体变换为面向过程的方式并没有太复杂,就是繁琐一些,花一个小时就搞定了。同时考虑到 Python 无法内联的特性,分块大小偏大,减少调用开销。
不过里面有一个问题如鲠在喉;甚至说,如果不将它解决,那么其他的优化都无法生效,Numba 必然会回退到 object
模式。在循环的最深处,需要根据迁移函数获取在该视角(viewpoint)下某个体素(voxel)的对应颜色值。这可是插在层层循环中最深的地方的一个钉子。而必需的迁移函数,在实现上是一个复杂对象,@jitclass
还不支持。嗯,仔细观察一下,颜色计算其实是根据一张表实现的,而这张表在单次渲染中不发生变化。所以我就写了一个计算量不大的函数,将迁移函数的内部状态提取到了一个 ndarray
中,并修改颜色计算使其用这个 ndarray
而不是原来的迁移函数对象。这样核心计算就可以在 nopython
模式下进行了。
目前(0.46)Numba 还不支持 MAKE_FUNCTION
opcode。Numba 只会报错说“op_MAKE_FUNCTION
未实现”。这么一个莫名其妙的错误信息还是得猜一下。结果是,它不支持函数内定义的函数,无论内部的函数是否形成闭包。所以我只好又违反了模块化原则,把一些内部的小函数提出来放到全局范围里。
最后优化到了什么程度呢?原先的每帧渲染时间是大约600秒,优化后冷启动24秒(含 JIT 时间),多次运行18秒。整整33倍!而且这仅仅是将 Python 代码 JIT 的结果,甚至都没有并行化。这个函数输入太多,签名复杂,所以我就不浪费时间去做并行化了——软件本身只是用来出结果的,后面还有报告呢,这时候离截止日期已经不远了。比起再花几个小时尝试优化到最佳状态,还不如就18秒先用着,毕竟看模型又不是重复数十次上百次的重复劳动,最后总计花费时间也不会太久。
接下来就是纸上谈兵环节了。
我们看到了解释代码到原生代码的效率提升。如果要进一步优化异构任务的话,该怎么办呢?在这里再加一个前提:跟宿主语言无关,也就是要找一个相对通用的解。
一个很容易想到的方案是使用传统的 GPU。将计算逻辑变换成 shader,输入变换为顶点数据,输出从图像中解码。使用的 API 可以是 OpenGL(vertex/fragment)、Direct3D。这种做法别说现代 GPU,老一点的 GPU 都支持。缺点就是结果的复杂度不能太高,毕竟单元输出最多只有4个单精度浮点数。
如果使用 GPGPU,就可以避开许多输入输出的限制。可以使用的有 OpenGL(compute)、DirectCompute,以及 OpenCL 和 CUDA。其中一些还支持异构设备计算。
以上的方案,只要所使用的语言提供对应的绑定,就可以使用。
如果放宽“语言无关”限制,只针对 C/C++ 的话,有 OpenMP、OpenACC 和 SYCL。它们的效率提升方式不尽相同,有自动多线程并行的,有(合适时)使用 GPU 的。但是它们几乎可以和 C/C++ 无缝集成,而不需要将计算逻辑分离到独立的管线中。
对这个老师的吐槽。
客观来讲,他犯了一个严重的选型错误。他应该是这么想的,Python 不是什么“新时代的基础语言”、“万能胶水语言”,“上手十分简单”嘛。但是很明显,Python 和 VB 一样,学习曲线的上升幅度开始平缓,接着立马就极端地陡峭,也就是易于上手,难于深入,更难于精通。Python 的运行效率不能说差,但是很不擅长计算密集型的任务(至少在包括默认的 CPython 上的大部分实现,因为有 GIL)。(另外插一句,PEP-554 安排在 Python 3.9 了,所以现在也用不了。)而这门课要做的是一个软件渲染器,不仅是一个计算密集型的任务,而且是一个异构任务(不能简单地通过向量化完成)。写出逻辑是很简单的,但是一旦运行起来,就会发现它的运行时间无法忍受。想优化?对不起,优化异构任务的难度,已经远远超出了花样使用 NumPy(优化同构任务);后者也早就不是初学者范围内的东西了。也就是说,这个作业在目标之外设置了一个巨大的障碍,而所有人不得不做出选择——要不就克服这个障碍,要不就得忍受折磨——而几乎没有人有能力克服。这是完全没有意义的精神消耗,至少对于学生而言。(现实工作中也有可能会遇到这样的问题,不过都已经到了工作了,自然就不能假设有保护了。)
他貌似对自己的水平没有准确的估计。由于很多人被折磨得死去活来,所以他似乎收到了一些问问题的邮件。将这些整理成 FAQ 之后,其中就有令人哭笑不得的回答。在程序中有一个 bug,它导致计算出的图像不会绘制到窗口中,也就是体现为“看不见”。这几乎是致命的问题,自然有人会问。而他的回答呢?“看不见图像的原因,很可能是因为你的计算机在计算上出了问题。”我真不知道该说什么好。
在代码上,可以明显看出 Java 移植的痕迹。在命名上和一些辅助类的结构上就看出来。除了 GUI 部分的代码遵循 wxPython 的一般规则、OpenGL(仅用来画包围盒)用 PyOpenGL,其他都是 Java 的简单移植。不过要说命名,Python 本身就已经很混乱了,缩写的、连写(无分隔)的、snake_case 的、camelCase 的,乱成一锅粥。以颜色的记录来说,OO 的组织方式比较利于理解,而直接使用 ndarray
更利于和 NumPy 交互,也利于优化。这更多地是一种设计选择,但是我认为有经验的话不会做出这样的选择。
程序本身交互极差。上面也提到了一部分。这个作业在最后还要完成第四种模式,将你所选定的可视化方式应用于分块的体数据。这相当于有一个一级列表,让你选择某个物体,然后再有一个二级列表,选择其在某个时间点下的数据。但是要进入这第四种模式可不是那么简单。这个模式对应的单选框默认是无效(灰色)的,你得先手工选择两个文件夹(体数据索引、各个时间),再选定了其中一个物体的其中一个时间点后,这个单选框才会激活。要不然,想切换都没法切换。这个操作本身就非常反直觉,如果没有那同学的讲解,我恐怕也得摸索个几分钟。在代码逻辑上,单物体(直接打开体数据)和多物体(本段所述的分块选择)的交互是两套代码……
由于不断地有人哀嚎渲染一帧实在是太久了,这老师在截止日期前几天发了一则公告,说如果想加速的话推荐使用 Numba。然而他在给出的例子是切片——最简单的渲染模式,基本上就是简单的 ndarray
读取,没有复杂循环,没有合成。这个直接套个 @jit(nopython=True)
就搞定了,非常简单。然而真正有优化需求的体元合成,要用上 Numba,那得知道 Numba 是怎么工作的、它对数据结构是怎么支持的、Python 本身的瓶颈在哪里、什么时候 Python 解释器是必需的(例如让人又爱又恨的 inspect
),这样才能知道什么能加速什么不行,如果不行能不能换成等价的写法使得它能被加速。这在我看来,对知识厚度的要求远远超过这门课。将这种任务放在一个作业里,就是高射炮打蚊子。而且看来,他也没有对任务难度的合理估计,以为后面都只是像他给的例子那样,直接加个装饰器(decorator)那么简单。
不过,不是没有厉害的老师的。在我选过的课里面,就发生过:
flatten()
调整数据的形状”,但是在查阅资料后我判断应该用 batch_flatten()
,就这么写了。后来在作业的点评里这一条被专门指了出来,说确实应该用 batch_flatten()
,是他失误了。#define DECLARE_CLASS(this_class, base_class) \ typedef base_class MyBase; \ typedef this_class MyClass
对于普通的类,实现起来是这个样子:
#define DECLARE_ROOT_CLASS(class_name) \ typedef class_name MyClass#define DECLARE_CLASS(class_name) \ typedef MyClass MyBase; \ typedef class_name MyClass
使用:
class Base { DECLARE_ROOT_CLASS(Base);};class Derived : public Base { DECLARE_CLASS(Derived);};
很明显,这样利用 typedef
的覆盖规则,省去了再抄一遍基类名称的麻烦。另外,我用了老式的 typedef
而不是更现代的 using
,这在这里只是风格问题,对语义无影响。
对于模板类,我打算如法炮制。可是,问题出现了。
我的宏定义是这样的:
#define DECLARE_ROOT_CLASS_TEMPLATE(class_name, TArgs...) \ typedef class_name<TArgs> MyClass#define DECLARE_CLASS_TEMPLATE(class_name, TArgs...) \ typedef MyClass MyBase; \ typedef class_name<TArgs> MyClass
然而在编译的时候,在第4行产生了错误:
MyBase
等同于 MyClass
(假设仍然使用后文的例子,则 Derived::MyBase
等同于 Derived
而不是期望的 Base
);MyBase
未定义。我就不理解了,为什么是未定义呢?似乎以前 stat 给 UB(两个把 C++ 玩出花的大佬)讲过 VC++ 有符号名称查找(name lookup)上的错误,与标准的不符。但是,Clang 和 GCC 是怎么回事?这里的 VC++ 又是怎么回事?
所以我就问了 stat,如下的例子中为什么 TBase
处出错了。
template<typename T1, typename T2>class Base { typedef Base<T1, T2> TBase; typedef TBase TThis;}template<typename T1>class Derived : public Base<T1, T1*> { // 后注:在提问时我手写的;不过这样写在所有编译器中都不能通过编译,出问题的还是“typedef TThis TBase;” typedef typename TBase::TThis TBase; typedef Derived TThis;}
stat 想了一会儿,说:“TBase
是 non-dependent,第一遍解析的时候找不到。”
什么是 non-dependent 呢,就是 dependent 的反义词咯。那什么又是 dependent 呢?我就搜索了一下。全称应该叫做 dependent name,具体还可以分为 type-dependent 和 value-dependent。(不是 argument-dependent lookup!)
stat 所说的“第一遍解析的时候找不到”,指的是对模板类中符号的二次查找。这里由于 MyBase
(TBase
)是一个普通符号,而不是 type-dependent(SomeTemplate<T>::TBase
、TBase<T>
)也不是 value-dependent(SomeTemplate<N>::TBase
、TBase<N>
),所以不能通过一次扫描来确定合适的上下文。也就是 stat 说的:“(解析)模版的时候 TBase
是不知道是基类的 typedef
的”。
不过,我并不知道中文该叫什么,本文标题里的“待决名”是将语言切换到中文后显示的翻译。
下一个问题,VC++ 这里的行为是怎么回事。让我们看看文档和开发博客里是怎么说的。VC++ 默认遵照的不是标准行为,而是往里面加了点毒。在进行二次查找时,查找的位置错了。就从文章中的例子来看看:
#include <cstdio>void func(void*) { std::puts("The call resolves to void*");}template<typename T>void g(T x) { func(0);}void func(int) { std::puts("The call resolves to int");}int main() { g(3.14);}
标准行为是,在模板类/模板函数中使用的符号,其查找位置为定义处,也就是说,要查找这个符号,范围应该从开头至此处。“定义”指的是模板的定义。而 VC++ 的行为是,查找位置在实际使用处。
按照这么个逻辑,上面的例子结果就很明显了。
g()
对 func(0)
的调用会被解析到 void func(void*)
。因为在此时(直到 func(0)
)编译器只知道这个声明,而且隐式转换是可以接受的。void func(int)
。因为在此时(直到 g(3.14)
)编译器知道两个重载了的声明(void func(void*)
和 void func(int)
,而其中根据参数类型决断,更合适的是后者。同理,也就产生了上面的问题。在代码中,如果 MyBase
的使用晚于 MyClass
,就会产生 MyClass
已经被重新 typedef
的情况,从而让 MyBase
指向错误的类型。当然,就算是顺序恰巧对了,在某些情况下(比如调用前出现了多种特化/偏特化),还有可能因为非标准行为而产生难以预料的后果。
另外偶然看到的一个有意思的“可选命名参数”实现思路和代码。调用效果类似 C# 的可选命名参数:
void Test() { CallSomeMethod(param2 = "Param2Value", param1 = param1Value); CallSomeMethod(param1 = param1Value, "Param2Value"); CallSomeMethod(param1Value, "Param2Value");}
副作用就是会有少许污染。
在移植的时候,在变量上我使用了和原来代码相同的命名,其中就包括 xor
。结果编译器就报错了。
一查,才知道原来 C++ 还有一串运算符的替代表示法。在默认情况下,这些替代名称是启用的,除非使用选项关闭。以链接中的例子来说,这样的代码虽然看上去很奇怪,但也是可以正常编译的:
%:include <iostream>struct X<% compl X() <%%> // 析构函数 X() <%%> X(const X bitand) = delete; // 复制构造函数 bool operator not_eq(const X bitand other) <% return this not_eq bitand other; %>%>;int main(int argc, char* argv<::>) <% // 带引用捕获的 lambda auto greet = <:bitand:>(const char* name) <% std::cout << "Hello " << name << " from " << argv<:0:> << '\n'; %>; if (argc > 1 and argv<:1:> not_eq nullptr) <% greet(argv<:1:>); %>%>
]]>今天讲到 Kahn Process Network(KPN),我看到图示之后立刻反应过来,这结构不就是消息队列(message queue)吗。噫,消息队列“天然地”支持分布式架构,我理所当然地就接受了,因为有一点工程经验它就显得很自然了。没想到也是有一个理论支持的。
讲到并行化的实现思路(也即 parallelism),一般分为两类。多线程实现的“并行”,虽然在具体实现中是不可或缺的,但是只能说是技巧(technique),而不是架构(architecture)。这门课针对的是 parallelism by design,是架构上的并行,而且在系统设计时就有着确定的行为。(注:KPN 中各个过程是可以并行的,但是整个系统仍然有着确定的行为。这是 KPN 的一个特性。)在课程描述里还讲到有一个叫做 parallelism by compilation 的,我不知道是什么(毕竟 compilation 可能指常见的“编译”之外的更泛的意思),所以就去问了。教授(略有学究气的老先生)回答说,如果编译器足够智能的话,就可以将过程分离(注:原话用的是“process”,不过并不是指进程,而是抽象出的过程——粒度可以是大到功能模块,也可以小到一个原子操作),并抽取出可以并行的部分,自动并行。说完之后,他盯着我,很认真地(从语气和眼神里可以体会到)说:“我坚信确定的并行化(parallelism by design)是最好的方案,虽然很少人这么想。”我不知道该说什么好了。我大概能猜到他的心情;不过总有一种“力挽狂澜”之感,专门开这么一门课。
这又让我想起了复杂系统的设计方式,构造和自组织。我的思想仍然受到这篇文章的影响。或许以前我引用过,但是在这里我还是想引用一次:
但我并不认为 Java 有很大的机会,因为它本质上是为构造大型复杂系统而设计的。什么是大型复杂系统?就是由人清清楚楚描述和构造出来的系统,其规模和复杂性是外生的,或者说外界赋予的。
而 AI 的本质是一个自学习、自组织的系统,其规模和复杂性是一个数学模型在数据的喂养下自己长出来的,是内生的。
不过,我仍然认为关键系统需要人为地设计其架构。“it just works”地在精细尺度上的、不可预测的并行,虽然显得很迷人,却是非常危险的。
想起编译器优化的同时我又想起了之前和“博导”(我以前的一个同学,高中去了美国,大学读的……好像是电子工程;反正一直碾压同龄人)的一段对话。以下是节选:
(前略)
我:这算什么,原教旨?(注:指纯 C 写移动设备 app)
博导:算是吧,C 系手动内存正统,Java 等 GC 语言异端!我要确定性,掌控每一行代码!
我:抱歉,指令预测真的可以为所欲为。除非不断加 memory barrier,要不严格来说你无法掌控“每一行代码”。
博导:我知道 Intel 内部运作,知道 reordering 和 uop,所以是掌控的。(注:uop = micro-operation)
博导:当然更加知道 GCC 每一行代码会编译成什么。
我:(图片:熊猫膜拜)
博导:其实 Intel 8卷读一遍就知道了。
我:(图片:许多熊猫一起膜拜)
不知道放哪里好,就放这里吧。
原文:
In a sense, the specular term gets “dibs” on the incoming light energy, and the diffuse term
can only use its “leftovers”.
译文:
某种意义上,specular项指着入射光能量大喊“归我了”,然后diffuse项只能用“剩下的”。
我读到这里的时候哈哈大笑。虽然这个翻译仍然有可以提高的地方,但是这个版本很有画面感。
]]>某种意义上,高光(specular)项占了入射光能量的大头,散射(diffuse)项就只能利用余下的能量。
“回字有四样写法,你知道么?”
而且似乎不少人写过这玩意儿了。所以您也可以将本文作为茶余饭后的消遣,乐乐就好。
今天在面试的时候被问到了这样一个问题:求斐波那契数列的第 $n$ 项,你能想到多少种算法?
自然,猴子都知道简单递归。再进一步,黑猩猩都能看出来递归可以用一个额外的表来速查(空间换时间)。有递归就自然要想能不能迭代。稍稍想一下的话可以知道是能迭代的。
然后又问,时间和空间复杂度是多少?
迭代简单,$T(n) = O(n)$,$S(n) = O(1)$。递归我就炸了(所以说我太水了),时间分析就满脸问号(用表的话……优化是优化了,但是和迭代也没差多少了,却还多了调用开销;简单递归分析卡壳了),没答上来,面试官也就没问空间复杂度。所以我当时只想到了两种;后来因为提到迭代的空间复杂度为 $O(1)$,面试官可能没太听清,说道:“(时间复杂度)$O(1)$ 的解法也是有的,不过你用了迭代只能是 $O(n)$ 了。”虽然和我的回答意思相差了一些,但是我还是惊诧了一下(没出声):“这个 $O(1)$ 是怎么来的?”
所以下午我就搜索了一下。
……连个本科生(复杂度计算本科生都会)都比不过,我自裁罢。
斐波那契数列 $f_n (n \in \mathbb{N})$ 的定义如下:
之后的代码以此为准。
为了简单起见,在之后的代码中使用 int
,而不是更大的 long long
,懒得用无符号数,也没有防溢出机制。
这部分的方法就是只用到代码、基础算法和优化,几乎不需要数学。
递归就是最简单直接的方法了,代码形式和数学形式接近,理解很好理解,大部分工作交给计算机完成。
朴素递归应该学过代码都会,代码就不需要多讲了。
int fib(int n) { if (n < 1) { return 0; } else if (n <= 2) { return 1; } else { return fib(n - 1) + fib(n - 2); }}
对应的调用如下:
int result = fib(15);
这个算法的空间复杂度比较好分析(虽然有点绕),是 $O(n)$。令初始调用为第0层,调用一次加深一层。可以知道,在某一层的所有调用结束之前,不会产生新的调用(即,栈深不会增加)。那么栈深最深的是什么时候呢?画一棵调用树就可以看出,是从 fib(n)
开始到 fib(1)
(实际上可以算到 fib(2)
为止)的这条“单刀直入”的路径。或者可以这么想,每次 fib(n)
执行实际计算(求和)的时候,fib(n - 1)
已经计算完成了。所以空间复杂度是 $O(n)$(更准确地说是 $\Theta(n)$)。
那么,时间复杂度是多少呢?许多文章直接就说结论是 $O(2^n)$,但是也没给出推导过程,或许也是因为太简单了。观察调用树可以知道,一个非叶子节点最多有两个子节点,而树的深度是 $O(n)$;在每个节点的操作都是简单加法,时间为 $\Theta(1)$;所以总计就是 $O(2^n)$。
但是大多数人没想过,这是一个好的估算吗?我们知道,大 O 函数只是给出上界;给出比一个已知上界更高的上界也不算错。也就是说,我可以胡扯说复杂度是 $O(2^{2^n})$,你也不能说我错了,只能说这个上界“质量不好”。
再次观察调用树,可以很明显地发现不管树高度如何增长,总是有不可忽视的一部分节点“缺失”了。因此,很明显还能找出比 $O(2^n)$ 更紧的上界。
为了计算这个上界,我们不妨来观察函数调用的过程。设 fib(n)
的调用时间为 $t{n}$ ,我们可以知道:$t{n} = \Theta(1) + t{n-1} + t{n-2}$。那么,这不是和原来的斐波那契数列形式完全一样吗?回到原点了。
但是等一下!斐波那契数列是可以求出通项的(见 4.3 节)。提取“公因式” $\Theta(1)$(不是公因式,不过在复杂度分析中可以视作等量级;另外这个 $\Theta(1)$ 虽然上面是出现在递归式里,但是在通项中可以化为一个系数——这么讲不是很准确,不过意思就是这样)之后可以知道:
$\frac{1+\sqrt{5}}{2} \approx 1.618$,虽说看起来好像比2差不了多少,不过对于 $n$ 很大的时候差距就比较明显了。这个上界明显比 $O(2^n)$ 要好,也是能获得的最紧的上界。
既然朴素递归开销这么大,有没有可能用无敌的尾递归想想办法呢?毕竟一些现代编译器是支持尾递归优化为循环(迭代)的。(注:其实尾递归并不是无敌的,有着严格的应用条件;这里只是玩梗。)
我们知道,尾递归的一个重要条件是对调用自身后返回的结果不可以进行处理(即,尾调用——对自身的调用必须是返回前的最后一条指令)。那么我们将对之前的两个状态的操作(相加)转移到参数中,不就成了尾递归了吗。代码如下:
int fib(int x1, int x2, int n) { if (n < 1) { return 0; } else if (n <= 2) { return 1; } else if (n == 3) { return x1 + x2; } else { return fib(x2, x1 + x2, n - 1); }}
对应的调用如下:
int result = fib(1, 1, 15);
这个的复杂度分析要考虑一下编译器。对于支持尾递归优化的编译器,这实际上跟下面的迭代是一样的,所以时间复杂度同为 $O(n)$,空间复杂度同为 $O(1)$。对于不支持的编译器,时间和空间的复杂度都很好分析,看参数 n
的变化就行——单次调用开销为 $\Theta(1)$,所以总复杂度同为 $O(n)$。
不管是否想到了尾递归的方法,迭代都应该比较容易想到。如果想到了尾递归的话,很自然地可以进一步到迭代。
想法直截了当:既然每次只需要前两项的结果(状态),而 $f{n-3}$ 并不会被 $f_n$ 及之后的计算直接用到,那么为什么要保存其他状态呢?$f{n-1}$ 和 $f_{n-2}$ 已经很好地充当了“缓存”的功能。代码如下:
int fib(int n) { if (n < 1) { return 0; } else if (n <= 2) { return 1; } else { int n1 = 1, n2 = 1; int tmp; for (int i = 3; i < n; ++i) { tmp = n1 + n2; n1 = n2; n2 = tmp; } return n2; }}
调用如下:
int result = fib(15);
显而易见,时间复杂度为 $O(n)$,空间复杂度为 $O(1)$(更准确地说是 $\Theta(1)$)。我觉得分析都不用写了。
简单暴力的代码解法毕竟是太拿衣服。要往深了优化(或者给出无法继续优化的证据)还是得要数学。
为了理解接下来的分析,首先要知道特征方程。数列的特征方程我记得是高中讲过的,可是后来一直都没怎么用过(高数里也是);加上本来我数学就弱,所以基本就是忘光了。对不起了,宋老师。
斐波那契数列是一个二阶的递推数列,一般形式为 $x{n} = c{1}x{n-1} + c{2}x{n-2}$。假设能找到两个数 $p$ 和 $q$,使得 $x{n} - px{n-1} = q(x{n-1} - px{n-2})$,移项得 $x_n = (p+q)x{n-1} - pqx{n-2}$。所以有 $p + q = c_1$,$pq = -c_2$。不难看出 $p$ 和 $q$ 的地位是相等的,因此消去任意一个(以 $q$ 为例)可得 $p^2 - c{1}p - c_2 = 0$。这就是二阶递推数列的特征方程。求解该二次方程,两个实根(如果有)就是 $p$ 和 $q$。
代回原来的数列形式,若 $p \ne q$,则通项为 $x_n = \frac{x_2-qx_1}{p(p-q)}p^n + \frac{px_1-x_2}{q(p-q)}q^n$;若 $p=q$,则通项为 $x_n = (\frac{px_1-x_2}{p^2} + \frac{x_2-px_1}{p^2}n)p^n$。
将斐波那契数列代入(即 $c_1 = 1, c_2 = 1$)可以解得 $p = \frac{1+\sqrt{5}}{2}$,$q = \frac{1-\sqrt{5}}{2}$($p$ 和 $q$ 可互换)。
(是搜索之后才知道有可以应用“矩阵快速幂”这么一类问题的。我没参加过 ACM,所以渣了。)
从 4.1 节可知,存在符合条件的 $p$ 和 $q$。于是,就可以将每次求值化为矩阵乘法。
对于 $k$ 阶线性递推数列 $xn = c{1}x{n-1} + c{2}x{n-2} + \cdots + c{k}x_{n-k}$,每次递推可以表示为:
其中大小为 $k \times k$ 的矩阵 $\mathbf{M}$ 就是……呃,好像没有标准称呼?就叫递推矩阵好了。由此可见,由于 $\mathbf{M}$ 在递推过程中不变,计算 $x_n$ 就被转化为了计算矩阵的幂的问题。这个矩阵受初始值选取和递推公式两方面的影响。
对于斐波那契数列,给定初始值 $[1, 1]$,可以得到
虽说是矩阵快速幂,但是对幂函数的计算优化其实和数的是一样的,只不过乘法的定义略有不同。
要计算一个数 $a$ 的 $n$ 次幂,最直接的想法是连乘,时间复杂度为 $O(n)$。但是只要见过质数筛这类的问题,或者是插值函数优化,就马上会意识到,其中许多次的乘法是多余的,结果是可以缓存的(甚至可以不用缓存)。那么,如何剔除这些多余的计算呢?
不是有对数函数嘛。大杀器。
考虑到实现的便利性,一般就取 2 为底了。将幂次 $n$ 分解为 2 的幂的和:$n = c{0}2^0 + c{1}2^1 + \cdots + c{m}2^{m}$,其中 $c_i \in {0, 1} (0 \le i \lt m), c_m = 1$。进一步展开到乘法,就可以看到,$a^n = a^{c{0}2^0} \times a^{c{1}2^1} \times \cdots \times a^{c{m}2^m}$。这样,从前往后“扫描”,由于 $a^{2^n} \times a^{2^n} = a^{2^{n+1}}$(废话),所以只需要保存最近的中间结果,就可以依次得到正确的乘积,并最终得到 $a^n$。代码如下:
// 简洁起见,仅处理 a > 0, n >= 0 的情况int pown_fast(int a, int n) { int result = 1; int cur = a; while (n > 0) { if (n & 1) { result = result * cur; } cur = cur * cur; n = n >> 1; } return result;}
可以见到,pown_fast()
的时间复杂度为 $O(\log n)$,空间复杂度为 $O(1)$($\Theta(1)$)。对于矩阵的幂,由于斐波那契数列相关的递推矩阵大小是常数,所以时间和空间复杂度都和计算数的幂是一样的。再考虑初始值的输入等等,最终的时间复杂度为 $O(\log n) + \Theta(1) = O(\log n)$,空间复杂度为 $\Theta(1) + \Theta(1) = \Theta(1)$。
也有用类似思想,但不是快速幂的解法,而是采用新的递推公式,参见[1]。
将 $p$ 和 $q$ 的值代入 4.1 节的通项公式可得: $f_n = \frac{1}{\sqrt{5}}((\frac{1+\sqrt{5}}{2})^n + (\frac{1-\sqrt{5}}{2})^n)$。
按照这个通项计算 $f_n$,理论上可以达到 $O(1)$ 的时间复杂度。
很美妙,不是吗?$O(1)$ 和 $O(1)$ 啊!但是简单地计算依然会出问题。这个公式中,多处涉及了(在最终结果前)无法约简的无理数。在现代的计算框架中,如果按照一般的思维,用浮点数来表示这些无理数,进行计算的话,一定有因浮点数的不精确性导致的误差。$n$ 越大,误差越大。且不说“四舍五入”这个行为在此正不正确,当误差超过 0.5 的时候,舍入都救不回来了。所以在实践中,这个方法是不可取的。
当然不是没有能保证精度的方法。采用符号代数就可以了。而且这个计算过程中的无理数只有 $\sqrt{5}$ 一个,而且 $(\sqrt{5})^2 = 5$ 还是个整数。不过计算过程就得根据二项式定理展开,然后累加并分类求和,所花的时间肯定会多于 $O(n)$,空间开销也不会小。
以上我们见到了五种计算 $f_n$ 的方法:
在几种方法中,时间和空间复杂度都是从前往后越来越好。但是通项由于需要用浮点数表示无理数的值会导致误差所以基本上不能用。所以综合各种因素考虑,最优的算法是采用快速幂计算 $f_n$。
参考:
]]>别急,不会鸽的。
二月份都比较忙,所以除了试着玩了一下基础的光线追踪以及用它尝试了一把 OpenCL/SYCL 之外,没做什么自己的玩意儿。啊说到 OpenCL,用过之后觉得,它的限制和坑还是挺多的。也许有时间我写一篇文章讲讲这些;毕竟关于如何“正常地”使用,许多人都写过了。
那就讲讲最近的事情吧。(本来想显示全文的,结果写完发现太长了,就写了简单的关键词。)
反向工程:东京偶像计划、无线屏幕;狂欢节;杂事。
昨晚又小小地反向了一把,对象是京东娃娃项目东京偶像计划(プロジェクト東京ドールズ)。关于结果 esterTion 应该已经更新在他的文章里了。
其实只是蹲坑的时候刷 Google Play,给我推荐了一个这玩意儿。看着画风还行,打开看看。嘛游戏内容(小人打架加一些反应要素)我就不感兴趣了(而且世界观的建构更是让人一脸问号),但是一些图啊模型啊还是不错的。(“穿着偶像衣服却不干偶像的事。” by 群友)饰品分离目前我只见过 Square Enix(SGS、京东娃娃)和 Konami(心跳凉凉)做过,大概还有一些参考价值?心跳偶像本来我想着跟土豆一样反向的,但是安卓严重混淆,我又没有越狱的 iPhone,没办法;后来……它就停服了。
有点远了。这次反向其实还挺简单的,esterTion 已经找到了关键的类和方法,所以省了很多事,只要耐心读就行了。他大概只是心急失手了而已。如果他又想吹我,您别听他的;我只是个弱渣。
IL2CPP 的反向比一般的原生代码要简单的一点是,它保留了很多 IL 的特征,C++ 编译器无法完全将它们优化。比如在这次挨个设置密钥数组元素的时候,就出现了很明显的一种模式:
if (array.metadata.length < 0) throw_index_out_of_range_exception();array.elements[0] = elem0;if (array.metadata.length < 1) throw_index_out_of_range_exception();array.elements[1] = elem1;if (array.metadata.length < 2) throw_index_out_of_range_exception();array.elements[2] = elem2;if (array.metadata.length < 3) throw_index_out_of_range_exception();array.elements[3] = elem3;
在反编译的代码中,上面都是用偏移表示的。虽然在没看 IL2CPP Array
的源代码的情况下,并不清楚这个结构每个偏移是什么(快去看,懒虫),但是根据这么几个连续的偏移规律,也能摸个大概。再想想对应的 C# 是怎么样的:
arr[0] = elem0;arr[1] = elem1;arr[2] = elem2;arr[3] = elem3;
在实际使用中,这个索引测试一般不是手工做的。因此可以猜测是在 IL2CPP 变换的过程中自动插入的片段,以保持语义一致(ldelem.*
)。
与此类似的还有 IL2CPP 类型初始化(不是 .cctor
)、IL2CPP 实例初始化(不是 .ctor
)、加载静态的引用类型字段等等,生成的代码一看就知道是干什么的。这些重复的模式比起“一般的” C++ 来说要仁慈多了。
当然 C++ 编译器也不是吃素的,明显能优化的地方一定会优化。比如简单的 getter/setter,尤其是简单类型的自动属性,会被直接内联。我就说怎么一些属性的 getter/setter 无法被 xref,结果一看只要是简单赋值,全都跑到实际使用位置去了。而且恶心的是,这通常指的是指针+偏移访问。这就防住了简单的使用检索(find usage),如果是关键属性的话会非常头疼。
IDA 的 F5 并不能完美地反编译,有时候需要进入函数再出来,让它更新函数签名,特别是变参函数和看上去参数奇怪的函数调用。再比如,在 MakeUrlCore()
的关键调用签名更新完毕后,会发现有返回值没被使用的情况。而这些函数并没有修改 call site 所在实例的内部状态,这时就根据下文推断返回值使用的地方,而且可以回到汇编窗口验证。对,我说的是获取 UTF-8 字节数组之后计算 MD5 hash 的那一块。
如果对 .NET 比较了解,少数虚函数表里的调用也可以根据上下文推断。string + Encoding.MethodX() -> A; HashAlgorithm.ComputeHash(A)
,那么这个 MethodX()
就极有可能是 GetBytes()
,即使是虚函数,而且(在 F5 中)缺少实参来判断。再比如,StringBuilder.MethodY() -> B; string.Concat(B, ...)
,那么这个 MethodY()
就极有可能是 ToString()
,即使因为虚函数表+静态分析的原因无法看到实际调用的是哪个函数。这种思路跟中学的化合物推断是很相似的。
说到反向,还有有趣的事情。上一周有一次组会的时候,我们预订的是学校最新的大楼的一个房间。那里面就有一个无线屏幕,可以把设备的屏幕无线地投射到显示器上。一个组员(本科生)说,他在另一门课(好像是 IoT Security 还是什么的)的小组就对这个屏幕使用的客户端下手了,写了一个非官方的客户端,能绕过一些限制来登录。
这肯定让我玩心大发啦。简单看了一下客户端,是一个无任何混淆的 WPF 应用程序。迅速扒光。然后跟着进入 P/Invoke 的原生库,稍微摸了一下结构。另一个是 C++/CLI 的 wrapper,依赖的是 C++ 的。在找不到公开文档和调试信息的情况下,后者基本是不可能静态分析的。
根据我原来看到的信息,我觉得这个系统的验证是存在漏洞的,理论上可以很容易地伪造用户登录和播放任意内容。不过刚才我看了更多的资料和代码之后否定了之前的想法。任意登录下降为可能的,在服务器配置存在特定配置缺陷的情况下;同时对登录有比之前所见的更大的限制。这就带来了攻击成本的上升和回报的下降,导致显得不划算。不过,反跟踪仍然是可以使用低成本的方式做到。
关于这个 WPF 程序的软件质量,我要称赞它,是软件工程的范本。遍地都是契约检查、防御性编程、打印日志(而且模糊了隐私信息),很专业嘛。
啊,本来还想着也许能做个白帽黑客的,不过现在看起来这个案例可能没什么能让我出手的地方了。
不过等到假期结束,我还是想亲手试试。
毕竟人家本科生都能做到的事情,我要是做不到,就太丢脸了。我虽然不知道他们做到了哪一步,但是必须要假设我想到的都已经完成了。
说到假期,这个星期是狂欢节假期。因为艾因霍温在南部嘛,而狂欢节是南部地区在庆祝,所以我们就有了这么一个假期咯。
上周六和昨天晚上去超市的时候,路上偶尔会看见一些穿着“奇装异服”的人。在上面的维基百科链接中可以看到大概的样子。你也可以在这里读到去年的样子。不过我没事又不经常出门,而且这还是在晚上,所以只能看见整个节日的一点点。狂欢游行什么的根本没有参与过,但是的确能在市中心看到不少散发着啤酒味的一次性杯子和几个临时的 live house。
前几天开始我的左耳突然出毛病了。最开始的时候是时不时堵着,就跟坐飞机一样;但无法通过活动咽喉来解决,只能手动按压通气。而后一天晚上玩了一下 The Witness,不过饱和度太高,不久就中等恶心(我玩大多数游戏都没有3D眩晕),受不了了。第二天起来还没发现什么异常,但是联机打了一局黑魂之后就发现对敌人的听觉定位不准了——正中的声源,会听起来偏右。再自言自语的时候就感觉颅内传导的声音,右耳出去了,但左耳好像碰到了什么,反射回来一部分,形成了混响。
虽说现在已经要找医生了,但是还得先处理别的麻烦事——验证邮件收不到,就无法完成电子身份认证,就无法预约。而现在正是狂欢节假期,不知道会不会影响。即使是能预约了,按照我的经验,也得等至少一个星期。这时候我就很怀念国内的挂号了。
所以……没办法咯,只好先习惯目前的处境,调整大脑的处理方式,希望之后不会耽误什么。
哇,如果听不见声音的话(或者只能借助于骨传导),我可会很难过的。
]]>如果有人关注我最近的活动的话或许会注意到,这半年来我推代码的频次相较以往同期要少。嗯,首先是 CS 确实很有意思,而且以我的基础可以看得比较全面(能自然地看见“点”之间的“线”),这样要深挖的就多多了。HTI……我的思维运转方式不是那样的,所以除了课上讲的冰山一角,基本都看不清,所以业余时间都投入到代码上了。
还有一个原因是我希望做一个更有趣的人。除了抽象的代码,我也乐于欣赏艺术创作。欣赏久了,自然就产生了投身于创作的想法。就像笼中之鸟羡慕飞翔的鸟儿一样。以我的性格,要是只能代码一条路走到黑我肯定要抹脖子了。临渊羡鱼,不如退而结网。
在B站上我之前一般是做烧笋的创作谱面;去年因为造一些轮子所以发了一些成果视频。这是我放松的一个选择。但是这毕竟形式非常单一而且(我认为)总是摆脱不了无生命感。——写谱面,在许许多多的规则之下,发挥的余地其实比较少(我不是硬核玩家),而且普遍关注的是技术难度,缺少要表达的主题。每一种艺术都不过是表达的方式,私以为代码也是这么一种手段;有的人擅长这种,有的人擅长那种,仅此而已。当然观众也得有相应的鉴赏能力。
所以我就想了,要是投入等量的时间,能让一种技能从70上升到75,或者让另一种从0上升到15,该如何选择呢?事实上这和 RPG 游戏加点一样是个一直困扰着我的难题。我选择在艺术上从0加到15,很清楚这并不会带来“一技傍身”的优势,而是希望借由将自身化为艺术创作者,去理解他们的视角和思维,会遇到什么问题,我的方案比别人差在哪里或者好在哪里。
继最开始的简单剪辑(1、2)之后,我开始尝试其他的形式:混搭(1、2)、手书(1、2)、MAD 和改谱(远算不上编曲)。每个作品的个中滋味恐怕都不是一两段话能说完的,也并不只是视频简介里的那些。当然,创作水平肯定是无法一蹴而就的。所以除了我自己的练习和体悟之外,我希望寻找更新的想法,希望它们也确实地传达给观众了。反正又不图名又不图利,若是能被欣赏,或进一步地,在此基础上衍生创作,丰富大环境,那就是更好的了。
那么近况如何呢?我确实在大脑没退化的前提下比之前更快乐了。比之前整天在代码上挖掘,望天叹气要好多了。我觉得这偶尔的音乐、绘画和视频创作是有一份功劳的。自己有趣不有趣……还不知道。至于产出的效益,那要等时间来回答。
胡乱说了一些话,权当跨(农历)年的一篇吧。
]]>最近因为做项目,需要用到 NW.js。在搭环境的时候,我自然要写一个最简单的功能来测试了。我的例子是一个关闭按钮。简明起见,下面的代码复现的是在第二次试验。注释“2”的是第二次测试添加的输出,“1”是第一次的。
bootstrap.ts
:
import * as main from "./main";console.log("imported"); // 2main.bindCloseKey();
bootstrap.ts
是编译为 ECMAScript 5 之后,加了个简单的兼容片段(解决 exports
找不到的问题),直接从页面链接的。
main.ts
:
console.log("exported"); // 2export function bindCloseKey(): void { console.log("called"); // 2 const button: HTMLButtonElement = document.querySelector("#close"); if (!button) { console.log("Button not found: " + selector); // 1 } else { console.log("Button found"); // 1 button.addEventListener("close", () => { window.close(); }); }}
主要功能如上所示,功能很简单,如果找到按钮就尝试绑定事件,不管找没找到都有一个输出。
但奇怪的是,实际运行的时候,点击按钮没有反应;打开开发者控制台(直接按 F12)却看不见任何输出。
于是加了几条注释之后再跑,只见到了第一条输出,也就是“imported”。
那么其他的输出在哪里?功能到底执行了没有?我猜想是目标问题,于是切换到 ECMAScript 6,顺便认识和试用了一下 ESM(就是那些 .mjs
文件),但问题还是没有解决。
无奈之下只好谷歌了。但是大多数人是希望将开发者控制台的输出重定向到系统控制台(确实有解决方案,在 package.json
里加入 "chromium-args": "--enable-logging=stderr"
),我是纳闷为什么看不见开发者控制台的输出。结果发现了答案,也就是该做的是手动右键检查背景页;F12 是审查应用的当前页,而 NW.js 的 app 是作为扩展载入的,所以两个页面不同。
考虑到项目完工时手动右键检查背景页未必可用,我想了一个变通的方法。console
看上去是个全局对象,实际上是 window
的属性。那么我将 window
的引用传过去不就好了?
bootstrap.ts
:
import * as main from "./main";main.setWindow(window);main.bindCloseKey();
main.ts
:
let $window: Window = null;export function setWindow(window: Window): void { $window = window;}export function bindCloseKey(): void { const button: HTMLButtonElement = document.querySelector("#close"); if (!button) { $window.console.log("Button not found: " + selector); // 1 } else { $window.console.log("Button found"); // 1 button.addEventListener("close", () => { $window.close(); }); }}
这次能看到输出了。但是显示的居然是“Button not found”!
我再次确认了,HTML 页面是能找到这个按钮的元素的。好吧,手动执行一下试试。分别在 app 页和背景页的开发者控制台执行 document.querySelector("#close")
,前者返回的是 null
,而后者返回了一个 <button>
。
这下我就知道为什么了。两个页面之间有一堵无形的“墙”,一个负责前台(Chromium 端),一个负责逻辑(Node.js 端),有点类似 WebWorker。但和 WebWorker 不同的是,这个隔离的双方都能访问通用全局对象(window
等等),然而访问的却不是同一个对象。上面 $window.console
来自“能和页面交互的” window
,document
(实际上是 window.document
)来自“不能和页面交互的” window
。
后来再看,原来是 0.13 加入的上下文分离,在 NW.js 的文档里其实也写明白了。调试不同上下文的代码则需要分不同的开发者工具。用 require()
加载的模块都会运行在 Node.js 上下文中,路径解析和能访问的对象都是和浏览器上下文不同的。Node.js 上下文中确实有一个 window
对象,但指向的是背景页。如果需要在 Node.js 上下文中访问浏览器的对象,包括全局对象,必须要将这个对象传递过去(就像我上面做的那样)。当然如果偷懒可以用文档后面提到的上下文混合模式(mixed context mode),但它有潜在的上下文不同导致的问题(路径、原型链;见文档)。所以最保险的方法就是在设计的时候就注意,尽量将前后完全分离,消息交换只用上下文无关的对象(例如普通的 {}
、Object.create(null)
创建的对象)。
那么既然上面说在上下文混合模式下会有问题,那么默认的上下文分离模式(separate context mode)下,传递对象会不会出错呢?答案是不会。NW.js 自动处理了对象封送,在传递的时候切换了上下文。我们可以做一个小实验。
bootstrap.ts
:
import * as main from "./main";console.log("From Chromium? " + (window.close instanceof (window as any).Function));main.testWindow(window);
main.ts
:
export function testWindow(window: Window): void { window.console.log("Context of Node? " + (window.close instanceof Function)); window.console.log("Context of Chromium? " + (window.close instanceof (window as any).Function));}
运行,结果:
From Chromium? trueContext of Node? trueContext of Chromium? false
由于 main.ts
是被用 require()
加载的,所以它运行在 Node.js 的上下文下,直接访问全局的 Function
访问到的是 Node.js 的 Function
。而传入的 window
参数,原对象是在 Chromium 上下文中的。访问它带的 Function
(不会被重新指派上下文)访问的到的是 Chromium 的 Function
。由此可见,在对象传递过来的时候,也包括传递回去的时候,对象的上下文都会自动发生变化。
可能要问了,既然是上古版本更新引入的分离,为什么我以前没发现呢?因为以前为了做即时示例,要在没有 Node.js 的浏览器环境下运行,所以都用了 Browserify。这样在 NW.js 中实际上所有代码都是在 Chromium 的上下文里执行的,不会引发问题。而这次我并没有想把它完全部署为普通网页,就用了分文件、模块加载(毕竟 NW.js 也支持 require()
),这下上下文分离的事实立马就暴露了。
注意:本文有(严重)剧透。
这一集的主角是空天(スカイハイ,Sky High)。空天是所有英雄中最为单纯的一个人,这点在前面的剧情已经提及了。动作细节(本想直接拿苹果,但想了想,在衣服上擦了擦才伸手)和语言(还要算上声优表现出的语气)说明,他也是一个正统的绅士。
空天和神秘少女在公园的喷泉前相遇了。怦然心动。空天每天晚上都会巡逻。空天多次在喷泉前碰到少女。身在低谷期、人又不擅表达的空天,在多次向一直“聆听”的少女倾诉后,逐渐发现了怎么走出低谷。同时他也意识到了自己的想法。因为偶然,老虎和兔子在夜间抓到了邪恶的博士。博士说出了真相。
悲剧的因素齐了。
空天在不知情的情况下,亲手击毁了已经化为武器的人造人少女。讽刺的是,在处决面前这个“怪物”的时候,空天作为动力所回忆的,是那个少女。
虽说我早已猜到是这样,但是在表现手法等种种因素的作用之下,我还是唏嘘不已。
不过这还没完!事件结束后的这一段,才是最让我惊叹和赞美的。
从鼓励完老虎和兔子(约20:45)开始,是本集的第二高潮:空天前去和少女道谢。接下来的所有卡,空天的兴奋之情都溢出了屏幕。(分镜和演出,精彩啊!)
其实喷泉很明显是经典爱情电影的隐喻,至少是桥段,比如《罗马假日》。这一集的配乐,对,就是那首舒缓的弦乐,仅出现在这一集;风格上我觉得会类似爱情电影吧。最后这段的镜头设计比起电视动画,更像是电影。哎其实我也没看过爱情电影,想想套路就觉得肉麻到不行,看别的去了。所以在讲述上面的镜头设计时文字苍白无力。
看了这第15集后,我就没再往下看了。剧情已经大概了解,但是很难说接下来会不会出现更亮的点,没有的话就会索然无味。
2022-05-10:
突然想起来,相爱但因为种种巧合认不出最后相杀的剧情设计,上述也早就不是第一个了:已经可以不用再战斗了,巴尼。
]]>Recently CRI Middleware updated their latest ACB archives’ format, and they seemed to have updated encryption of HCA files as well. As a result, the old toolsets can do nothing on the updated files. After about 20 hours of research I finally found what they changed on the decryption. Might be the first in the world, again. This is what’s called “highlight moment” in reverse engineering. XD
The final answer is somewhat simple: transform the decryption key, and use the transformed key to initialize the decryption table. The formula is key' = key * ((uint64_t)(k2 << 16) | (uint16_t)(~k2 + 2))
where key
is the input key and k2
is a secondary key stored in the higher 16 bits of “field alignment” field, in each ACB/AWB file.
This article is about the way and thoughts of finding this transformation. It is roughly recorded in time order. I hope these materials may help someone in the future.
My result is based on various people’s works. Without thoses pieces, the whole puzzle cannot be completed. So a big thank you for everyone involved.
Be noted again: the contents in this article must not be used for commercial purposes.
* “Decode” in the text below may mean decode after decrypting when applicable.
* Sorry about the broken tenses in the last paragraphs. I was too tired to fix them all.
As the new game Dragalia Lost (ドラガリアロスト) is released, CRI Middleware revealed their updated audio technology. As for me, a guy who does not play games in the “normal” way, I care about resource extraction more than how it displays in the game. Forward thinking is what I should do as a game developer, and reversed thinking is for the cracker identity (not “cracker” as in “cookie” :P).
This update updates ACB version from 1.29 to 1.30. Although it is a minor upgrade, it brings a trouble that, HCAs cannot be decrypted even with the correct key. It began with an issue created by FZFalzar. When I received the email, I didn’t know or understood what was going on. In the next day, esterTion posted a screenshot of a comment in his blog article, saying that someone is not able to decode extracted HCA with the game provided. I recalled that the key is the one I saw in that issue, so this must not be a coincidence. Together with the information FZFalzar provided, the new tools should have changed something, which might break backward compatibility. I could imagine if the same measure is applied on CGSS or MLTD, or other games, maybe, a doom for audio extraction. So I decided to challenge it.
The first thing to do is analyzing the files to see whether there are some “odd” values.
Since I already had an ACB extraction tool (AcbUnzip
), I directly dragged the attached ACB on it. Unexpectedly, AcbUnzip
crashed, only throwing an exception and creating an empty file. I also tried VGMToolbox, which created 6 files, but all of the files were empty too.
Since I wrote AcbUnzip
, I could debug it. I found that the difference of the number of files is caused by the difference of the numbers of cues and tracks. There was only one cue, but there were 6 tracks (3 in internal AWB and 3 in external AWB). But the bigger problem was that, though I had the information of file entries, I could not extract them. The file sizes were negative numbers with extremely large absolute values! After narrowing down by instrumentation, I saw that the value of “field alignment” fields in AFS2 (the file structure used by AWB) was quite strange. According to VGMToolbox, it should be a 32-bit unsigned integer. From the past experience, the value is usually 32. However this time, it was far larger than the file size.
The most common guess should be there is a mask. So I looked at the value in hex digits. The lower 16 bits were 0x0020
, which is 32; the higher 16 bits were something unknown. Obviously, it is masked by 0x0000ffff
. I successfully extracted all HCA files after doing this.
The exception was the first piece of the puzzle. Besides applying a mask, it also exposed the fact that the layout of AWB had changed. As I said, since the field offset (32-bit unsigned integer) was usually 32, which is far smaller than 65535 (0xffff
), its higher 16 bits were actually reserved. Here I began to doubt if CRI did use the higher bits as reserved and attached some other meanings to them. But I still could not determine whether they were random numbers (to disturb analysis) or they do have a meaning.
After the HCAs were extracted, it was time to take a view on them. They looked normal: no unknown header parts or blocks, no out-of-range values, and no additional information added. The only recognizable thing was the size of HCA header extended from 96 (CGSS/MLTD) to 397. But except for known fields, the header was filled with zeros.
Here I made 3 assumptions:
comp1
to comp10
(referring to the HCA decoder) were set to some meaning values that affect the decoding process. I did not know what those fields mean so I was unable to proof or deduce only by observing.All these assumptions were possible, requiring more materials to proof or falsify.
At this point, the limit of file analyses was reached.
Just as I replied in the issue, after some observations, considering the updating speed of the technology, iteration cost (time and finance), I thought of 4 most possible possibilities:
Of course their combinations were also possible in reality. Here let me explain the details of each possibility.
The first possibility. The new decoder employed an entire new theory. This means huge changes in theory and code, which can be seen as a new audio format. CRI should not have enough time and financial support to do this.
The second possibility. Think about the values of comp1
to comp10
mentioned above. Considering known code structure of the public decoder, if new branches were introduced to handle values that never appeared before (in the context of code, if
), then new branches or new tables would be logical. I thought it possible, but proving is hard. It depends on the decompiled code.
The third possibility. Since the core of decoding is value mapping using the precomputed tables, could the tables themselves were modified (the code should also be updated) so that input were mapped to different outputs? This thought also depends on decompilation.
The fourth possibility. This is a two-phase encryption. The overall process was untouched but the key was transformed internally, or a decryption table different than the previous one was generated using the same key. Obviously this is the most probable way. All you need to do is adding a few lines of code, and all the public decoders stop working. The cost is close to zero. Combining with the decoding workflow of HCA, in validation, decryption and decoding, a process does not influence the previous one(s). So the key can be easily set to a new one. (By the way, this is also how hcacc
works.)
However my guess was that the key was modified before initializing the decryption table. For that I was wrong.
Because I could only obtain a lite version of the SDK (ADX2LE SDK), which does not support encryption and decryption, and FZFalzar had the full SDK (ADX2 SDK), I asked him to start with some tests. While those tests are in progress, I began the initial reverse engineering on tools in ADX2LE SDK.
I had a question above: is the new SDK backward-compatible? It can play the latest ACB, but can it play old ones? Whatever the answer is, it would help eliminate false guesses. My personal guess was the new SDK is not backward-compatible, but the test results disagreed. According to the fact I believed that there was not an all-new all-different decoder and tables were unlikely to change. The greatest change would be adding branches and tables, at most. If we got lucky, it would be much easier.
FZFalzar found out that the SDK cannot play extracted HCAs (not being packed inside ACB/AWB) with correct keys. He thought maybe the something got into the metadata of ACB. So I checked the new structure of ACB using utf_tab
. The first apparent change was the update of format version. CGSS uses version 1.23.1, MLTD uses 1.29.0, while this one was in 1.30.0.
From my experience learned from MLTD’s ACB upgrade (compared to CGSS), upgrading usually means adding new tables. Current ACB format can only use a limited number of fields (for easy coding?), so it reserved several slots in the end. CGSS (1.23.1) had 18 reserved slots (R0 to R17), and in MLTD (1.29.0) there were only 12 (R0 to R11). And this time (1.30.0), a new table SoundGeneratorTable
was added compared to MLTD. Could this table influence HCA decoding? I thought not. In the sample ACB, the size of this table was zero. If this table is used as a switch, CRI could have chosen a much simpler type. If it is a table and it does have influence, it should not be empty, but filled with some meaningful data.
I did not find other important differences in this sample ACB besides this new table. Some control tables, such as TrackCommandTable
, SynthCommandTable
and TrackEventTable
is not yet parsed, they should not play such row because of their usages in history. To be sure, I modified some values and tested, with no luck.
At this time, FZFalzer sent the player (Atom Viewer, 2.25.14) in the latest full SDK. Finally I was able to test the keys.
During testing, I discovered something interesting. I mentioned before that in the header of AWB there were 16 unknown bits (2 bytes) in “field alignment” field. This time I tried to modify those two “garbage” bytes. I tested these combinations with the new player:
00 00
in previous ACBs.)If the AWB is an external one, the information in ACB will declare that the AWB is a streaming one. Inside ACB it will also store a copy of the header of the AWB. So I thought it was necessary to test both internal and external modifications.
Guess which ones could be played normally? The answer is 1, 3, 4, 5, 6 and 7. Yes, all combinations from 3 to 6 could be played. This was a bit strange but it might be a mistake during test. Now that the decryption is already retrieved, there is no need to test them again. But you can still try it if you like.
The results showed that even ACBs in old versions could not be played with those two bytes modified. So these two bytes must be playing some kind of roles. But where the switch is and how are the bytes involved, were still unknown.
Later FZFalzer tested 2017 version of the SDK (possibly the one MLTD uses) and reported that it could not play the new ACBs. Therefore, the breaking change exists between these two versions.
Considering that there were 6 HCAs in the ACB, and they were in two groups, members in which were of the similar size. Could they be XORed? I tested this possibility but the answer was no.
What about between groups? Like simple cyclical passwords with short key and long encrypted text? Nope.
How about direct key manipulation? Assuming that those two bytes are used, but none of adding, substracting, shifting or masking worked.
All other methods were run out. I had to use the last resort, decompling. Decompling can almost solve all problems, but also with very high costs. It requires massive time, energy, techniques and experience. I was not sure if I can solve the problem before I reach my limit.
The materials i had were ADX2LE SDK (mainly Win/X86) and the APK from Dragalia Lost (Android/ARM32). Later the new player (Win/X86-64) was also used.
This step began at the same time when I asked FZFalzar to do the compatibility tests. My intention was looking at the usages of the key after it is set. The entry point was quite obvious, the well-known criWareUnity_SetDecryptionKey()
.
Statically trace to the location above. The first thing we can see is that the input key is not changed during this process. Another noticable point is, the input key, or the original key, has passed the file verification. Now follow the decryption setting of Atom (audio decoding component) to the next level.
Here we run into some troubles. There are lots of global variables and unknown arrays or class members. But by looking carefully you can see that the key is only printed out (to the debug output) here, with some data whose meanings are unknown. If you know a little about the C language, you may have noticed that the call to sub_CEF9C()
is problematic. According to its arguments, it should be something like printf_s()
, or at least a function with variable arguments. But in the assembly code it is still like this in the picture. The problem is we don’t know what that function outputted. Statical tracing shows the original value of the key is assigned to dword_17AC90
, which was not used in the decompiled code. In assembly code, it is set to a register (then its value is assigned to a class member) but not pushed into the stack. Well I can’t get anything more from this, because I don’t know too much about ARM stuff. This lead ends here.
FZFalzar mentioned the library CpkMaker.dll
in the SDK. He said in the new versions the alignment is set to a large value, while it is 32 unless manually assigned in the old SDKs. Let’s take a look.
Although I don’t have the full SDK, but LE is sufficient for making an impression of code structure. CpkMaker.dll
is a .NET assembly, which is out of my estimation. It is written in C++/CLI, apparently. So what are the other libraries that are easy to decompile? From previous exprience I already knew AudioStream.dll
is an assembly. From its UI, Atom Craft is obviously a Windows Forms application. With these as an entry point, I found CriAtomGears.dll
and AcCore.dll
. AtomPreview.dll
(who exports some APIs similar to the runtimes of CRI products) and AtomPreviewer_PC.exe
are native PE binaries. Managed code for encoding/decoding, or packing/unpacking are not found in these assemblies, so they are definitely inside those native binaries.
Atom Craft has two playback modes. If you import an audio file (which they call “material”) and play it in the Materials panel, it is the audio file itself that is played. But, if you add the audio file into one of the cues, open Session window (in menu View - Session Window), drag the cue onto the list in Session window, and then click the play button, Atom Craft will generate and ACB (content encoding is specified in project settings) and play the ACB file. This difference means behind the different playback handling, there is a invocation from managed code to native code; whether it is P/Invoke or C++/CLI is yet to be tested.
Therefore I try to find the click event. The window name itself is a hint. After painful searches I found it. (I have to say, the code sucks, and assemblies are not well divided.) But the mechanism is not so direct as I think. It uses a C/S architecture. Sending (commands) and receiving (events) are all based on messages. The messaging seems to be wired through sockets. So at run time, Atom Craft starts a native server, and the calls are actually RPCs. As for encoding and packing, it references AudioStream.dll
and CpkMaker.dll
, inside which the functions are completed using P/Invoke. I have to decompile AtomPreview.dll
and AtomPreviewer_PC.exe
.
The immediate choice is AtomPreview.dll
, because it exposes some APIs. This time, I enter the library from code for ACB to look at the reading process. Well, this is much harder than reading decryption settings. I can only perform three levels of static analysis and the analysis is heavily interfered by class members, so I cannot find something interesting. It is not easy to be dynamically analyzed (can attach to the process though). The code structure is far different from expected and the key signature @UTF
is not found anywhere so I don’t know where to start.
Analyzing this from the shared library in Draglia Lost’s APK is as difficult as above.
Atom Viewer in ADX2LE SDK can play the bundle files too. But it does not have public APIs. In the issue comments FZFalzar said the related code is “baked” into the library (in fact this is called static linking :P). Even it does not expose public APIs, there is still a way to locate the code, just make good use of its logs. According to the log, search for string Open ACB:
and follow the references. In the end you will find a function with characteristics very similar to criAtomExAcb_LoadAcbFile()
. However it is still hard to analyze.
Compared to the key settings and ACB, HCA-related functions look harder to catch because they are not exposed anywhere. In addition, the decoding process runs in a background thread, so there is no way to find a direct entry (like from criAtomExPlayer_Start
).
Now what? Scan for tiny traces they leak to the surface. Reading the public decoder code, the most impressive thing is those precomputed tables. These tables will lead us to the hidden functions. As a commercial decoder, speed is another important consideration, apart from precision. Sometimes you will have to balance between them. The official decoder is also likely to choose a trading-space-for-time strategy, and it would be even better if they use the same tables. Anyway, try our luck first.
Based on the public HCA decoder, the decoding framework is like:
void decode_block(Block *block) { validate_block(block); decrypt_block(block); for each channel { decode1(channel); } for (i = 0..7) { for each channel { decode2(channel); } for each channel { decode3(channel); } for each channel { decode4(channel); } for each channel { decode5(channel); } } to_wave(channels);}
Just have a first expectation in mind. Now let’s see if the official decoder use the same tables.
Here we choose the first element in the second table inside decode5()
, 0xBD0A8B04
. The reason to choose decode5()
is, it lies on the last location in the innermost loop. Its feature serves well as a beacon. As for which table, that is a casual choice. In the worst case just try them all. During searching beware the endianess. Here I use the Atom Viewer from the full SDK (because I prefer X86/X86-64 to ARM), so the search pattern is D4 8B 0A BD
.
As expected, it is found in the global variable segment. Having a glance at its previous and next elements, it is obvious that this is the table we are interested in. Now switch from Hex View to IDA view and do a xref (cross referencing). Results show that this address is only referenced by one function, whose address is 0x00007FF606770718
. Do some more searches on the tables used in this function, we can also find the first table in decode5()
. This means the function we are looking is probably decode5()
or at least a part of it. The caller of this function contains the third table, and another part of decode5()
.
Repeating the same operations, find all referenced known tables until the top level of known region is reached. Here is a function decode_block()
. In this process you may find multiple references to the same function. Just try them one by one.
Don’t mind the mild differences of code structure between this and the framework above. The interesting thing is their key characteristics. Keep in mind that compiler optimization is quite powerful.
Again, go up until the top level is reached and there is no obvious caller. Now you should reach a function at 0x00007FF6067688F4
. xref-ing shows it becomes a function pointer, which means dynamic invocation, for example registering in a function table or as callback. Inside this function we can find a string literal Failed to decode HCA header.
, next to which is a function reading the HCA header (located at 0x00007FF60676A274
; I name it FindAndLoadHcaHeader
because it include some code to search for the offset of the header). Reading the HCA header and audio data block decoding appear inside the same function. What is the function like? Yep, a common (and bad) pattern:
if (flag == PARSE_HCA_HEADER) { FindAndLoadHcaHeader(pData); DecodeAudioBlock(pData + headerSize); // Immediately reads the first audio block after reading the HCA header} else if (flag == DECODE_AUDIO_BLOCK) { DecodeAudioBlock(pData);}
Now put that aside. Maybe you will question about the disparity between decode_block()
and the public decoding framework. Now let’s check what the code is like in tools in an old version SDK (ADX2LE). Using the same technique to locate the corresponding decode_block()
inside AtomPreviewer_PC.exe
. It seems the general structures are alike. Forget not, that we are not comparing the decompiled code and the public code, but between old and new versions of the SDK. As there is no (obvious) difference, the decoding procedure should not have changed, and neither should the tables.
Now there is only one question left. What are the meanings of comp1
to comp10
(comp9
and comp10
are calculated using the other comp
values)? How do they affect encoding and decoding? Can they take “strange” values? At this time, I suddenly found VGAudio. It was like hitting by a lightning when I read its readme. The shocking fact (to me) is that, the repository includes the principles and details of HCA encoding. There is even an HCA encoder! From this repository I know that those values have fixed meanings, which cannot be, “strange”.
So only one of the four possibilities in the beginning still stands. All of other “hard” (not easily changed) functionalities are unchanged. The key is the only thing modified. But this is contradict to what I discovered above, so there should be something I missed.
Finally, the finale. This time let’s trace the criWarePC_SetDecryptionKey()
(well, the name is a guess) inside Atom Viewer.
But… isn’t this function not exported? How can we find it? Fear not. We use the same technique as locating from log. The place in criWareUnity_SetDecryptionKey()
, where I stuck before, has a variable argument function that could not be understood. There is a format string in that function: %s, %lld, %lld, %s, 0x%08X, 0x%08X, %d
, hard-coded. This means in Atom Viewer, there should also be the same, hard-coded string. Open Strings View and search for it. Next step, searching for references.
Considering that C/C++ compilers will merge the same strings, it is normal to see such a number of results. A little patience is all we need. Luckily, the first result brings us to the correct location.
The picture above is what it is like after I annotated. But before annotation the high similarity between it and the previous function we analyzed.
Here is a tip. The Hex-Rays Decompiler (F5 in IDA) is not very good a processing variable argument functions. You have to enter that function and exit, to see its correct arguments. (This does not work on ARM. I don’t know why.) After a simple static tracing of one level, most of the symbol names can be deduced. If you are familiar with HCA encryption and decryption, you can pick out InitDecrypter()
quickly. Some information requires dynamic debugging, for example finding strings in tables.
Enter InitDecrypter()
. A little tracing will show the initialization of type-0, type-1 and type-56 encryptions. The value it returns, according to the log, is “DecrypterHn”, which looks like “decryptor handle”. But if you read it more carefully, you will know that this value is a pointer to the decryption table. This pointer is used by two global variables (one at 0x00007FF606818CC8
, another one at 0x00007FF606819628
). But neither of these two reveals a meaningful result for tracing.
As we can see, after the normal initialization of this decryption table, it is used as the decryption table for both HCA and HCA-MX. You can confirm the usage name from doing one more xref. So here is where the decoding code analyses and decryption code analyses converge.
I did a simple dynamic debugging. Until setting the decryption tables for HCA and HCA-MX, the contents of the table is still the same as computed using public tools. So there is nothing abnormal until here. But what I am looking for is all references of the key and the decryption table, so I am able to notice this assignment:
v8 = sub_7FF606712670();*v8 = keyLit2;
Obviously, the key is passed to somewhere else, in addition to the normal usages we see before. Why should this redundant move be introduced?
Expand sub_7FF606712670()
and you can see that all it does is returning the address of a global variable:
void *sub_7FF606712670() { return &unk_7FF606821B30;}
Where is unk_7FF606821B30
used? Static analysis tells us it is only referenced inside this function. But sub_7FF606712670()
, is used at two locations.
After checking these references, jackpot.
Tracing the pointer v4
, which received the pointer to the key, it’s easy to find that it is used in another round of computation, andv6
should be the new key. As expected, the function which uses v6
is for generating a type-56 decryption table. (So why bother writing the same function two times? I don’t understand what CRI guys think.) The problem is how does v5
come from. I add a breakpoint at the assignment of v6
, open an ACB, but the breakpoint is not hit. Well, this is an ACB from CGSS. When using an ACB from Dragalia Lost, I get a hit. The value of v5
is 0x80b2
, a familar number. I immediately recognized that, this is the two “garbage” bytes inside the ACB/AWB. Although v5
comes from the call result of sub_7FF6066F28C0()
, which is the value of a class member, but it is obvious that this is those two bytes. Observing the referencing status of sub_7FF6067006CC()
, which is strongly related ACB reading, the inference has a high probability to be a right one.
So I did a little modification to the existing HCA decoder and put in the transformation. And yes, the decoding was successful. Another test on the ACB inside Dragalia Lost’s APK was also successful. Now we can conclude that the reverse engineering this time completed in success.
Let’s read the formula again: key' = key * ((uint64_t)(k2 << 16) | (uint16_t)(~k2 + 2))
. key
is the input key, and k2
is a 16-bit integer stored in every AWB. Mind the signed/unsigned and length truncation. Also, it does not throw exceptions when the muplication overflows.
If k2 == 0
, the key is not transformed. See the decision on v5
. For example, esterTion found out Princess Connect Re:Dive also uses ACB 1.30, but the HCAs extracted is still able to be handled by existing tools. This is because the bytes at field alignment is 20 00 00 00
.
After this update, HCA can use de facto dynamic keys. Directly decoding HCA files using the static keys will not be safe anymore. Batch decoding HCAs are not possible, but it is still doable when having ACB/AWB. It’s just adding another shell. All the tools are still public, and all you need to do is writing a small fix. LOL.
* Update on Oct 16: The vgmstream guys (whose members also watched the issue) already pushed the changes on Oct 14. Wow.
Well you don’t have to read this in the English version. Just my personal comments.
To be short, the flexible use of data for attacks. For detailed explanations please read the Chinese version.
Written in the order I think of, when I write this section.
近日 CRI ACB 格式更新,连带着 HCA 加密“升级”,旧轮子全员阵亡。在经过二十多个小时的研究后,我找到了关键的修改,搞定了解密。可能又是全球第一个呢。做反向,最激动人心的就是这样的时刻。XD
最终的答案很简单,就是在解密之前,进行密钥变换,并二次初始化解密表:key' = key * ((uint64_t)(k2 << 16) | (uint16_t)(~k2 + 2))
,其中 k2
保存在每个 AWB 中的字段对齐的值的高16位。
这篇文章讲的是我找出这个变换的过程,包括思路和操作,基本上按照时间顺序记录。希望在未来会对某人有所帮助。
总的来说,这次的成果是建立在众人的工作基础上,没有这些碎片,就无法拼成完整的拼图。在此要向各位致谢。
再次强调,本文内容不可用于商业用途。
* 下文中,“解码”表示解密(decrypt)之后解码(decode),除非特别指明。
随着新的游戏 Dragalia Lost(ドラガリアロスト,中文译名“失落的龙约”)的推出,CRI Middleware 也展示了更新的音频技术。对于我这种不好好玩游戏的人来说,更有意思的不是它在游戏中的表现,而是如何提取资源。正向思考是游戏开发者身份做的事,反向思考是破解者身份做的事。
这次更新带来了 ACB 的版本更新,从1.29升级到1.30。虽说只是一个小版本升级,但是引发了一个问题:即使知道正确的密钥,也无法解密。一开始是 FZFalzar 提了一个 issue。我收到了邮件,但是我并不知道,也没意识到发生了什么。在一天之后 esterTion 在群里发了一个他的博文评论截图,显示有人用已知的密钥也无法解码提取出的 HCA。我看着这个密钥觉得很眼熟,突然想起这不是 issue 里的那个嘛。看来不是个例。结合 FZFalzar 提供的信息,新版的工具确实改变了一些关键的东西,可能无法向后兼容。一想到如果以后烧笋和土豆也更新了那就完了,我决定是时候出手了。
首先要做的是进行文件分析,看看新的文件有没有什么不正常的地方。
由于我已经有了 ACB 的提取工具(AcbUnzip
),我就直接把附的 ACB 拖上去。但是出乎意料地,AcbUnzip
崩溃了,而且只创建了一个空文件。接着我打开 VGMToolbox,它创建了6个文件,然而也都是空的。
那么就没办法了,反正我有源代码,上调试。调试中发现,文件数量的差异是因为条目(cue)和音轨(track)的数量不同,只有一个条目,却有内部 AWB 和外部 AWB 各三个音轨。但是更大的问题是,明明我读取到了文件信息,却无法提取,每次都抛出异常。观察字段的值——文件大小居然是一个绝对值很大的负数!通过逐步插桩确定范围,发现是 AFS2(AWB 使用的文件结构)中的字段对齐被设置为了一个莫名其妙的数。根据 VGMToolbox,这个值是一个32位无符号整数。从过往的文件提取经验中,它一般是32。然而读取后,它的值远远超出了文件大小。
这时候最常见的猜想就是有一个额外的掩码(mask)。于是我看了一下它的十六进制表示,发现低16位是 0x0020
,也就是32,而高16位不知道是什么东西。很明显,它应用的是一个 0x0000ffff
的掩码。加上这个处理后,很顺利地就提取出了内部的所有 HCA。
这就是第一块拼图。从这个异常中,除了得知加了掩码这个信息,还可以知道,AWB 的字段意义发生了变化。如我所说,由于字段对齐一般只是取32,远远小于65535(0xffff
),所以高16位其实是相当于保留的。这里我就开始怀疑 CRI 是不是将这里看成了保留位,加了私货。但是我还不能确定这高16位是随机数(扰乱分析用的),还是确实有其意义。
解出 HCA 之后,就该观察 HCA 了。不过 HCA 倒是很正常,没有未知块和头,没有超出已知范围的取值,也没有加什么奇怪的东西。HCA 头长度从以前烧笋和土豆常见的96加到了397,但是除了已知字段,其他的部分都是用0填充的。
在这里我有三个假设:
comp1
到 comp10
(见 HCA 解码器)被设置为了有其他意义的值,影响解码。因为我不知道这些值具体是做什么的,所以也不知道怎么去证明,更无法通过肉眼观察得出。以上三条都只是可能,需要其他材料来证明或者证伪。
到这里就是目前初步文件分析的极限了。
正如我在 issue 中回复的,做了一些初步观察后,考虑到技术更新速度(弱点!)、迭代时间和成本(对于商业公司也是弱点!),我先设想了四种最可能的情况:
当然,实际操作中,可以只选一个或多个一起选。这里我解释一下每一条的具体意义。
第一种情况,新的解码器从根本上用了不同的原理。这意味着大型的理论更改和代码重写,基本上可以算是新的格式了。我不认为 CRI 有时间×财力这么做。
第二种情况,结合上面提到的 comp1
到 comp10
的取值,考虑到已知的解码器的代码结构,如果引入对应其他值的分支(体现在代码中,就是 if
),可以引入新分支和/或新表。我觉得有可能,但我暂时无法验证。具体要看反编译的代码。
第三种情况,既然解码核心是查表来重建波形,是不是有可能这些表本身发生了变化(同时代码要配合变化),导致查到了不同的值呢?这也要反编译才行。
第四种情况,就属于二次加密了。总体流程没变,但在内部将密钥进行了变换,或者根据了同一个密钥生成了和以前不同的解密表。这显然是最可能的方式,只需要添加几行代码,就能让公开的(带解密的)解码器全部失效,而且几乎是零成本。结合 HCA 的解码流程,验证、解密、解码三者,后面的步骤不影响前面的步骤,所以密钥是可以随便更改的。(这也是 hcacc
的工作原理。)
不过当时我的猜想是密钥在进行解密表初始化之前被变换,这个猜错了。
由于我只能拿到精简版(lite edition)的 SDK,也就是 ADX2LE SDK(不支持加/解密),而 FZFalzar 有完整的 SDK(ADX2 SDK),所以只能先拜托他来进行测试了。在他开始测试的时候,我也开始对 ADX2LE SDK 中提供的工具进行初步反向。
上面我就有一个疑问,新的 SDK 是不是向后兼容的。虽说可以播放最新的 ACB,那以前的是不是无法播放呢?不管答案如何,这个答案都将十分有助于我排除猜想。我个人的猜测是无法向后兼容。但是其实可以。这就让我确信了,全新的解码器是不存在的,改已有的表也是不存在的,最多就是增加分支和表,运气好的话会更简单。
FZFalzar 发现,如果是将 HCA 提取出来,即使输入了正确的密钥也无法播放。于是他猜想,是不是 ACB 的元数据加入了新的东西。我用 utf_tab
查看了一下 ACB 的表结构。首先发现的是格式版本升级了。烧笋用的是1.23.1,土豆用的是1.29.0,而这个的版本是1.30.0。
从我观察烧笋到土豆的经验,升级一般意味着增加新的表。现在的 ACB 能使用的字段个数是有限的(大概是为了代码好写),所以在最后预留了一些字段的位置。烧笋(1.23.1)的还剩下18个(R0 到 R17),土豆(1.29.0)就只剩12个(R0 到 R11)了。这次就相比土豆新增了一张表 SoundGeneratorTable
。会不会是这张表影响了 HCA 解码呢?我不这么认为,因为在样本中,它的大小为零。如果只是一个开关,大可不必这么大动干戈,加一个普通的类型就行;实在要是表,而且要影响解码的话,那也不应该是零大小,而是填充一些有意义的数据。
除了这张表,我没有在这个样本 ACB 中发现什么其他重要的变化。虽说一些详细控制的表,比如 TrackCommandTable
、SynthCommandTable
和 TrackEventTable
的内部数据意义我并不清楚,但是它们从历史功能上来看就不大可能会影响解码。不过为了保险起见我还是做了一下修改实验,没得到有用的信息。
这时候 FZFalzar 发来了最新的完整版 SDK 中附带的播放器(Atom Viewer,2.25.14)。于是我终于可以测试密钥了。
测试的时候发现一个很有意思的现象。前面不是提到了在 AWB 的头部,字段对齐的掩码问题吗?我就试着修改了一下那两个“垃圾”字节。我用新版播放器测试了如下组合:
00 00
)。如果 AWB 是外置的话,ACB 除了会声明这个 AWB 是流式加载(streaming)的之外,还会存储每个 AWB 的头。所以我认为有必要把两边的修改都测试一下。
猜猜哪些能正常播放?答案是1、3、4、5、6、7。3到6都能正常播放。这个我就觉得有点奇怪了。不过现在想想可能是测试疏漏,中间出现了错误。不过现在已经拿到了解密方法,以后有时间再重新测试一遍吧。
结果表明,即使是老版本的 ACB,如果改动那两个垃圾字节,照样无法播放。这说明这两个字节一定起了什么作用。但是,哪里是控制开关,以及这两个字节参与了什么计算,都还不知道。
后来 FZFalzar 测试了2017版的 SDK(估计就是土豆用的那个),它无法正常播放新版的 ACB。这就说明,功能断层就发生在这两个版本之间。
(这里有一个小插曲。esterTion 比我先收到通知邮件,他在收到之后直接往群里发了邮件截图,并附言“算法修改石锤”。然后 stat 跟着:“再见.jpg”。确实,乍一听这个消息,心里也是会咯噔一下。)
考虑到这个 ACB 内部有6个 HCA,而且是两组,每一组内大小近似,会不会是做了异或加密?于是我测试将数据部分异或,但并不对。
那么组之间呢?像简单循环密码那样,用短密钥、长密文吗?也不对。
直接对密钥操作呢?假设那两个字节有用,那么做一些加减、二进制操作试试。然而还是不行。
这时候实在是没办法了,只好祭出大杀器,反编译。它几乎可以解决所有问题,但是代价也很大,需要大量的时间、精力、技巧和经验。我不知道能不能在我到达极限之前攻破这个问题。
手上的素材有 ADX2LE SDK(主要是 Win/X86)和龙约的 APK(Android/ARM32)。后来加入了新版播放器(Win/X86-64)。
这个操作是我在让 FZFalzar 测试兼容的同时开始的。我的目的是看看密钥在设置后的流程。入口点很简单,就是众人皆知的 criWareUnity_SetDecryptionKey()
。
静态跟踪到上图的位置。首先可以看到,设置的密钥在这个过程中是没变的。另外一个值得注意的事情是,设置的密钥,也就是原始密钥,到这里已经通过了文件验证。接着跟着 Atom(负责音频解码)的解密设置来到下一层。
这里就有点麻烦了,一片都是全局变量,还有意义不明的数组或类成员。但是仔细观察一下,密钥在这里只是被打印出来(到调试输出)了;而且同时被打印出来的还有一些意义不明的数据。如果你有一些 C 语言的知识,你可能已经注意到了,sub_CEF9C()
的调用是有问题的。根据输入的参数,它很像是 printf_s()
,至少是一个变参函数。但是去看汇编的话……它好像也是这个样子。麻烦的是,我们不知道它输出了什么东西。静态跟踪可以知道,密钥的原始值赋给了 dword_17AC90
。但是,在直接反编译的代码中并它没有被使用。去看汇编的话,它被放到了寄存器里(并进一步赋值给了类成员)而不是压到了栈上。这个我就不知道什么意思了,毕竟我不熟悉 ARM 那一套。线索可以说到这里先断了。(当然,根据后来的结果,如果这里进一步挖掘的话,可能可以提早得到结论。)
FZFalzar 提到了 CpkMaker.dll
,说他能看见新版设置对齐时默认设为了一个异常大的值,而老版本则是默认为32,除非手工设置。那好,去看看。
我虽然没有完整版 SDK,但是只是看代码结构的话,精简版也是可以的。出乎我的意料,CpkMaker.dll
是一个 .NET 程序集。而且很明显使用 C++/CLI 编写的。还有其他这么容易分析的吗?AudioStream.dll
以前分析解码的时候就知道是程序集了。从界面来看,Atom Craft 明显是一个 WinForms 应用程序。以此为入口,又找到了 CriAtomGears.dll
和 AcCore.dll
。同时,AtomPreview.dll
和 AtomPreviewer_PC.exe
是原生的 PE 文件;前者可以发现导出了一些跟 CRI 的运行时公开的函数近似的函数。那些程序集都没有托管的编解码、打包解包代码。因此这些功能肯定都是在原生二进制文件中。
在使用 Atom Craft 的时候,我发现,它有两种播放模式。导入音频文件(他们称之为素材,material)之后,直接在素材面板播放,是直接播放素材所指的文件。但是如果将其加入其中一个条目(cue),打开会话(session)窗口(View → Session Window),把这个条目拖放到会话窗口下面的列表中,点击播放的话,则会生成 ACB 之后,播放 ACB 里面的内容(格式视工程设置而定)。这就意味着,在这个功能背后,是一次从托管到原生的调用,具体是 P/Invoke 还是 C++/CLI 那就不知道了。
所以我开始去寻找这个点击事件。窗口名称就是提示。经过痛苦的寻找之后(因为不得不说,这代码写得真烂,而且程序集划分不好)我找到了,但是这背后的机制并没有我想的那么直接。它采用的是 C/S 架构,发送(指令)和接收(事件)都是采用消息。消息传递从实际使用看上去是通过 socket。也就是说,在运行时,它启动了一个服务器(原生),而中间的调用其实是 RPC。至于编码打包,是引用 AudioStream.dll
和 CpkMaker.dll
(虽然打包的大部分计算是在托管部分完成的)以 P/Invoke 方式实现的。这就再次逼我去反编译 AtomPreview.dll
和 AtomPreviewer_PC.exe
了。
首选自然是 AtomPreview.dll
,因为它暴露了 API。这次我从 ACB 相关的地方进去,看看读取的过程。但这怎么说呢,比那个更困难。静态调试只下了三层,被类成员干扰,没找到有意义的东西;而它又不好被动态调试(其实可以附加进程,我糊涂了)。代码结构和预计的不太一样,也没找到标志性的 @UTF
,无法确定真正开始的位置。
龙约里的那个从 ACB 入手也同样分析困难。
ADX2LE 附带的那个 Atom Viewer 也是能播放各种玩意儿的,不过没有公开 API。FZFalzar 说是把相关代码“烘焙”(baked)了进去……其实是静态链接(statically linked)啦。虽然没公开 API,但是还是有办法定位的,只需要利用它在运行时生成的日志。根据打印的日志格式,直接搜索 Open ACB:
然后顺藤摸瓜就能找到,函数特征和 criAtomExAcb_LoadAcbFile()
是一致的。不过一样是很难分析。
相比上面的密钥和 ACB,HCA 相关函数看上去是更难抓的,因为它没有暴露出 API;同时解码运行在后台线程,所以无法通过可能的入口点(比如 criAtomExPlayer_Start
)找到。
那么怎么办呢?看看这些函数有什么蛛丝马迹是直接暴露在外面的。分析已知的解码器代码,最引人注目的莫过于预计算的那些表了。这些表,就将成为突破口。作为商业解码器,速度是精度之外的一个重要考虑因素,甚至精度有时候都需要与速度平衡。官方的解码器也很大概率会采取空间换时间的策略,如果进一步用的是同样的表就再好不过了。不管怎么样,先试试手气。
从公开的 HCA 解码器可以知道,解码的框架如下:
void decode_block(Block *block) { validate_block(block); decrypt_block(block); for each channel { decode1(channel); } for (i = 0..7) { for each channel { decode2(channel); } for each channel { decode3(channel); } for each channel { decode4(channel); } for each channel { decode5(channel); } } to_wave(channels);}
先有一个心理预期。接着开始看看官方的解码器究竟是不是用了同样的表。
这里取 decode5()
里的第二张表的第一项 0xBD0A8BD4
。为什么是 decode5()
呢?因为它处于最里层的最末,位置作为一个信标来说很适合。至于选择哪张表就随意了,大不了把每个都试一遍。搜索的时候注意字节序。()我这里开始用的是完整的 Atom Viewer(因为我还是喜欢 X86/X86-64 那一套),所以搜索的是 D4 8B 0A BD
。
果不其然,在全局变量区找到了。而且看看前后,就是要找的表。找到之后从 Hex View 转回 IDA View,然后执行引用查询(XRef),发现这个变量被且只被一个函数引用了,这个函数位于 0x00007FF606770718
。进一步搜索这个函数中用的表,我们还能发现公开代码中 decode5()
的第一张表。这说明这里很可能就是 decode5()
(至少是一部分)。查找这个函数的调用者(也就是上一级),在这个上一级函数中发现了第三张表,同时还有 decode5()
的另一部分。
如法炮制,找到所有被引用的已知表,直到最上层,也就是 decode_block()
函数。其间会遇到一些被多次引用的情况,这时候就逐个试验。
不用太在意代码的结构差异,找到关键特征即可,因为编译器的优化和重排可是十分厉害的。
之后一路高歌猛进,上到不能再上,来到位于 0x00007FF6067688F4
的函数。再往上查找引用,可以发现它变成了函数指针,所以很明显是动态调用,比如注册个静态函数表(不是虚函数表)什么的。在这个函数中我们可以发现一个字符串 Failed to decode HCA header.
,顺着就能找到读取 HCA 头信息的函数(位于 0x00007FF60676A274
,我将其命名为 FindAndLoadHcaHeader
,因为它居然还包括了一段搜索偏移的代码)。读取 HCA 头和解码音频数据块出现在一个函数中,你觉得会是什么呢?没错,就是一种很常见(而且很糟糕)的模式:
if (flag == PARSE_HCA_HEADER) { FindAndLoadHcaHeader(pData); DecodeAudioBlock(pData + headerSize); // 没错,紧接着就是读第一个音频块了} else if (flag == DECODE_AUDIO_BLOCK) { DecodeAudioBlock(pData);}
这边先放一放。你或许会说上面这个 decode_block()
函数长得和公开的模型不像啊。那么我们看看老版本的解码是什么样子的。利用同样的技巧定位到位于 AtomPreviewer_PC.exe
的与 decode_block()
函数对应的位置。结果发现从整体来看精简版和完整版在解码上没有什么区别(解密就不用讨论了)。不过别忘了,我们的目的不是比较反编译出的代码和公开的代码,而是去看旧的精简版和新的完整版是否有什么不同,如果有再采取下一步行动。但既然没有不同,就说明解码流程并没有发生改变,甚至表也没有变化。
那么就剩下一个问题了,comp01
到 comp10
(其中 comp9
和 comp10
是通过其他值计算出来的)表示什么,会对编解码造成什么影响,是不是可以取“奇怪的”值。这时候我偶然搜索到了 VGAudio。读到 readme 的瞬间,我就像被闪电击中了一样。让我震惊的是,它包含 HCA 编码的原理和详情,而且包含一个 HCA 编码器!你要知道,我一直想知道 HCA 编码是怎么实现的,因为整条工具链就只剩下这个环节受制于人,还用的是通过“取巧”手段(而且对用户来说还麻烦)得到的官方库了。但是编码并不能从解码反推。从它的代码中我知道了这些只有编号的字段的意义(虽然并不能实战,因为我信号处理是渣)。而既然他们有固定的意义,就不会随便取值。
所以现在开始的四种可能性只剩下一种了,也就是其他所有“硬”功能都维持原状,只是密钥发生了变化。但是这和我所见的密钥设置有矛盾,说明我可能看漏了什么。
终于到重头戏了。这次我们来跟踪 Atom Viewer 里的 criWarePC_SetDecryptionKey()
(猜测名)。
但是……这个函数不是没有导出吗,怎么找呢?别着急,这里和从日志输出中定位是一样的。在我们前面看的 criWareUnity_SetDecryptionKey()
中,不是卡在了一个变参函数输出无法理解的地方吗?那个函数的格式化字符串是 %s, %lld, %lld, %s, 0x%08X, 0x%08X, %d
,而且是硬编码。所以,很可能这里面也会有一样的格式化字符串,而且搜索起来很简单。打开 Strings 视图,搜索这个字符串,就可以看到了。接着就是查找引用。
考虑到 C/C++ 编译器会将相同的字符串合并,出现这么多结果也不奇怪。需要的就是一点耐心。不过我们运气不错,第一个结果就把我们带到了正确的位置。
上面这图是我已经标注的样子。不过在标注之前也可以看出和之前我们看的函数的高度相似性。
这里有一个小技巧。IDA 的F5不是很会处理变参函数,需要进入这变参函数再返回,才能看到它分析出正确的实参。(不过这个技巧对前面 ARM 上的并不适用,不知道为什么。)经过简单的一级函数静态跟踪后,上面截图的大部分符号名就能得出了。对 HCA 加/解密熟悉的人也很容易看出 InitDecrypter()
函数。一些信息需要动态调试,比如里面几个查表找字符串的操作。
进入到 InitDecrypter()
内部,经过几级跟踪之后,很容易能看出0型、1型和56型加密的初始化。它返回的根据打的日志是一个“DecrypterHn”,从名字上看是个解密用的句柄(decryptor handle),但实际上仔细看就会发现其实是指向解密表的指针。这个指针被返回后被两个全局变量引用(一个在 0x00007FF606818CC8
一个在 0x00007FF606819628
),但是这两个全局变量中,前者除了被设置为 NULL
之外没什么用(意义不明),后者被设置给了一个类成员变量(无法静态跟踪了)。
可以见到,在正常初始化这张解密表之后,它同时被用作 HCA 和 HCA-MX 的解密表——从再 XRef 之后看到的错误信息字符串中就可以看出一个是读取 HCA 一个是读取 HCA-MX 的。解码线和解密线在这里相遇了。
我动态调试了一下,发现直到设置 HCA 和 HCA-MX 的解密表,这张表还是和用已有工具计算出来的完全一致。说明到这里都没什么异常的事情发生。但是我留意的是密钥和解密表的所有引用,所以我自然注意到了这个赋值:
v8 = sub_7FF606712670();*v8 = keyLit2;
很明显,这个密钥除了在上面我们看到的正常使用外,又传到别的地方去了。为什么要有这个“多余的动作”呢?
展开 sub_7FF606712670()
,可以看到它很简单,只是返回一个全局变量的地址:
void *sub_7FF606712670() { return &unk_7FF606821B30;}
那么这个 unk_7FF606821B30
在什么地方被使用了呢?静态分析表明只在这个函数里。但是 sub_7FF606712670()
就不一样了,它在两个地方被使用。
跳转之后就知道,中大奖了。
跟踪收到指向密钥的值的指针 v4
就可以发现它参与了另一轮的计算,v6
就应该是新的密钥。果然,使用 v6
的函数的作用就是第二次生成56型加密表。(所以既然操作完全一样,为什么不用同一个函数呢?不知道 CRI 在想什么。)那么问题就是 v5
是怎么来的了。我在给 v6
赋值的地方下了一个断点,打开一个 ACB,发现没命中。仔细一看,原来我用的是烧笋的 ACB。换成龙约的 ACB 之后,断点命中了。我一看 v5
的值,0x80b2
,怎么这么熟悉?由于这两天都在跟这些玩意儿打交道,我立刻意识到,这就是那两个“垃圾”字节。虽然 v5
值的来源是 sub_7FF6066F28C0()
的结果,后者返回的是一个类成员的值,但是很明显,这就是那两个字节。再综合一下这个函数(sub_7FF6067006CC()
)的被引用情况,它的周围都是 AWB 读取的操作,因此很高概率就是如我所想。
于是小小地改写了一下现有的 HCA 解码器,先硬编码这个值,做个实验再说。结果成功解码了。接着我又试了龙约的 APK 里带的 ACB(装的是音效),也成功了。于是这次反向工作到此结束。
再看一次这个公式:key' = key * ((uint64_t)(k2 << 16) | (uint16_t)(~k2 + 2))
。其中 key
是原始输入,k2
是保存在每个 AWB 中的(每个 AWB 内统一的)一个16位值。注意有/无符号和长度截断。此外,溢出是不报异常的,简单向左溢出。
同时,如果 k2 == 0
,则不执行变换(见 v5
的判断)。举例来说,esterTion 发现干炸里脊也用了 ACB 1.30,但是以前的解码工具能正常工作,因为对齐的四个字节是 20 00 00 00
。
这次更新之后,HCA 实际上相当于用上了随文件的动态密钥,直接用静态密钥解码 HCA 文件在新游戏中不再保险了。虽说批量解码 HCA 是不可能的,但是给出 ACB/AWB 之后,批量解码又是可能的了。所以只不过是多加了一层麻烦而已,所有工具仍然是公开的,只需要略微改造一下。(笑)
SDK 的工具中,提供给反向用的破绽太多了。不仅是代码层面的,还有商业、习惯、理论层面,就看你能不能找到、联系和利用了。虽说一丝裂纹不算什么,但是千里之堤,溃于蚁穴啊。而我最擅长的就是逐个击破。不过或许也可以认为是 CRI 良心,因为要把反向变难的方式多得数不胜数。比如,把静态变量变成单例的类成员,就足以令人抓狂了。如果代码中用更多的动态调用(动态函数表之类)或类成员,或许我就输了。
ACB 头部有个版本字段。但是这次测试中,老版本的 ACB/AWB 只要修改了文件中对应位置的值使次级密钥非零,则密钥还是会被变换,从而导致被修改的老文件也无法正常解码。这个逻辑就奇怪了,按照完全的向后兼容,就该加一个版本判断,小于 0x1300000
(也就是之前版本的 ACB)就无论如何也不应该变换密钥。不知道为什么这个正常处理被吃了。
干炸里脊(公主连接,プリコネR,Princess Connect Re: Dive)DMM 版出的时候带了保护。当时正好也遇上群里对通信束手无策,我准备先试试脱壳。结果发现以我的能力,还没到接触到主程序,就已经跪了——虽然已经知道文件内容替换到代码空间,但是 IDA 分析就炸了,因为每次都要重新分析指令。而内存映像又抓不到,我没有 DMM 启动器,进程(在没挂调试器的情况下)在未知的地方崩溃了。还有反调试保护。群里大佬脱了一层,结果发现这是二层壳……最后说是 Android Republic 的大佬搞定了,不过我因为不玩这个游戏,就没再跟进。前几个月有人在我抱怨土豆加密的文章下回复(我觉得应该就是 Crypto 了,虽然穿着马甲)说是被 XOR 了,然而我就没有个 root 过的机子,静态分析又让人崩溃,所以只好不了了之。这次……至少赢了一把。
我把这个故事告诉了老爸。他问:“你以前也做反向,这次和以前相比有什么新的贡献呢?”于是我卡住了。
我觉得这次就是一条斜向上的直线,一个台阶都没有。而所谓“台阶”,就是产生了飞跃的地方。上次能吹一会儿的是本地动态库的中间人攻击,但这次好像没有什么新东西。或者换一种问法,这次整个流程中起到最大作用的是什么?
我觉得是解读现有条件的方式。有话云:“看山是山,看山不是山,看山还是山。”原意是建构、解构、重构。大概就是如此。数据,一般都能看出正常的使用方式(读写,换言之,功能是存储);但是在某种情况下,比如这次的作为预初始化了的、紧密排列的全局变量,它的使用状况还可以作为代码的定位工具;定位之后,它又恢复了作为数据的基本功能。就是这样的跳跃,才会打开新的可能性,揭示新的关系。如此的应用总是有条件的,不过试一试总是值得的。
没必要分先后。按照想到的顺序。