非想天则开发日记·第七则——RIV大翻新
鱼的千千
编辑于 2025年10月08日 14:37
收录于文集
共10篇

絮语           

哇,我又回来了!距离上次写文过去了……

两个月!创造了新的纪录,大家的掌声在哪里?

拖更这两个月里主要在做毕设开题相关事务(心虚),以及RIV插件的重开发,后者同时也是下文将要介绍的内容主题。现在诸事告一段落,该趁着国庆假期写点东西记录一下啦。

以下是莫名奇妙格式的正文

摘要·Abstract

本文介绍了一个基于SokuLib框架的ReplayInputView模组重构工作。该模组用于在东方非想天则(Touhou Hisoutensoku)中可视化玩家输入序列及——

停,这个真没必要。

I 引言·Introduction

RIV——ReplayInputView插件,用于本地对战时显示判定框、暂停/加速/减速、显示输入指令等;实用如此,可谓新则时代必装插件之一。多位开发者经手,其本身的开发和修改历史也源远流长,或许最初可以追溯到SokuLib和SWRSToys都未曾出现的遥远旧则时代,那时没有什么整合包和插件加载器,只有千奇百怪的修改法。

最初霓虹银搞出的版本似已不可考,目前广泛使用的是经过SWRSToys适配和一些修改优化的版本,品种名多了个“+”,简称为RIV+。此次重开发也始于这个版本。

既然要重开发,说明当前版本还是存在不好用的地方。介绍一下我投入时间重构RIV的动机(Motivation)罢,且分有以下几点:

依然是不支持列表的一天

II 相关工作·Related Works

ReplayInputView的改进参考了以下社区项目:

- AssistSoku(仓库:SokuDev/AssistSoku):启发弹幕绿框实现。

- ReplayInputView+(仓库:SokuMods/modules/ReplayInputView+):提供项目实现的最初范本。

III 设计与实现·Design and Implementation

一个好的项目从从起名开始!既然是师承RIV+、再承RIV的项目,不妨更增一“+”符,名曰RIV++。只是两个+似乎略显俗套,恰巧笔者日常使用的一款编辑器叫做notepad--,便取其中删繁就简、重构新生的寓意,将项目名定为ReplayInputView--,一个“+”拆作两个“-”,旨在去除冗余与复杂,并追求纯粹优雅的设计(大雾)。不过,从学名到品种名大抵应该是要有所取舍的,未来作为插件更新推送的文件还是拟为RIV++,让--仅留作代码仓库的牌匾罢。

模块1 判定框显示(F4)

该模块得到完全重置,分配不同颜色以适配防御技、当身、隙间、反射盾等各种特殊判定框。

十分多彩,而且可以通过ini自定义!

下面聚焦介绍几点重要设计。

双层框:

以当身、无敌、防御技为例,这三项属性可以同时出现,并按无敌>当身>防御的顺序结算,而单用框的颜色无法表示三种维度。针对这一点,RIV++提出了双层框设计。在表示主要属性的外框上,添加一个向内收缩2px的内框,可以再多显示一种颜色,这样外框、内框、填充便有三种颜色维度——将当身属性分配给填充(红/蓝/紫);将单属性无敌按类型显示为内层框(红/蓝),全属性无敌仿照原RIV显示为全透明填充;最后外层照常即可。

面对这个爆改过的羽衣若水,你能说出她身上有多少种属性吗?

没错,体术无敌(内层红框)+防御技(外层白框)+弹幕当身(蓝色填充,稍不明显)。如此妖孽的组合不太可能在原游戏出现就是了。

三回敦促我加上擦弹属性的显示,但是目前没想到怎样再去添加显示维度。有好主意请在评论区分享!

此外,游戏中有大量弹幕属于“红绿共用”——在FDE中为带有红框的弹幕勾选“AttackObject”复选框,让弹幕的绿框从红框中复制而来。这时若不做特殊处理,显示时绿框将与红框完全重合而遭到掩盖。因此针对这种共用的弹幕,将其绿框向外扩展1px,并降低绿框的填充透明度以优化显示,如下图。

命中时会在红绿间来回闪烁

无效判定滤除:

先讲一件轶事。几年前在Q群里参与美铃符卡的讨论时,不记得大球的发动帧数了,于是用RIV看大球红框出现时机,得到数字5f。而实际上在7f之前,红框都是没有hit的假判定。结果讨论时说出“大球强发动,5f全游最快”被批成云玩,真是丢人丢大发了(捂脸),至今想起仍难掩尴尬之情。

无效红框

另一印象深刻的还有青蛙种的树,同样是假判定的重灾区。

无效红框2

将RIV++发给三回测试时,他一眼看出妖梦d22半灵的残留红框(如图)也因为过滤规则得到了修复,不愧是6年ym牢玩家。

我去这框真帅吧

怎样判断框是否失效?不妨从怎样才算“有效”说起。

对于角色或弹幕的hitbox,也就是红框,要使得判定有效必须满足:collisionLimit > 0 && collisionType == 0 。collisionLimit又称hitCount,很好理解,记录的是角色或弹幕的剩余hit数,如果为0则代表没有hit,判定无效。

那么collisionType呢?第一则专栏里我承诺日后会详细讲讲collsionType,此刻机会正好,便来详细说说「collisionType」是个什么东西。

collisionType是角色和弹幕对象都具有的属性,用于记录两个物体判定框接触时的判定结果。

游戏中的判定结果由整数表示,总共有0~9共10种情况,其含义如下表:

简而言之,collisionType包含的是当帧的判定结果。

对于多hit的招式,角色或弹幕的更新(Update)函数需要按一定间隔将collisionType重置回None(0),才能使判定重新有效。只要理解了个中要点,就能随意魔改招式的hit数啦~

无奖问答:魔理沙的d623升龙有99hit(疑似黄昏测试遗留),但是实战只能打出1hit,你知道是为什么吗?

回到正题,那么只要检查collisionLimit、collisionType两个变量就可以辨别有效和失效了吗?

理论上是这样,但是这两个变量表示的是当帧「判定后」的结果。以持续命中的多hit招式为例,判定一出就会发挥作用并使collisionType不为0,显示出来便是一直处于无效。而我更希望对失效与否的显示是当帧「判定前」的状态,至少让判定出现的当帧保持有效显示,这样就能展现出判定结果重置的过程。

好在游戏确有提供相关变量,也就是boxData.prevCollisionType和boxData.prevCollisionLimit;复用一下受击框表示无敌的“空心”,失效情况将红框填充设为透明即可。

然而,使用过后才发现,boxData存在「数据滞后」的严重毛病。在特定条件下,游戏会认为boxData的任务已经完毕,于是懒得再去更新boxData包含的判定框、prevCollisionType等缓存数据,导致停留在「有效」的阶段。前面提到的妖梦半灵和青蛙种树的残留判定显示便是数据滞后的直接结果。

至于所谓特定条件,通过对变量打断点等,找出了负责更新boxData的代码位置,并反编译整理如下。

代码块
text/x-c++src
自动换行
复制代码
void __thiscall CBattleManager_UpdateCollision(CBattleManager *this) {
    //已移除与文章不相关的大部分代码
    for (auto player : this->players) {//遍历Player
        mUpdateBoxInfo(player);
        for (auto& object : player->objectList->getList()) {//遍历Objects
            int hitboxes = object.frameData->attackBoxes.size();
            int hurtboxes = object.frameData->hurtBoxes.size();
            if (object.customBox) {
                hitboxes += 1;//动态hitbox,总数+1
                if (object.frameData->frameFlags.atkAsHit) {//attack object, 共用红绿框
                    hurtBoxes += 1;//hurtbox总数+1
                }
            }
            if (object.parentA) {//存在A类父物体
                mUpdateBoxInfo(&object);//更新boxData  
            } else {
                if (hitboxes != 0 && object.collisionType == 0 && object.collisionLimit > 0) {//存在hitbox且判定有效
                    mUpdateBoxInfo(&object);
                }
                else if (hurtBoxes != 0) {//存在hurtbox
                    mUpdateBoxInfo(&object);
                }
            }
        }
    }
}
复制成功

用文字描述逻辑,就是:

如果是角色(Player),更新。

如果是弹幕(Object),则计算红框/绿框数量,并判断:

    若存在A类父物体,更新;

    若有红框且判定有效,更新;

    若有绿框,更新。

如此,可以推断出造成滞后的几个情景:

1. 仅有红框的物体(体术物体或无相杀弹幕)命中时,红框判定失效且没有绿框,导致先前的判定滞留;典例:小爱5A/6A,式神「蓝」等。

2. 弹幕命中或消失时,从有红绿框的frame进入没有红绿框的特效frame,导致先前的判定滞留;典例:小町B弹、紫妈C弹。

对于情景1,可以在上述函数中通过hook插入代码,把当帧可能发生滞后的Object收集保存到RIV++的数据结构中,然后在显示时比对是否收集,就能判断数据是否已发生滞后。具体实现就不赘述了。

情景2解决起来更方便些,可以检查frame数据是否为特效(没有任何红绿框),如果属于特效则不予显示判定框。

以青蛙的L3A树为例,四颗树中靠近青蛙面前的“主树”为其他3棵树的A类父物体,因此其他3棵树的判定是实时的,但是主树从有判定的攻击帧进入消失前的枯萎帧后,由于不符合更新条件,缓存判定仍停留在攻击帧而错误显示,直到销毁后才消失。

另外,对于弹幕的相杀判定,即绿框,判定有效的条件要更加宽松:只要collisionLimit大于0即可。相杀不检查collisionType,可能是出于每帧都要判定的考虑吧,若还要靠更新函数来回收判定结果就太麻烦了。

最后欣赏一下正确显示的大球吧!

霸体条:

开发过程中另有一项重要发现是霸体耐性和power机制,揭示了龙星霸体需要至少两击打破、灵梦DC破龙星等现象背后的原因。

受击方不断维护一个上限为100的倒计时变量(unknown4BC) ,相当于霸体的削韧条:受到攻击时power值会累加到该变量上,达到100则进入硬直动作并重置倒计时为0,即霸体被打破;另一边,任何攻击都具有power值,绝大多数招式设置的power值为100,也存在少许power为200的“强力”攻击。当然,也有刻意设置为power0、不造成硬直的图书小水泡。

正常情况下,power100/200的攻击总是能打破霸体韧性,体现为受击方不具有霸体;开启龙星后,平时保持为1的power乘子(powerMultiplier) 的值会乘以0.5,随后受到攻击而累加的削韧值也跟着折半,于是通常power100的招式打在身上只剩50,怎么说也要至少2hit才能打出硬直,但power200的招式折半后还是≥100,得以无视龙星给予的霸体;除龙星外还存在台风霸体和无限霸体(superArmor),都是将power乘子设置为0的结果,此时攻击无法累加对方削韧条,形成无限的霸体。

摸清楚原理后要将其显示出来,RIV++的设计是根据乘子倒数显示圈数,每圈代表1hit的power量;每圈相当于一个圆形“进度条”,从外向内消耗,且随时间恢复。直接看成品就很直观!

连续2hit体术间往往有帧数间隔,而耐性随时都在回复,所以需要3hit

无限霸体就随便弄了个特效,纹理素材是从STG里截取的。

模块2 Input面板(F7)

该模块仅重构代码结构,功能没有大的改变。

展示新增的几项特性:

预输入显示:通过反色按钮边框来突出显示,表示该指令该帧存在预输入。

取消链上的预输入为10f,生效或倒数结束后消失;非取消链上的为基础2f(10f被闲置动作吃掉);符卡键恒定为按下后10f有效。

有新的10f预输入进入时,将顶替前一个10f预输入。

模块3 Debug窗口(F6)

该模块的前期工作已经完成,但尚未投入实用,仅作粗略介绍。

鼠标点选功能:

WindowProc回调,WM_MOUSEMOVE消息获取鼠标相对于(640, 480)窗口的位置(ScreenToClient);坐标计算抓取光标5×5容差范围内的物体锚点;滚轮切换重叠锚点;左键点选锁定跟踪对象,右键解除锁定。

独立窗口渲染:

创建线程,窗口处理函数;避免窗口激活抢占游戏焦点;CreateAdditionalSwapChain创建额外交换链并绑定到Debug窗口;全屏切换致d3d设备丢失——hook resetDevice函数跟随重置交换链;layout.xml控制窗口大小及布局。

一开始的思路是为Debug窗口分配独立d3d设备,但是发现交换链技术后认为使用额外的交换链更加简化资源管理,还能复用d3d设备和游戏实现的图形ADT。

勘误·Erratum

开发RIV++过程中实验发现,//bilibili.com/read/cv42611820对A类父子关系的表述有误,实为主观臆测。

A类子物体(List<GameObjectBase*> childrenA)和A类父物体(GameObjectBase* parentA)都属于GameObjectBase成员,因此是角色和物体共有的属性。一个角色或物体可以拥有多个“A子”,但每个物体只能拥有一个“A父”;A类父子之间组成“攻击判定共享链/树”,共享hit数与判定结果,并且当父子判定框同时判定时,以父类物体判定优先。总之,从招式设计的角度,可以通过给攻击加A类子物体来起到灵活扩展判定框的作用,也能够突破pat 5个判定框的上限。

判定共享并非形似链/树的关系,而是单纯判定时从A父拉取hit数与判定结果;换言之,这种共享不会链式或树状传递。

致谢·Acknowledgment

感谢相关代码仓库的所有贡献者,为本项目提供了重要的参考与启发。

感谢@HagbGreen、@PinkySmile、@Slen等社区开发者提供技术支持;感谢@三回転Tstar、@monte及社区玩家提供测试反馈。

开发使用ChatGPT、豆包等AI工具辅助,以减轻查阅、学习等负担。

附录·Appendix

这里还是用来放轶事吧。

1 无视无敌的论外招数

何方妖孽,竟能让凹卡坠机?

下面介绍在无敌显示的开发过程中偶然发掘出的标志位:unknown40000。

根据判定无敌的反编译代码,游戏会检查攻击的一些标志位来判断攻击种类,并检查该种类的无敌是否打开。

代码块
text/x-c++src
自动换行
复制代码
flags = atker->boxData.frameData->attackFlags;
if (!flags.isBullet) {//不是弹幕,检查受击方体术无敌
    if (victm->meleeInvulTimer != 0 || victm->gameData.frameData->frameFlags.meleeInvul)
        return false;//判定体术攻击失败
}
else if (!flags.unknown40000)//不是unknown40000,检查受击方弹幕无敌
    if (victm->projectileInvulTimer != 0 || victm->gameData.frameData->frameFlags.bulletInvul) {
        return false;//判定弹幕攻击失败
    }
}
if (flags.grab) {//检查投技无敌
    //...
}
复制成功

下面的表格展示了两种标志位启闭对无敌检测的影响。

那么,只要同时打开isBullet和unknown40000,就可以造出一招体术无敌和弹幕无敌都无法回避的弹幕攻击;再把grab关上,游戏也就不会去检测投技无敌;倘若继续打开unblockable(不可防)和ungrazable(不可擦)标志,口圭,传说中的必中攻击诞生啦!

把图里这个Attack flags Value(29758750)放进皇家烈焰有惊喜

好在原版游戏并没有使用unknown40000的地方,FDE也没有提供该标志位开关,只是魔改角色是否会利用这个发现就不好说了……

2 ichirin的坑爹EndScene

ichirin是谁?CharacterInForeground、PracticeEx、ScoreTracker等优质mod的一作,高技术力但不开源仙人,就是他这个坑货!

一开始尝试渲染Debug窗口时,发现PracticeEx的内容总是莫名奇妙地混进来。

wtf

幸得@Slen大佬指点,发现ichirin极其粗暴地hook了d3d9设备的EndScene函数以实现自定义渲染……大哥,这是所有渲染都必须经过的函数啊,你这修改路子也太野了,不给其他mod留条活路么?

无奈,只好在游戏启动时抢救一下原函数位置的机器码,要用EndScene时先写入原机器码把hook撤销了,用完再hook回去恢复功能。

说得简单,排查并解决这个问题可是花了我宝贵的一天啊kora,fvck you ichirin!

虽然这么骂,其实ichirin是以极其硬核的技术力,基本独立于现有研究完成了上述mod,路子野一点也无可厚非吧。fvck之后还是要向前辈表示敬意的。

3 不再随机

开发到中途,突然发现游戏的随机变得不随机了,比如打开游戏后第一次随机选人必然摇出9和灵梦,这说明随机数种子遭到了固定。

一系列排查后发现,由于要复用游戏自带的Design类(xml布局),所有额外素材都放入了dat文件,然后在需要加载时使用appendDatFile函数,而这个函数居然会将随机数种子重置为固定值。估计是涉及到加解密算法吧。游戏本身加载th123a等dat文件之后会调用srand(timeGetTime())来设置随机种子,但是RIV++一开始没有遵从这个时机要求,导致随机种子又重置回去了。

该问题已通过hook sokuSetup修复。

至于为啥正文大纲要写成论文的格式,这个不重要啦…… 忘了写“未来进展和展望”了,反正主要是把F6 Debug变得实用起来。

鱼蛙子 2025年10月8日