调用堆栈和消息循环

YRE对Yuri-IL脚本进行演绎的过程就是接受用户的IO输入,在调用堆栈上解析脚本逻辑、处理流程控制并调用画音渲染器执行相应动作,该过程称之为YRE的消息循环
在本节将详细讨论YRE中消息循环的技术细节,请注意此处的消息循环仅仅是对解析场景Yuri-IL的主调用堆栈而言。关于并行处理函数和信号系统(反)激活函数的调度,将在后续章节中继续讨论。

栈机

YRE调用堆栈是一个高级语言栈机,不同于高效动态语言虚拟机的堆栈机或状态机将所有的操作都以字节码形式表达,YRE栈机维护了一个装载着场景/函数栈帧实例的调用堆栈,如下图所示。

CallStack

进行场景切换其实就是弹出当前场景栈帧并压入目标场景栈帧,而进行函数调用则是直接压入函数栈帧。由于栈的LIFO性质,实现了保护当前现场的功能,因此YRE的函数调用是可递归的。
栈机为运行时环境提供了如下的服务:

函数或属性名 作用
Reset 初始化栈机
Submit 将一个栈帧提交到栈机
Consume 弹出栈机顶部
Clear 清空整个堆栈
Count 计算堆栈中栈帧的数量
ESP 栈顶指针
EBP 中断或等待前指针
SAVEP 场景指针

注意到栈机是一个可序列化对象,进行游戏保存的本质就是保存当前主调用堆栈和它引用的上下文。

栈帧

YRE栈机中的信息单元是运行时栈帧,它储存着当前的执行逻辑和临时性上下文的引用。它是游戏消息循环要怎么进行动作执行的描述子。消息循环
栈帧的数据结构如下:

属性名 作用
IP 下一指令指针,保存游戏时被悬空
IR 下一指令的索引名字
PC 指令计数器
State 栈帧状态,指示当前是在处理什么(场景、函数、中断等)
ScriptName 正在解析的脚本名
Argv 实参列表,用于向函数传递参数
Delay 执行栈帧之前的延时
TimeStamp 该栈帧诞生的时间戳
BindingSceneName 该脚本所绑定的场景名
BindingFunctionName 该脚本所绑定的函数名
BindingFunction 栈帧的函数模板对象
BindingInterrupt 栈帧的中断对象
Tag 附加值

指令指针IP用于寻指,它指示了当前栈帧所对应的执行可执行脚本的下一个要执行命令是什么,消息循环会检索它并执行它,当IP已经指向空时,意味着该脚本已经执行完毕,调用堆栈将会弹出这个栈帧。但当游戏保存时,指针IP将被悬空,以避免整个脚本的动作序列图都被序列化到存档文件中。
指令索引IR用于记录IP的索引名,它是一个字符串,用于保存游戏时记录当前正在解析的指令,使得恢复游戏存档时能够让栈帧的IP指针从悬空恢复为指向下一指令。
计数器PC指示了调用堆栈在当前栈帧停留的时间,避免出现无穷递归等情况。标签字典保存了这个场景中所有标签名和它对应指令的位置。所以,在YRE栈机中,场景跳转的本质就是弹出当前场景栈帧,压入新的场景栈帧;函数调用的本质就是压入函数实例的栈帧;跳转指令的本质就是查询标签字典并修改栈帧的IP指针。
栈帧状态State指示了当前栈帧正在进行何种处理。目前YRE中一共有6种栈帧状态:

类型 意义 并行安全
Interpreting 正在解析场景脚本
FunctionCalling 正在进行函数调用
WaitUser 等待用户IO ×
WaitAnimation 等待动画结束 ×
AWait 延时等待
Interrupt 系统中断

栈帧中并未直接保存着场景或函数的上下文,这是因为场景上下文存在于堆区的Permanent Space中,由符号表管理器Yuri.PlatformCore.VM.SymbolTable提供统一的访问接口;而函数的上下文对象则是被保存于函数对象的内部,即BindingFunction之中。

消息循环

消息循环是YRE的核心,在.NET版本的YRE实现中,它由一个时钟事件触发。
消息循环的处理函数位于总控制器Director中,函数名为UpdateContext,该方法被周期性地执行。它从调用堆栈中查看当前栈顶的类型,如果是正在解析游戏脚本,则递归寻指取得下一指令来交给画音渲染器UpdateRender类去执行;如果是等待IO、等待延时、进行函数调用、系统中断等情况,就要进行相对应其他处理。在完成相应的状态处理函数后,UpdateContext函数会刷新系统的IO信息,如鼠标点击、键盘的按键状态等,根据这些IO信息更新游戏内部相应标志位的值,以实现一些引擎内部的IO处理(例如分辨率改变、快速存档等快捷键的响应)。总而言之,消息循环驱动着游戏的进行。
消息循环的刷新频率是不固定的,但它的策略如下:

  • 如果本次消息循环执行的指令是立即返回的(即不需要等待就能执行下一指令),那么消息循环会使用快启动机制,即下一趟消息循环不等待时钟事件来触发,而是立即继续进行
  • 如果某次消息循环执行的指令是等待IO、等待动画、延时等待这些会持续一段时间的指令,那么按照标准时钟事件的模式继续消息循环。每次时钟事件触发的间隔不早于一个GlobalConfigContext.DirectorTimerInterval字段所对应的值(单位:万分之一毫秒),默认情况下1毫秒刷新一次

