Skip to content

Conversation

@Starrah
Copy link
Contributor

@Starrah Starrah commented Feb 3, 2026

此前在#113 (comment) 里有部分讨论

问题分析

public static void SetSpeed()
{
player.SetPitch((float)(1200 * Math.Log(speed, 2)));
// player.SetDspTimeStretchRatio(1 / speed);
player.UpdateAll();
movie.player.SetSpeed(speed);
gameCtrl?.ResetOptionSpeed();
}

函数里面除去注释掉的,共有四行,其中前两行是正常执行没问题的

第三行会抛出一个NPE,这里的情况是,movie变量的来源是通过钩子捕获MovieController._moviePlayers,而它在1.55以上,类型被修改为了List<MovieMaterialMai2>。所以原来的代码movie.player.SetSpeed(speed)就是试图在一个List上访问player成员thanks to C#,这居然不是一个更严重的异常而仅仅只是返回null;再对null调用SetSpeed(speed)就是NPE的来源。

除此之外,如果你修好了这里,紧接着你发现第四行gameCtrl?.ResetOptionSpeed()马上又是一个NPE。这是因为,

[HarmonyPatch(typeof(GameCtrl), "Initialize")]
[HarmonyPostfix]
public static void GameCtrlPostInitialize(GameCtrl __instance)
{
gameCtrl = __instance;
}
,这个钩子会被调用两次,分别是对应1P的和2P的GameCtrl对象(其中的monitorIndex分别为0或1)。而我们亲爱的SBGA做了什么呢?他在ResetOptionSpeed()的实现里面,居然完全不会检查当前Monitor有没有人在玩游戏,而是直接调了某个“返回当前P位的玩家的配置”的函数,该函数返回了null,于是令人惊讶但是理所当然的,在ResetOptionSpeed()内部NPE了。啊,这就是我们亲爱的SBGA啊!

进一步的问题:

  1. 为什么功能还能用?
    • 前两行才是练习模式的主体,调整游戏速度。第三行是在给PV设置速度,所以之前的版本如果你开一个有PV的谱仔细观察,会发现调速对PV是无效的:你把速度调到50%,但PV并没有变慢,于是PV早早的放完。但因为主体功能(音乐和谱面的调速)在这行的前面所以没有受到影响,所以大部分人注意不到,而已。

    至于第四行是在做什么,我没有完全看懂,好像是在调一个叫作guide的东西的速度,引导线?但是舞萌并没有这个东西吧。如果有懂的可以讲讲。我只能说是,第三四行即便都不执行也不影响基本功能(音乐和谱面)的运作,音乐和谱面的变速用前两行控制就已经足够了。

  2. 为什么这个NPE没有出现在log里:
    • 不考虑自己在外面通过其他Mod调SetSpeed的情况,就说AquaMai里的逻辑,SetSpeed是通过SpeedUp、SpeedDown等函数调用的,而它们的调用点呢?在:
      else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B6))
      {
      PracticeMode.SpeedDown();
      }
      else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B3))
      {
      PracticeMode.SpeedUp();
      }
      else if (InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B5) || InputManager.GetTouchPanelAreaDown(InputManager.TouchPanelArea.B4))
      {
      PracticeMode.SpeedReset();
      }
      ,这是在public class PracticeModeUI : MonoBehaviourUpdate()函数里面。 也就是说,它是Unity负责调用的而不是Melonloader的钩子调用的,所以出了异常自然也不会被MelonLoader捕获, 而是被Unity捕获打到Unity的log里去了。

    上面这句话说的有问题,正确的分析请看[O] DontRuinMyAccount的一些增强 #113 (comment)

我修了什么(抄自commit message):

具体而言:

  1. 1.55以上,MovieController._moviePlayers的定义发生改变,从对象变为List。
  2. 先前的gameCtrl只是一个对象,但实际上对于1、2P会分别有两个GameCtrl对象。
  3. 给SBGA擦屁股,GameCtrl.ResetOptionSpeed里都不对是否开启2P做一下校验的,1P运行时直接一个NPE抛出来了。

具体而言:
1. 1.55以上,MovieController._moviePlayers的定义发生改变,从对象变为List。
2. 先前的gameCtrl只是一个对象,但实际上对于1、2P会分别有两个GameCtrl对象。
3. 給SBGA擦屁股,GameCtrl.ResetOptionSpeed里都不对是否开启2P做一下校验的,1P运行时直接一个NPE抛出来了。
@gemini-code-assist
Copy link

Summary of Changes

Hello @Starrah, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

此拉取请求旨在解决AquaMai练习模式在游戏版本1.55及以上运行时遇到的兼容性问题。通过调整对游戏内部API(如MovieControllerGameCtrl)的钩子处理方式,确保了练习模式的速度调整功能能够正确应用于视频播放和游戏选项,从而提升了用户体验并修复了潜在的空引用异常。