接下来考虑虚拟机的寻指机制。YRE在初始化时会将游戏的入口场景Main压入调用堆栈,此时该栈帧的IP指针指向它的第一条指令,游戏消息循环会在下一次迭代的时候取出并执行它。寻指按照以下先后顺序进行:

  • 如果调用堆栈为空,就返回空,指示消息循环结束游戏
  • 如果IP指向空,就弹出当前的栈帧,返回递归寻址函数的结果
  • 如果IP非空,且IP是一个控制流程指令,就按照该控制流程的约定条件寻找新的IP并返回
  • 返回IP,并让IP移动到当前指令的下一指令位置

算法的伪代码如下:

IF CallStack IS empty
  RETURN null
END IF
ret ← CallStack.ESP.IP
IF ret IS null
  CallStack.Pop()
  RETURN FetchNextInstruction()    // recursive fetching
END IF
IF ret.type IS YuririGameCommand
  CallStack.ESP.PC++
ELSE
  ret ← ProcessControlFlow to find next GameCommand Instruction
ENDIF
CallStack.ESP.IP ← ret.next
RETURN ret

从伪代码中可以看到,递归寻址算法首先会查看调用堆栈是否为空,为空的话说明游戏应该结束了。非空时,使用调用堆栈的ESP属性取栈顶的栈帧,并用IP属性取下一指令。如果IP指针指向了空说明当前栈帧中所有的动作都已经执行完了,此时应该弹出调用堆栈的顶部,并递归调用自身,继续在新的栈顶进行寻指。
对于非空的指令,需要看它是否是一条可执行语句,如果是条件分支、循环等控制流程,就不应该直接返回它,而是通过计算它们的条件属性,确定程序指令的路径,并寻找下一个可执行的语句去返回给导演类Director,否则下一递归寻指只能在导演类下一次消息循环时发生,在游戏逻辑中含有大量分支时很影响效率。

系统中断

YRE拥有系统中断机制,它使得一些状态变化所产生的高优先级调用可以打断当前消息循环正在处理的栈帧转而执行中断处理函数。中断的本质是将一个中断栈帧压入主调用堆栈的顶部,使得下一次消息循环先执行中断处理脚本。
按钮的单击处理函数调用就是使用中断机制实现的,当游戏里一个按钮被按下时,YRE将该单击处理函数包装成一个中断栈帧提交到主调用堆栈上,此时场景的解析执行会被保护现场,消息循环在中断栈帧上继续处理。当完成中断栈帧中绑定的中断处理函数后,栈帧出栈,恢复到原来中断前的上下文逻辑继续执行。
事实上,YRE提供的中断服务比上述的按钮中断要复杂得多。中断对象的数据结构如下:

函数或属性名 作用
Type 中断类型
Detail 中断原因
InterruptSA 中断处理动作(它在中断发生后最优先被执行,但它的后继结点将被忽略)
InterruptFuncSign 中断处理函数调用签名(这个动作将在处理完中断动作后被施加到调用堆栈)
ReturnTarget 中断结束后跳转的标签名(这个动作将在处理完中断函数调用后被施加到调用堆栈)
PureInterrupt 在执行完中断动作后是否处理后续动作
ExitWait 是否在执行时弹空所有等待

符号表管理器

YRE使用符号表管理器SymbolTable来为场景上下文和全局变量提供公共的访问接口。它是一个单例类,只有唯一实例,它可以被序列化。
它维护了一个全局变量的上下文对象GlobalCtxDao,将全局变量名和变量在运行时环境的托管内存的引用关联起来。YRE通过RuntimeManager中的赋值函数Assignment和引用函数Fetch两个方法操作符号表。由于栈顶可以时场景也可能是函数调用,因此这两个方法会根据调用堆栈栈顶的状态来判断去哪个符号表里寻找变量:如果是场景,那么就在场景静态符号表SceneCtxDao中寻找;如果是函数调用,那么就要在函数栈帧中找它自己生成的局部变量符号表。还需要注意的一点是,在引用一个变量之前,首先要判断它是否在符号表里存在,如果不存在说明它在被引用之前从未被作为过左值,这是不正常的逻辑,YRE会抛出错误并强制结束游戏。

运行时信息管理器

运行时信息管理器RuntimeManager负责整个YRE信息的管理,它是当前游戏状态的一种可序列化描述。它在运行时会维护以下的内容:

  • CallStack:主调用堆栈
  • Symbols:符号表管理器
  • Screen:屏幕管理器,管理画面的描述子
  • PlayingBGM:正在播放的BGM
  • PerformingChapter:正在演绎的章节名
  • DashingPureSa:当前正在执行的动作
  • ParallelExecutorStack:并行处理栈,其栈帧对应主调用堆栈同一偏移量的场景所对应的并行处理函数的执行器向量。关于并行处理,请参阅后续小节“并行调度机制
  • ParallelHandler:并行消息循环处理函数委托。关于并行处理,请参阅后续小节“并行调度机制
  • SemaphoreBindings:信号量绑定字典,其键是信号量名称,键值是信号量激活函数和反激活函数名组成的二元组。关于信号量机制,请参阅后续小节“信号分发机制

画面管理器的引用ScreenManager和当前正在播放的音乐PlayingBGM是当前游戏状态的文本化描述,用于保存和恢复游戏状态。ScreenManager会为前端所有正在显示的画面元素保存它们的描述子Descriptor,这些描述子记录了画面元素的位置、缩放、不透明度等参数,当存档被读取时,这些描述子会指导前端管理器ViewManager如何重绘画面。同理,PlayingBGM记录了当前的背景音乐,读取存档后将恢复播放。

保存和读取游戏存档

保存和读取游戏存档就是将游戏运行时信息管理器RuntimeManager及其引用的对象进行序列化写稳定储存器上的过程及其逆过程。在序列化发生之前,需要对它做一些临时性处理,以移除不能序列化和不应该序列化的内容。
保存过程的执行逻辑如下:

  • 调用RuntimeManagerPreviewSave方法,它会创建一个临时包装对象PreviewSaveDataStoringPackage
  • 弹主调用堆栈,直到SAVEP指针与ESP指针相等,弹出的栈帧保存到临时包装对象中
  • 缓存栈顶ESP栈帧的指令指针IP到临时包装对象中,并悬空IP指针
  • 将并行句柄ParallelHandler和并行执行器向量ParallelExecutorStore保存到临时包装对象中,并悬空它们的引用
  • 序列化RuntimeManager,写到稳定储存器中
  • 调用FinishedSave方法,逆转上述的临时包装过程,将缓存的内容恢复到原来的变量上

读取过程的执行逻辑如下:

  • 从稳定储存器中读取存档内容并反序列化成RuntimeManager对象,并作为参数调用DirectorResumeFromSaveData方法
  • 暂停当前的消息循环、并行处理
  • 清空回滚快照
  • 清空画面并停止音乐
  • 将主控制器Director的运行时信息管理器实例引用更改为反序列化得到的对象
  • 通过主调用堆栈栈顶的IR属性恢复IP指针指向下一个要执行的指令
  • 更新屏幕管理器,重绘整个画面,重绑定渲染器所作用的调用堆栈,重新演绎背景音乐
  • 将保存前正在执行的动作封装成中断,提交到主调用堆栈,复现保存前最后一个动作

一个交互的例子

YRE涉及到的游戏交互具体用例是非常多的,这些结构在实现上有着细节上的差异,很难用一种统一的顺序图模型来描述它,这里选择介绍其中一种组合场景:“用户点击游戏界面里的一个按钮后播放指定的音乐文件。”,利用这个用例来从架构上分析YRE是如何实现它的。

ClickButtonExample

这一过程的顺序图如上,整个Yuri引擎一共有6个主要对象参与了这个用例。用户点击前端的按钮后,Button类的实例捕获到鼠标点击事件,随后向总控制器类Director提交一个中断请求,并将中断处理函数func和参数列表发送给Director。使用中断机制是因为用户按下按钮时需要立即暂停系统当前的游戏逻辑来处理按钮事件。
总控制器Director在接收到中断请求后将中断实例提交给YRE主调用堆栈,即提交到RuntimeManager,它将中断实例压入主调用堆栈,完成本轮消息循环。随后,再一次消息循环时就会处理这个中断,调用按钮提供的中断处理函数func,而上文说到按钮点击的目的是为了播放音频,因此func的内容是一个播放音频的指令,该指令会被Director发送给画音渲染器UpdateRender,它在接受要播放的文件名并调用音频管理器Musician来播放音乐,音频管理器请求资源管理器ResourceManager返回这个文件在托管内存中的句柄,在成功获取资源后,Musician将调用计算机的音频设备播放指定的音乐,至此整个事件结束。