Highlights

  • 兼容性修复: 修复了在游戏版本1.55及以上时,练习模式中部分功能不工作的问题,主要涉及视频播放速度调整和游戏选项重置。
  • MovieController API适配: 更新了对MovieController._moviePlayers的类型处理,以适应其在1.55版本后从单个MovieMaterialMai2对象变为List<MovieMaterialMai2>的变更,确保视频速度调整的兼容性。
  • GameCtrl 多实例处理: 改进了对GameCtrl对象的管理,现在能够正确处理1P和2P的两个GameCtrl实例,避免了单人游戏时对2P的GameCtrl操作导致的空引用异常。
  • 空引用异常规避: 引入了一个新的GameCtrlResetOptionSpeed方法,该方法在调用GameCtrl.ResetOptionSpeed()时增加了空引用检查和异常捕获,以规避原版游戏代码中缺乏检查导致的问题。
Changelog
  • AquaMai.Mods/UX/PracticeMode/Libs/PractiseModeUI.cs
    • PracticeMode.gameCtrl?.ResetOptionSpeed()的调用替换为新的PracticeMode.GameCtrlResetOptionSpeed()方法,以利用新的异常处理逻辑。
  • AquaMai.Mods/UX/PracticeMode/PracticeMode.cs
    • 移除了未使用的UnityEngine引用。
    • 将静态字段movie的类型从MovieMaterialMai2更改为List<MovieMaterialMai2>,以适应游戏更新后的API。
    • 将静态字段gameCtrl的类型从GameCtrl更改为GameCtrl[],并初始化为包含两个元素的数组,以支持1P和2P的GameCtrl实例。
    • 新增了GameCtrlResetOptionSpeed静态方法,该方法遍历gameCtrl数组,并安全地调用每个GameCtrl实例的ResetOptionSpeed(),同时捕获并忽略由原版游戏代码缺陷导致的NullReferenceException
    • 修改了SetSpeed方法,使其现在遍历movie列表中的每个MovieMaterialMai2对象,并调用其player?.SetSpeed(speed),确保所有视频播放器都能正确调整速度。
    • 更新了GameCtrlPostInitialize钩子,现在根据__instance.MonitorIndexGameCtrl实例存储到gameCtrl数组的相应位置。
    • 修改了MovieControllerPostAwake钩子,将参数类型从MovieMaterialMai2更改为object,并根据运行时类型判断是单个MovieMaterialMai2对象还是List<MovieMaterialMai2>,然后将其统一转换为List<MovieMaterialMai2>赋值给movie字段。
Activity
  • 此拉取请求由Starrah创建,目前没有其他评论或审查活动。
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

本次 PR 修复了练习模式(PracticeMode)在 1.55 以上版本中因游戏内部数据结构变更而导致的部分功能失效问题。主要改动包括:正确处理了 MovieController._moviePlayers 从对象到列表的类型变化,并为 GameCtrl 增加了对双玩家的支持和空引用的安全处理。

代码改动方向正确,有效解决了描述中的问题。我提出了一些建议以进一步提高代码的健壮性和可读性:

  1. 恢复一个被误删的 using 声明,以避免在 DEBUG 模式下编译失败。
  2. 通过条件检查替代 try-catch 块,使代码逻辑更清晰,避免使用异常来控制流程。
  3. 增加一个空值检查,以防止在 movie 列表未初始化时发生崩溃。

整体而言,这是一次不错的修复,解决了兼容性问题。

Comment on lines +71 to +78
foreach (var g in gameCtrl)
{
try
{
g?.ResetOptionSpeed();
}
catch (NullReferenceException) {} // 忽略即可,因为SBGA的代码在ResetOptionSpeed内部没有做null检查,单刷的时候对2P那侧这个函数必定会抛NPE
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

使用 try-catch 来处理预期的 NullReferenceException 虽然可行,但通常不推荐将异常用于控制流。一个更清晰且性能更好的方法是,在调用 ResetOptionSpeed 之前,检查玩家是否真的在游戏中。您可以使用 GameManager.IsEntry(g.MonitorIndex) 来进行此检查。这样可以避免捕获异常,使代码意图更明确。

        foreach (var g in gameCtrl)
        {
            if (g != null && GameManager.IsEntry(g.MonitorIndex))
            {
                g.ResetOptionSpeed();
            }
        }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我懒了,反正我的注释清楚且明确,而且这个函数的调用频率很低(只在SetSpeed的一瞬间调用,而不是定期调用)。所以我不想改了。

@Starrah
Copy link
Contributor Author

Starrah commented Feb 3, 2026

这就属于那种,写代码很快,但知道怎么写很慢的,事情。花了几个小时才全部查清楚。

@clansty clansty merged commit 9cbca00 into MuNET-OSS:main Feb 10, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants