设定今天的工作计划
今天我们本来是打算继续开发性能分析器(Profiler),但在此之前,我们认为有一些问题应该先清理一下。虽然这类事情不是我们最关心的核心内容,但我们觉得现在是时候处理一下了,特别是为了让别人能更顺利地运行我们目前的版本。
目前存在的一些问题会让其他人尝试运行这个游戏变得困难,尤其是我们为了让 OBS 在处理 OpenGL 程序时能够正常流畅地捕捉画面而做的特殊处理。这段代码本身其实是个很不合理的“黑科技”——可以说是个坏主意。
在构建脚本 build.bat
中,我们设置了一个环境变量 GAME_STREAMING=1
。这个变量控制了在 game_opengl.cpp
文件中的特定代码路径。在那段路径中,我们把 wgl
双缓冲设置为了 false
,即使我们明明在使用双缓冲。这种设置理论上是非法的,我们甚至不知道它为何能工作——但它确实让 OBS 可以更好地捕捉画面,不需要我们使用一些更复杂的技巧。
对我们来说,这种做法确实让直播更轻松,但问题是,如果有其他人尝试构建游戏而没有意识到必须关闭这个变量(或不知道有这回事),那么他们的构建结果将会是完全出错的——可能是渲染混乱、崩溃,或者别的奇怪错误。
因此,现在需要清理这段临时代码,或者至少让构建系统在默认状态下是“对其他开发者友好的”,不需要他们了解直播中为了兼容 OBS 所做的特别处理。
编辑 build.bat
:引入并检查 GAME_STREAMING
环境变量
我们希望实现一个功能:在我们的构建版本中,默认开启游戏串流(game streaming),而在其他人的构建版本中默认关闭。为此,我们考虑通过设置环境变量的方式来控制这个行为。也就是说,只有在设置了特定环境变量的情况下,游戏串流功能才会被启用;否则,它将保持默认关闭。
具体设想如下:
-
使用环境变量控制功能开关
我们计划使用一个自定义的环境变量,例如GAME_STREAMING
,来判断是否启用游戏串流。只有当该变量存在并且非空时,我们才会将游戏串流相关的编译选项加入到编译参数中。 -
批处理脚本实现逻辑
在编译脚本中(如.bat
文件),我们添加逻辑判断该环境变量是否被定义:- 如果没有定义该变量,则跳转到一个标签(如
:no_streaming
),跳过添加编译标志的部分; - 如果变量已定义(即我们设置了),则将相应的编译标志(如启用游戏串流)追加到通用编译参数中。
- 如果没有定义该变量,则跳转到一个标签(如
-
编译验证与测试
- 我们首先验证未设置变量时的构建结果,确保不会启用游戏串流;
- 然后测试设置了环境变量的情况,确保游戏串流功能被正确开启;
- 还通过手动在脚本中插入测试信息(如设置不同值或输出信息)来验证判断分支是否正确执行。
-
设置系统环境变量
我们还尝试在系统中设置该环境变量,以确保每次打开构建环境时都会自动启用该功能。虽然一开始没找到正确的设置路径,但我们知道环境变量设置应该在系统设置的某个位置可以配置,后续可以完成这部分。 -
最终效果
实现后:- 我们的本地构建默认开启游戏串流功能;
- 其他人的构建将不会受影响,仍然保持默认关闭;
- 该设置对他人完全透明,他们无需做任何更改或了解这一机制;
- 编译流程仍保持简洁、自动化。
这个方案简单、可控、易于维护,同时不影响他人的开发流程。
在 CMakeLists.txt
中,检查一个环境变量是否被设置,可以使用 CMake 的 ENV{}
语法来访问环境变量,并结合 IF
语句判断。以下是具体方法和中文解释。
检查环境变量是否被设置(是否非空)
if(DEFINED ENV{GAME_STREAMING})message(STATUS "环境变量 GAME_STREAMING 已设置,启用游戏串流功能。")add_definitions(-DENABLE_GAME_STREAMING=1)
else()message(STATUS "未设置 GAME_STREAMING,默认关闭游戏串流功能。")
endif()
中文说明:
ENV{变量名}
用于访问系统环境变量;DEFINED ENV{变量名}
用于判断该环境变量是否存在;message(STATUS "...")
会在 CMake 配置阶段输出提示;add_definitions()
会向编译器添加宏定义,例如-DENABLE_GAME_STREAMING=1
;- 你也可以使用
set()
把变量保存下来,稍后再判断或使用。
如果想进一步判断变量值(例如只在值为 1
时启用功能):
if(DEFINED ENV{GAME_STREAMING} AND "$ENV{GAME_STREAMING}" STREQUAL "1")message(STATUS "GAME_STREAMING=1,启用游戏串流。")add_definitions(-DENABLE_GAME_STREAMING=1)
else()message(STATUS "GAME_STREAMING 未设置为 1,跳过游戏串流。")
endif()
测试环境变量(在命令行里设置环境变量再运行 CMake):
Windows CMD:
set GAME_STREAMING=1
cmake ..
Unix / Linux / macOS:
export GAME_STREAMING=1
cmake ..
这样就可以在 CMake 中根据环境变量是否存在或其值来决定是否启用某些功能。非常适合做本地构建开关控制。
这是主播为了防止别人OSB直播看不到设置的
设置环境变量试试
重启vscode
话说现在好像不用去.clangd 配置这个宏都高亮了
这个宏cmake中定义的
看来是clangd读取了compile_commands.json里面的宏
奇怪我如果关闭WGL_DOUBLE_BUFFER_ARB 就看不到了
删除一堆已经不再使用的旧代码
以下是对内容的中文详细总结,不涉及个人、开发者或作者,仅以“我们”视角进行客观陈述:
我们在构建流程中检查了几个模块的使用情况,发现某些部分目前并未实际启用,或已经不再使用,因此决定进行精简和清理,以简化构建逻辑并减少维护负担。
首先确认了预处理器相关的功能目前并没有实际使用,因此选择将该部分逻辑禁用,以防止它在构建过程中被不必要地调用,避免对其他人造成困扰。与此类似,“game_generated” 文件部分也未被使用,因此一并处理并移除。此外,“game_metadata” 的引用也被删除,因为文件已经不再存在,再保留相关代码会导致构建报错。
随后检查了其他可能冗余的调试代码,例如 debug_dump_struct
相关逻辑。确认已经没有调用,因此决定清除这些无效代码,以减少项目复杂度。这样做的目的是让其他开发人员不需要再处理这部分内容,降低理解和调试负担。
在 OpenGL 的部分代码中,存在一些类型转换操作。了解到这些转换对 GCC 编译器存在兼容性问题,因此考虑移除相关转换逻辑。部分代码中指针转换看似多余,因此也一并清理。
另外提到项目中有大量加载的文件,因此非常希望实现一个工具,可以扫描并搜索当前项目中所有加载的文件路径。尽管开发这个工具本身非常简单,目前尚未实现,仅因时间紧张和事务繁忙所致。
继续清理中,发现仍存在一些旧的头文件,如 metadata.h
等,这些文件也已经不再被需要,因此决定将它们完全移除。
最后,为了继续简化渲染逻辑,对 bitmap 相关部分进行排查,判断其位于哪个模块。回忆之后确定该部分代码可能存在于 render_group
模块内,因此接下来将转向该模块继续清理和优化工作。
总的来说,以上操作旨在清除无用代码、简化构建流程、提升项目整洁度,并降低他人理解和编译时所面临的复杂性。
修改 game_platform.h
:将 PointerToU32
改名为 U32FromPointer
并相应更改调用位置,避免 GCC 和 Clang 的编译警告
我们在处理纹理句柄(texture handle
)的过程中,发现由于其定义为 void*
指针类型,在某些编译器(如 GCC)中进行类型转换时会触发不必要的强制类型转换警告。尽管显式进行了类型转换(cast),编译器依然认为这是不被推荐的操作,表现得过于严格。
为了避免这些多余的警告,同时也提升代码的整洁性和可移植性,我们决定引入辅助宏或内联函数来封装这类转换操作。具体来说:
- 定义了一个
uint32_from_pointer
(或类似命名)的方法,将void*
类型指针安全地转换为uint32_t
; - 也定义了反向转换
pointer_from_uint32
,用于从uint32_t
转换回void*
; - 为了更通用和可扩展,可能进一步抽象成模板形式或带类型参数的宏函数,例如通过参数指定目标类型并完成转换;
- 在转换过程中,先将指针值提升到合适的整数宽度(如
uintptr_t
或uint64_t
),再进行类型转换; - 所有这些目的是为了让编译器接受这些转换而不报错,特别是在 Clang 和 GCC 中保持一致性。
除了转换函数本身,我们还审查了当前平台层中的一些纹理分配或架构接口,发现其中某些部分早已从特定平台模块中独立出来,因此不再应当放置于 Windows 相关代码段内。于是计划将相关函数移动到更合理的通用位置,以反映架构变化并简化结构。
此外,我们也准备在项目中各处将原先手动类型转换的地方,替换为刚刚定义的转换函数。这种做法不仅统一风格,还避免开发者在不熟悉编译器行为时遇到困惑或错误信息。
整体目标是减少编译器发出的不必要警告,使代码更稳定、更易维护,并改善他人的开发体验。
根据clangd 的警告去掉警告
运行游戏,确认一切正常运行
经过一番调整和清理之后,我们的代码现在运行正常,达到了预期效果。这些调整包括解决了几个简单且被遗留的问题,尤其是那些能够迅速解决的小问题,这使得项目变得更加整洁,减少了不必要的复杂性。
虽然这些问题已经被清理掉,但我们计划继续进一步检查相关的功能,确认是否还有其他类似的问题存在。不过,目前我们已经解决了最紧迫且最明显的问题,所以我们决定先处理这些简单的任务,以便尽早清除掉积压的工作。
接着,我们意识到已经花费了一些时间,检查了代码的执行情况并进行了优化。现在,剩下大约 40 分钟的时间。为了高效利用这段时间,我们计划继续进行性能分析(profiling),以进一步提高项目的性能表现。然而,在此之前,我们还在考虑是否有其他重要的任务可以一并解决。
在接下来的工作中,我们会关注变量初始化失败的相关问题,这是之前被提到的一项需要修复的情况。同时,也有团队成员讨论了如何在 OpenGL 环境下保持 fader
(渐变效果)的正常工作,这也是一个需要进一步解决的问题。
总体来说,尽管当前已经取得了一些进展,但仍有其他问题需要继续优化和修复,以确保项目在不同平台和环境下的稳定性和性能。
修改 win32_game.cpp
:移除淡入淡出效果(fader)
关于渐变效果(fading in and out),我们并不想继续支持这一功能。最初添加这个功能是因为某个团队成员想知道如何实现它,但我们认为这其实是一个非常糟糕的主意。原因在于,这个功能涉及到对 3D 图形卡的初始化,这本身就很容易出问题,而渐变效果只是给这个问题增加了更多的复杂性,可能会导致更多的错误。
因此,理想的做法是彻底移除渐变效果的代码。虽然我们已经展示了如何添加这个功能,但如果某些开发者想使用它,可以参考我们的代码并自行实现,我们不会继续支持这一功能。我们认为,将这个功能保留在项目中是没有意义的,特别是在实际发布时,这个功能可能会导致一些图形驱动程序初始化失败或出现其他问题。
所以,我们决定去掉这一功能,并删除与其相关的所有代码。具体来说,我们会删除与渐变效果相关的 theater
代码,并且去掉其中的窗口显示控制和其他不必要的部分。我们不再需要这些代码来显示窗口或处理窗口可见性的问题,因此这些代码也将被移除。
我们会在游戏启动前确保窗口显示逻辑正常,并在初始化完成后直接显示窗口。这样,整个程序在启动时就可以顺利运行,而不需要处理渐变效果带来的额外麻烦。
如果有人真的想要在自己的项目中使用渐变效果,可以参考原来的实现,但这并不在我们需要支持的范围内。我们决定不再将这个功能包括在我们的发布版本中,因为它可能会影响到不同机器上的图形驱动,导致各种潜在的问题。因此,渐变效果会被完全移除,任何人如果愿意,可以自行处理和实现。
去掉结构体挨着修改错误就行
考虑是否要移除多线程的 OpenGL 上下文
我们目前还在思考另一件事情,就是是否要撤销 OpenGL 多线程上下文的实现。从目前了解的情况来看,多上下文的处理实际上并没有带来太大实际好处,除非使用的是高端显卡,比如专业的 NVIDIA Quadro 系列。这类显卡提供了真正的“复制引擎”(copy engines),使得多上下文的图像资源复制能够带来性能提升。
但在大多数消费级显卡上,并没有这种优化机制存在。因此,在这些普通显卡上使用多个 OpenGL 上下文,实际上只是增加了系统的复杂性,却没有带来任何性能上的好处。
由此可以得出一个结论:我们实际上并不需要额外的上下文。更有效的方式可能是在主线程中直接准备好纹理资源。我们可以在程序初始化或资源加载阶段预设好所有纹理,比如使用像 Pixel Buffer Object(PBO)这样的机制来提前准备好纹理数据,然后仅在需要时调用一次 glTexImage
进行上传,而不再通过多线程上下文去提交这些纹理。
采用这种方式,流程将变得更加简单,数据准备会更清晰,纹理上传也不会被其他线程打断。同时也能避免潜在的多线程 OpenGL 同步和状态管理的问题。
目前还不确定是否要立即对现有的多上下文处理进行改动,毕竟虽然这样做可能不是最有效率的,但也勉强可以正常运作。可以暂时保留当前实现,日后再做优化调整。
总之,现在已经成功移除渐变效果(fader)相关功能,这是一个令人满意的阶段性成果。当前正处于项目清理的阶段,虽然这些工作比较琐碎无趣,但对整体架构的精简和未来维护都是有益的。接下来是否进一步优化纹理上传机制,视时间安排和优先级决定。
Pixel Buffer Object(PBO) 是 OpenGL 中用于异步像素数据传输的缓冲对象,主要用于提高纹理上传和像素读取的性能。它是 OpenGL 的一个扩展,允许在 CPU 和 GPU 之间更高效地传递图像数据。
简单理解:
在不使用 PBO 的传统方式中,纹理上传或读取像素数据(如 glTexImage2D
、glReadPixels
)是同步阻塞操作,CPU 会等待 GPU 完成操作,导致性能瓶颈。
使用 PBO 后,这些操作可以变成异步的:
- CPU 可以把像素数据传给 PBO,继续干别的事;
- GPU 在后台处理这些数据,不阻塞 CPU;
- 这就实现了CPU 和 GPU 并行工作,提升了效率。
用法总结:
1. 创建 PBO
GLuint pbo;
glGenBuffers(1, &pbo);
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo);
glBufferData(GL_PIXEL_UNPACK_BUFFER, size, NULL, GL_STREAM_DRAW);
2. 向 PBO 写入数据
void* ptr = glMapBuffer(GL_PIXEL_UNPACK_BUFFER, GL_WRITE_ONLY);
// 写入数据到 ptr 指向的内存
glUnmapBuffer(GL_PIXEL_UNPACK_BUFFER);
3. 上传纹理
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);
// 这里的 `0` 表示从当前绑定的 PBO 中取数据
4. 解绑 PBO
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
典型用途:
- 高频纹理更新(如视频流、屏幕录制、游戏串流)
- 读取像素数据时避免阻塞(比如截图)
- 多缓冲技术(双 PBO 交替读写)
注意事项:
- PBO 不自动提高性能,只有在正确使用异步特性时才有优势;
- 实现和效果依赖显卡和驱动;
- 对于小图像数据,开销反而可能更大;
- 常与
GL_STREAM_DRAW
、GL_STREAM_READ
配合使用。
如果你正在做一个需要频繁传输纹理数据的程序(比如串流游戏图像),PBO 是一个可以显著减少卡顿和提高帧率的重要工具。
再次运行游戏,并指出热重载时分析器存在的一个 bug
我们之前注意到一个可能可以复现的 bug,虽然没有人专门在 GitHub 上提到它,但我们观察到在某些情况下,当执行重构并触发重新加载时,字符串数据似乎会丢失,导致某些功能异常。比如在某些情况下,profiling 数据(性能分析数据)会消失,而这些应该是正常保留的。
我们怀疑这个问题可能出在字符串的管理或重载逻辑上。我们尝试通过修改代码、强制重建并重新加载程序的方式来观察这个 bug 是否能复现。最初的问题表现为,在重新加载之后,profiling 数据没有出现,或者某些字符串数据在界面上丢失。
我们尝试做一些比较明显的代码更改,比如直接去掉用于渲染场景中元素(比如墙体、武器、怪物等)的全部代码,来制造比较大的差异,强制触发重新加载机制。但是在实际操作中,程序并没有像我们预期的那样出错,反而依然运行正常,profile 数据虽然没有显示,但也没有直接报错。这说明可能 bug 本身未能触发,或者之前的问题被不经意间修复了。
在尝试制造代码出错行为时,我们进入了反汇编调试模式,试图理解跳转逻辑和条件断点设置的问题。我们分析了跳转指令、断点条件以及寄存器的变化,最后决定绕过特定的跳转条件,通过人工覆盖跳转目标的方式来避免断言失败。我们考虑使用一些简单的填充指令(例如 NOP)或直接修改指令流,使其“跳过”有问题的逻辑路径,这样就可以继续执行程序,而不会中断在不想要的地方。
总结来看:
- 我们曾遇到字符串数据或 profile 数据在重新加载后消失的问题。
- 试图重现这个 bug,但目前似乎无法复现。
- 怀疑问题可能与重载逻辑、字符串表、断言机制或跳转指令有关。
- 通过手动修改反汇编逻辑试图继续程序执行,跳过错误断点。
- 目前仍在探索问题的根源,不过已经有一些方向,例如字符串生命周期管理或条件分支处理。
后续可能还会进一步深入调试,找到数据丢失的真正触发点,并分析其与热重载系统或运行时内存管理之间的关系。
调试器中:手动写入 XOR 指令到内存
我们正在处理一个断言触发的问题,这个断言的逻辑是用于检测在渲染实体过程中是否遗漏了某些必须处理的情况。我们修改了一部分代码逻辑,导致原来的断言开始报错,因为它检测到我们没有处理所有应有的实体种类。由于我们现在只是临时注释或移除了一部分逻辑,断言因此失败是意料之中的。
为了解决这个问题,我们没有直接修改断言的条件或者绕过调用路径,而是采用了“外科手术式”的二进制补丁方式。我们进入了反汇编视图,定位到了断言触发前的汇编代码段,并找到了具体的地址区域。
我们分析了跳转指令和寄存器操作的具体内存布局,识别出用于断言判断的 test
指令和 xor
指令所在位置。然后我们决定将这些指令覆盖成一些无害的操作,以便程序可以继续执行而不中断。
我们采取的策略是用一系列 test eax, eax
(对应汇编为 85 C0
)指令填充原位置,这些指令不会对程序状态造成副作用。接着,我们插入一个 xor eax, eax
指令来维持寄存器状态的一致性,确保后续指令仍能正常运行。
因为 x86-64 是变长指令集,所以我们需要精确控制覆盖区域的字节长度,确保既不截断现有指令,也不留下无效的机器码,从而避免运行时崩溃或未定义行为。我们确认整个补丁操作在语义上是“无害”的,仅仅是跳过了不必要的断言检测。
最终结果是:断言被成功绕过,程序逻辑仍能正常继续执行,且不会触发错误或中断。
总结:
- 我们遇到了一个由于实体未完全处理而触发的断言。
- 为了避免断言中断程序,我们通过手动修改内存指令的方式绕过断言。
- 使用了一系列安全的
test eax, eax
和xor eax, eax
来填充指令空间。 - 这样修改后程序能稳定运行,便于继续调试和观察后续行为。
这是一种典型的底层调试技术,适用于需要快速验证变更影响但不想修改大量高层代码逻辑的场景。我们做到了最小化干扰且保证执行环境稳定。
这个修改内存应该打开vs
用vs打开二进制文件的路径
哎不对pdb没删掉吗
之前把依赖项删掉了
重新编译之后可以了
触发之前的段错误
换成提示的vssetings看上去还行
这段机器码替换是你在调试或运行时手动修改内存的一种方式,目的是绕过崩溃或断言等非法路径。我们逐字节来解释发生了什么,前后含义完全不同,下面是详细对比与解释。
原始机器码(崩溃前):
c7 04 25 00 00 00 00 00 00 00 00
逐字节解释:
这是一个 mov
指令,操作的是绝对地址:
c7 04 25 00 00 00 00 -> mov dword ptr [0x00000000], 0x00000000
它的结构如下:
c7 04 25
:表示使用绝对地址模式(mov [imm32], imm32
)00 00 00 00
:目标内存地址 =0x00000000
00 00 00 00
:写入的值 =0x00000000
问题:
试图写内存地址 0x00000000
(空指针地址),在现代操作系统下会触发段错误(segmentation fault),因为这是无效地址,操作系统保护了该页不被访问。
修改后的机器码:
48 85 c0
48 85 c0
48 85 c0
33 c0
拆解解释:
按顺序来:
-
48 85 c0
→test rax, rax
测试rax
是否为 0,不会改变寄存器值,只会影响标志位。(重复了三次,是为了覆盖原先等长的字节)
-
33 c0
→xor eax, eax
将eax
清零,等价于mov eax, 0
,但更高效常用。
为什么这样修改?
- 原本是个非法写操作(空指针写),会崩溃。
- 改成无害操作,比如
test
(逻辑判断但无副作用)+xor
(清零但合法) - 确保总共是 11 字节(覆盖原指令长度),避免执行跳转错位。
总结用途
原始指令 | 作用 | 问题 |
---|---|---|
mov [0x0], 0x0 | 向 NULL 地址写入 | 触发段错误(崩溃) |
替换后指令 | 作用 | 效果 |
---|---|---|
test rax, rax x3 + xor eax, eax | 无害清零/判断 | 不会崩溃,继续执行 |
适用场景
- 绕过无效内存访问
- 临时屏蔽断言或危险行为
- 继续运行程序做后续调试
如果你还想了解怎么在调试器里实际操作这段替换(例如在 GDB 或 WinDbg 中),我可以进一步示例操作。是否需要?
点击continue 继续运行
运行游戏,发现字符串显示出问题
这个我没出现
我们发现程序中的字符串内容出现了破损(损坏、乱码或丢失),经过验证,这种问题确实存在,并且可以稳定复现。目前已确认字符串数据在某些条件下被破坏,这意味着之前关于“字符串应该被正确保存”的假设并不成立,保存过程存在问题,具体原因尚待进一步调查。
为排查问题,我们执行了一些操作:
- 通过一次字符串读取的场景确认字符串确实出错;
- 将某些代码逻辑注释掉,避免每次重现问题时都需要复杂操作(例如手动修改汇编指令或补丁内存);
- 成功建立了一个稳定的复现步骤:只要执行某段逻辑或设置某个标志为 0,就可以触发字符串损坏;
- 这让我们可以方便地进入调试流程,进一步分析字符串损坏发生的位置和原因。
我们推测字符串损坏的根本原因可能很简单,比如字符串没有正确拷贝或指针悬空,但由于这是一个热更新/代码重载相关的流程,涉及到较多的底层内存操作,不能掉以轻心。这类系统非常敏感,尤其是与调试功能结合使用时,如果字符串系统不可靠,会严重影响调试信息的可读性,进而影响开发效率。
当前计划是基于这个可复现用例,继续深入排查到底是哪个阶段破坏了字符串,确保热重载和调试系统可以正常共存。我们需要特别关注字符串的生命周期管理、静态存储与动态更新之间是否存在冲突,或者是否有未处理好的数据拷贝边界问题。
修改 game_debug.cpp
:修复字符串处理问题
我们发现当前调试菜单中显示的字符串(特别是头部字符串)出现损坏的问题。这些字符串不是存储在单个调试元素中,而是保存在调试树结构中的“变量组”节点上,通过这些节点中的 name
和 name_length
字段进行引用。经过分析和代码排查,我们意识到这些字符串在热更新或重载后被错误地引用或丢失,其根本原因是指针仍然指向旧的(可能已被释放或无效的)内存区域。
为了解决这个问题,我们进行了如下处理和优化:
一、定位问题字符串的来源
- 字符串来源于变量组的层级结构,而非单个调试项;
- 每个变量组在创建时都会传入一段字符串作为名称,我们发现这部分字符串可能未被正确拷贝,而是直接引用原始内存。
二、改进变量组字符串存储方式
我们决定不再直接使用外部指针来引用名称字符串,而是在创建变量组时主动拷贝字符串内容,确保其生命周期独立、稳定:
- 删除了原本通过
name
和name_length
直接引用外部内存的方式; - 改为使用内存池进行字符串复制;
- 实现了一个新的函数
PushStringNoTerminate
,用于将字符串按指定长度拷贝到内存池中,并在结尾自动添加 null 终止符; - 这样可以防止重载代码或热更新后原内存失效造成字符串内容混乱。
三、对克隆逻辑进行优化
- 对于已经存在的变量组,在克隆子节点时,不再复制字符串内容,而是直接让子节点指向原始字符串;
- 这样可以避免重复拷贝相同的字符串内容,节省内存,同时保持一致性。
四、清理和精简判断逻辑
- 精简了字符串比较函数
StringsAreEqual
的使用; - 通过简化判断过程提高代码可读性,减少冗余逻辑。
最终目标和状态
通过这一轮修改,我们确保了变量组中的名称字符串:
- 始终存储在有效的内存中;
- 不依赖外部不可控的生命周期;
- 不会在代码重载或调试时发生内容损坏;
- 能够稳定显示在调试菜单中,保证调试信息的可用性。
整体来说,这是一次围绕调试系统稳定性的改进,重点解决了内存生命周期与指针引用不当导致的字符串损坏问题,增强了程序在开发过程中的可维护性和可靠性。
恢复之前代码
运行出现奇怪的Bug
再次运行并测试热重载,字符串稳定但发生崩溃
我们继续调试整个系统,重点是验证字符串相关问题是否已经彻底解决,并且顺便检查另一个尚未明确的问题——性能分析(Profile)数据为何消失。
一、字符串问题修复后的验证
我们在修复字符串复制逻辑后进行了以下验证操作:
- 重新回到之前的问题点,例如重新加载渲染组;
- 检查热重载或代码修改后是否仍然出现字符串被破坏的问题;
- 结果显示当前字符串已经可以稳定保留,不再在多轮运行或修改后出现乱码或丢失;
- 渲染系统中的调试信息显示正常,表明我们对内存池中字符串拷贝与指针引用的修复有效。
二、跨模块验证稳定性
- 进一步进入游戏逻辑部分,而不是仅仅停留在调试模块中;
- 原先我们观察到的字符串破坏其实是出现在实际游戏运行中,而非调试菜单本身;
- 当前进入游戏验证时,发现系统运行出现异常,似乎崩溃了;
- 崩溃发生的位置较为奇怪,和之前的渲染或字符串逻辑并不直接相关。
三、当前状态总结
- 字符串复制和管理的问题目前看起来已经被成功解决;
- 字符串不再在热重载过程中丢失,调试数据的可读性也已恢复;
- 然而,系统在实际运行中仍存在潜在崩溃点,可能是另一个独立的问题,暂时还未定位;
- 性能分析(Profile)功能仍无法正常使用,需要作为下一个重点进行排查;
- 当前程序可以稳定编译、运行并显示调试信息,但仍有其他系统性问题等待解决。
后续计划
- 跟进崩溃点分析,确定具体的出错模块;
- 查明性能分析功能失效的原因,确保调试工具完整可用;
- 全面验证字符串处理在所有运行路径中的一致性,确保不会在边缘情况中回退为旧逻辑或触发内存越界。
调试器中:检查线程,发现 OpeningEvent
不一定是有效的
我们在调试过程中发现一个新的问题,这次和字符串无关,而是出现在碰撞帧(collision frames)处理流程中,但问题仍可能与内存一致性或重载过程相关。
一、碰撞帧逻辑异常触发
- 碰撞帧本身和字符串并没有直接关系;
- 但是在重新加载(reload)代码或资源后,系统在处理碰撞帧时却触发了错误;
- 初步看上去像是某个状态不一致导致的异常。
二、线程状态分析
- 相关线程是有效的,说明调度或任务上下文没有问题;
- 第一个打开的代码块也是有效的,没有指针悬挂或非法访问;
- 但“opening event”(开启事件)似乎存在问题。
三、开启事件可能的问题
- 虽然这个事件本身仍在缓冲区(buffer)中;
- 但这个缓冲区是一个静态缓冲区(static buffer);
- 静态缓冲区通常意味着生命周期贯穿整个程序运行;
- 然而,可能在热重载过程中,缓冲区数据没有正确更新,或者某个状态被污染了;
- 因此,事件数据虽然“形式上”还在,但可能已经无效,导致了崩溃或错误。
四、当前结论
- 本次异常出现在碰撞帧处理过程中;
- 涉及的数据结构看似存在,但内部状态可能已经不可用;
- 可能与静态缓冲区的生命周期或热重载后的状态恢复有关;
- 虽然字符串模块已修复,但系统其他部分在重载后仍可能遗留无效指针或状态不一致的问题;
- 需要进一步确认静态资源在重载流程中的有效性与更新逻辑,避免引用旧数据或未同步数据。
后续排查方向
- 检查热重载过程中静态缓冲区是否被正确刷新或重新初始化;
- 确认事件系统中所有指针和状态是否同步;
- 验证碰撞帧中是否还有其他类似的数据使用了“看似有效实则无效”的引用;
- 建议对所有跨重载生命周期的对象添加更严格的校验机制。
问题探讨:为何 GlobalDebugTable
是静态缓冲区?
这个问题的核心是关于为什么在某个地方使用了静态缓冲区(static buffer)。经过分析,似乎这个设计存在一些不合理之处,可能是遗留问题。
一、静态缓冲区的使用疑问
-
静态缓冲区的定义: 这里的静态缓冲区是作为全局调试表(global debug table)来使用的,数据被写入这个表中。
-
为什么使用静态? 使用静态缓冲区的原因不明确,可能是因为历史原因或某种特定需求,但这种做法可能带来一些问题。
-
静态的潜在问题:
- 潜在重定位: 静态缓冲区在程序重载时可能会被重新定位,这会导致原本应该持续有效的数据失效或出错。
- 位置不合理: 静态缓冲区的使用地点不当。如果它确实是全局的,那么应该放在平台层(platform layer)而不是游戏层(game layer)。这样做不仅可以减少游戏层的复杂性,还能使平台层更好地管理底层资源。
二、设计缺陷分析
- 静态缓冲区放置错误: 把静态缓冲区放在游戏层可能带来不必要的麻烦,尤其是当需要重载或进行内存操作时,游戏层对它的管理可能不够细致。
- 平台层的更合理选择: 理论上,平台层应该负责管理这些底层资源,并且将其传递给游戏层使用。这样,平台层可以确保资源的生命周期被正确控制,避免了游戏层和底层逻辑之间的不必要耦合。
三、反思与总结
- 设计问题: 当前的设计让调试表作为静态缓冲区存在于游戏层,这种做法显得有些不合理,也容易在重载或状态更新时导致问题。游戏层并不应该直接管理这些底层的资源。
- 更好的设计方式: 应该将静态缓冲区的管理职责移到平台层,确保平台层对这些资源有更清晰的控制权和生命周期管理。平台层将这些资源传递给游戏层,这样可以减少不必要的复杂性,也能避免许多潜在的错误。
- 历史遗留问题: 看起来这个设计可能是过去的遗留问题,当前应该进行重新审视和修正,以确保代码更加健壮、灵活。
后续行动
- 重构建议: 对现有的资源管理进行重构,特别是将静态缓冲区的管理放到平台层,确保资源的生命周期被正确控制,避免直接将其放置在游戏层中。
- 进一步调试: 在修正资源管理问题的同时,确保所有的调试数据都能被正确保存和读取,避免再次出现类似的字符串损坏问题。
在 win32_game.cpp
中让 GlobalDebugTable
成为主控版本,并重写其整理逻辑
问题与分析:
当前的设计存在一个核心问题,即如何管理和传递调试表(debug table)。目前,调试表的传递方式存在一些混乱和不合理的地方,特别是在游戏和平台之间的交互上。分析后发现,现有的实现方式可能并没有经过深思熟虑,导致一些不必要的复杂性和潜在问题。
1. 当前的实现方式问题:
- 调试表的传递问题:目前,调试表的传递方式是游戏从外部获取调试表,这样的做法不清晰,也容易导致问题。我们并不清楚为什么要这么设计,尤其是为什么要从游戏获取调试表。
- 不必要的初始化:例如,事件数组的索引(
event array index
)被设置为零,可能是为了防止在没有游戏数据时,末尾的数据被覆盖,但这种做法显得不够合理。 - 静态缓冲区的管理:当前的静态调试表缓冲区放置在游戏层,容易导致重载时出现问题。正确的做法应该是将静态调试表放到平台层,平台层再将其传递给游戏层。
2. 计划的改进方案:
- 将调试表放置在平台层:理想的做法是将调试表放到平台层,而不是游戏层。平台层负责管理调试表,游戏层只需要使用它。这种方式能够更好地分离游戏逻辑和平台层资源的管理。
- 传递方式调整:应该在初始化阶段将调试表传递给游戏层。具体做法是在游戏开始时,通过
memory debug table
来设置全局调试表,避免游戏层管理调试表的生命周期。也就是说,调试表应该在程序开始时初始化,并直接传递给游戏使用。 - 删除不必要的返回值:在新设计中,调试表不再需要通过返回值从游戏层传递出去,而是直接在初始化阶段通过平台层传递给游戏。
3. 改进步骤:
-
调整调试表的管理方式:确保调试表作为全局变量,应该由平台层负责管理。游戏层只需要在初始化时接收并使用调试表。
-
初始化调试表:在游戏的更新和渲染阶段,初始化时直接通过
global debug table
来设置调试表,而不再依赖返回值传递调试信息。 -
简化代码:去除多余的返回和不必要的操作,简化代码逻辑,减少复杂性。例如,将一些冗余的操作(如事件索引的设置)去除,避免不必要的状态更改。
-
代码结构调整:通过将全局调试表的管理从游戏层移至平台层,并确保初始化时直接传递,来提升代码的清晰度和可维护性。
4. 结果预期:
通过以上改进,调试表的管理会更加清晰和规范。平台层和游戏层之间的交互将变得更加明确,减少了因设计不当导致的潜在错误。同时,通过简化代码,能提高代码的可读性和可维护性,也能降低后续修改和扩展的复杂性。
最终的目标是确保调试表能够稳定地存储和传递,而不在游戏层和平台层之间产生不必要的混淆和冲突。
运行游戏并做些微调
问题与目标:
当前目标是确保调试代码可以稳定运行,并在调试过程中避免崩溃。希望通过反复操作,确保在多线程和跨帧边界的情况下,调试代码能够正确报告问题。
1. 调试代码的重新加载:
调试代码正在重新加载,并且不再仅仅依赖于单一的线程和帧。这意味着调试系统现在可以处理更复杂的场景,尤其是在多个线程并且跨帧边界的情况下进行捕获和报告。
2. 增加多线程支持:
通过对调试系统的改进,能够处理多个线程的调试信息,这些线程可能会跨越帧的边界,确保即使在这种复杂的多线程和跨帧的环境下,调试信息也能被正确地捕捉和报告。
3. 程序的稳定性:
尽管增加了复杂的调试机制,当前的目标是在调试过程中尽量避免程序崩溃。通过对调试代码的进一步操作,确保它能在多种环境下稳定运行,不会因复杂的操作导致崩溃。
4. 调试代码的灵活性与执行:
这次的更新使得调试代码更具灵活性,能够适应更多的复杂场景和多线程的需求。通过在程序执行过程中加入更多的调试工具,能够更好地监控和捕捉问题。
5. 个人对引擎编程的偏好:
在过程中,明确表示了对于引擎编程的兴趣和对游戏编程的兴趣相对较低。这也表明在调试和开发过程中,优先关注的方向是引擎本身的稳定性和调试功能,而非游戏逻辑本身的开发。
总结:
整个过程中,调试代码得到了改进,增强了多线程支持,确保即使在复杂的操作和跨帧情况下,调试信息也能正确捕捉和报告。程序的稳定性在逐步提升,而开发重点则更多地放在引擎层面的优化与调试功能的增强。
调查分析器(Profiler)存在的问题
问题描述:
当前的关键问题是,为何配置文件(profile)会消失。在调试系统中,配置文件本应保持存在,但在实际运行时,配置文件却意外消失。我们怀疑这可能与调试系统的某些操作有关,但不清楚具体的原因。
1. 分析调试代码:
调试代码中,我们会进行调试块的操作,并查看是否有正确的根节点配置。调试的过程中,系统会执行一系列开始和结束的调试块操作,并进行数据记录。正常情况下,这些操作是配对的,且每一帧的开始都应该有对应的“根”配置节点。
2. 代码重载的问题:
一个可能的原因是,代码重载过程中没有正确创建根配置节点。在重载代码时,系统本应根据当前的调试块,重新创建根节点。如果在此过程中出了问题,可能会导致没有设置根配置节点,从而造成调试信息丢失,甚至配置文件消失。
3. 推测的原因:
假设代码重载过程中没有正确处理调试块,导致在某些情况下,调试系统没有正常关闭之前的块,从而未能正确创建根配置节点。这会导致后续的调试操作中,未能正确识别和设置根节点,从而出现配置文件消失的现象。
4. 当前的调试流程:
在正常情况下,调试系统会在执行时创建调试块,每个块都有开始和结束的操作,确保调试信息的完整性。当执行完一个操作时,调试系统应关闭当前块并开始新的块。这些操作和调试信息应当匹配,不应该丢失。
5. 事件数组的问题:
另一个需要注意的问题是,事件数组的索引为什么会被重置为0。根据调试信息,我们发现事件数组的索引被错误地设置为零,这可能是问题的根源。如果事件数组的索引始终为零,系统可能只能使用一个事件数组,这就会导致在进行调试时错过某些数据,影响调试信息的收集。
6. 调试表的操作:
在调试表中,有两个事件数组交替使用,即pingpong。然而,当我们将事件索引设置为零时,实际上表明我们只使用了其中一个数组。这会导致调试信息丢失,因为系统只会记录一个数组的内容,而另一个数组的内容可能被忽略。
7. 问题的核心:
根本问题是,调试系统中的事件数组和调试块的管理不当,特别是在代码重载和更新过程中,导致根配置节点没有正确设置或事件数组索引被重置。最终,调试信息无法完整记录,导致配置文件丢失。
解决方案方向:
- 修复代码重载中的根配置节点创建问题,确保每次代码重载后,都会重新创建并正确设置根配置节点。
- 避免将事件数组的索引重置为零,确保每次调试操作都使用正确的数组,避免错过调试信息。
- 优化调试块的管理,确保每个调试块的开始和结束都能正确匹配,避免在调试过程中丢失数据。
通过这些调整,可以解决配置文件消失的问题,并保证调试系统的稳定性和可靠性。
在 win32_game.cpp
中添加条件清除:若游戏加载失败则清除调试事件数组
我们分析当前调试系统中事件数组索引的行为,并尝试确认其中的问题。具体逻辑如下:
1. 当前事件数组索引的切换机制:
事件数组索引(event array index
)通过一种**交替切换(ping-pong)**的机制在两个数组之间来回切换。每一帧结束时,会通过 原子交换(atomic exchange) 操作来交换当前索引的位置,这样可以保证新的调试事件写入到一个干净的区域,避免与之前帧的数据混淆。
2. 原子交换的作用:
- 原子交换操作不仅负责切换当前使用的事件数组索引,同时也会清空被切换到的那一组事件数组中的数据;
- 这样可以确保下一次使用时,调试系统写入的是一个全新的空数组,保证数据干净且一致。
3. 对原先逻辑的怀疑:
回顾旧逻辑,存在一段代码在每一帧结束(frame end)时强制将事件数组索引清零。这段逻辑很可能是过去在调试系统中共享调试表(debug table)时的遗留物。
这种强制清零可能是为了清理未被主动清空的数组数据,防止因游戏未加载而导致数据积压。也就是说,如果游戏未成功加载,就无法进入正常的帧结束流程,也就无法正确清除旧数据。因此添加了这段逻辑作为保护机制。
4. 对现状的改进建议:
- 当前系统已不再使用共享调试表,因此这段强制清零的代码已经不再必要,甚至可能引起调试数据丢失;
- 应该只在一种情况清空事件数组索引:当游戏加载失败时。这时确实需要清理调试数据,以免堆积;
- 正常加载游戏的情况下,绝不应该强行清零事件数组索引,否则会导致调试信息在每帧结束前丢失或错乱。
5. 改进逻辑建议:
if (game_load_failed) {// 只有在游戏加载失败的情况下清零debug_event_array_index = 0;
}
这样做可以确保:
- 游戏未加载成功 → 清除调试事件数组,避免积压;
- 游戏加载成功 → 继续使用正常的 ping-pong 索引切换,不清空,保持调试数据完整。
6. 总结:
调试系统中原本用于清理事件数组索引的逻辑应当仅限于游戏加载失败的情况。其他情况下,这一行为会导致调试数据出错或丢失。通过引入条件判断逻辑,可以使系统在保留必要保护机制的同时,确保调试信息的完整性与准确性。
运行游戏并继续问题调查
我们认为当前的修改更为正确。之前的做法存在严重问题:调试事件总是写入到同一个事件数组中,这种做法会导致多个线程在写入调试信息时发生冲突。若其他线程仍在使用同一个数组位置,调试信息就会被覆盖或丢失,造成调试数据异常。这种行为是错误的,可能会导致分析时出现误判或信息缺失。
经过更正后,调试事件数组通过 ping-pong 机制交替切换,避免线程之间的冲突。这是符合我们预期的行为。
随后进行了验证操作,虽然一开始认为这个问题并不是导致性能分析信息丢失的根本原因,但修改后发现性能分析信息重新显示出来了,说明问题的确是出在调试事件数组被错误清空上。
这说明,事件数组被清空后丢失了根分析节点,导致后续帧中的性能数据无法正确关联和显示。
进一步分析表明,性能分析信息的消失可能还有其他隐患,例如线程调试块未正常关闭。如果在代码重载或帧切换时存在悬挂的线程块(thread block)未被正确结束,那么整个分析树的结构就会不完整,从而无法正确显示分析信息。这一点也必须特别关注。
总结如下:
问题诊断与修复过程:
-
原问题:
- 每一帧结束时错误地强制将事件数组索引重置为零;
- 导致调试系统始终写入同一个数组;
- 多线程调试信息发生覆盖,性能数据丢失;
- 导致性能分析界面无法正确显示根节点及相关数据。
-
修复措施:
- 移除无条件清零操作;
- 仅在游戏加载失败的情况下清空事件数组;
- 恢复 ping-pong 切换机制,实现调试数组安全交替使用。
-
验证效果:
- 修复后性能分析界面正常显示;
- 说明问题确实与调试数组错误清空有关;
- 原以为不是这个原因,但实际验证表明它就是核心问题之一。
-
后续需要注意:
- 检查是否存在未正确关闭的线程块;
- 特别是在代码重载或帧切换时;
- 确保调试信息结构完整,避免挂起状态。
这一过程体现了系统调试中隐藏状态的复杂性,一点小的逻辑失误可能导致整个调试系统失效。目前通过定位并修正清零逻辑,已经显著改善了系统行为。后续仍需持续关注线程块生命周期的正确管理。
调试器中:进入 BeginBlock
并检查 DebugState
我们当前正处于问题触发的状态,因此我们深入查看了调试信息跟踪相关的代码逻辑,试图验证并找出具体原因。
首先检查了线程的调试状态。在调试系统中,每个线程都有对应的调试信息,其中包括一个“当前打开的代码块”字段。我们查看了当前活跃线程的调试状态,发现其打开的块是“调试整理块”(debug collation block),而这个块的父节点居然指向它自己,这是明显不正确的。
这种情况通常只会出现在递归调用中,但这里并不是递归函数,意味着这个块并没有被正确关闭。这种异常恰好与我们观察到的问题相符 —— 性能分析信息缺失,很可能是因为存在未正确结束的调试块。
我们定位到这段问题代码是在某个位置。问题块出现在执行 begin_block()
后,本应执行 end_block()
进行关闭,但在某种情况下,end_block()
并没有被调用。具体来说:
- 这是一个包裹在游戏代码热重载过程中的块;
- 在
begin_block()
调用之后,游戏代码被卸载或刷新; - 而在新代码重新加载之前,
end_block()
还没来得及执行; - 由于重载发生,调试系统的数据结构也会重置;
- 导致这个“悬挂”的块始终保持打开状态,不再被关闭。
这就解释了为什么性能分析树无法正确显示根节点或其他信息 —— 根本没有形成完整的块结构。
进一步思考了热重载对调试系统的影响,发现问题可能出在块匹配机制本身。系统匹配 begin_block()
和 end_block()
时,可能是依赖某些标识符,例如一个 GUID id
,而这个标识符在代码重载过程中可能会变化。
如果在热重载后标识符不一致,调试系统就无法正确匹配并关闭原本打开的块,从而留下“悬挂块”。这种挂起状态会导致后续帧无法以正确方式嵌套新的分析块。
不过也有疑问,即热重载是否真的会导致这些关键字段(如 GUID)变化?一度怀疑 GUID 是问题根源,但后来又认为也许 GUID 没有实际变化,可能还需进一步验证。
详细总结:
-
问题现象:
- 某些调试块未被正常关闭;
- 导致调试树结构损坏,性能分析数据无法正确显示;
- 调试状态中存在自指(自身为父)块,明显不合法。
-
具体原因:
- 游戏代码重载过程中,
begin_block()
已执行但end_block()
尚未执行; - 重载触发后调试系统重置,原有块信息丢失;
- 残留块无法关闭,形成“挂起状态”;
- 进一步导致调试树逻辑异常。
- 游戏代码重载过程中,
-
潜在根本原因:
- 块匹配机制依赖某些标识(如 GUID id);
- 热重载可能导致这些标识不一致,匹配失败;
- 导致新旧块之间逻辑断裂。
-
可能的解决方向:
- 在执行重载之前,强制关闭所有尚未结束的调试块;
- 或者确保重载不会清空调试块的状态结构;
- 或对调试块增加更可靠的唯一识别机制,避免误匹配。
当前判断比较明确:确实是因为调试块在代码重载过程中未能正常结束,造成挂起,从而引发分析数据异常。这是调试系统与热重载机制交互中的一个典型边界问题,后续应在热重载前后处理逻辑中加入强制性调试块闭合保障。
检查事件的 GUID 并进入 EventsMatch
函数
我们正在深入检查调试系统中“事件匹配”的机制,目的是确认在代码重载前后,是否由于事件匹配逻辑的问题,导致调试块无法正确闭合,从而造成性能分析数据异常。
首先,我们观察了两个调试事件:
- 一个事件来自
game.cpp
,位置在 2412:34; - 另一个事件来自
debug_interface
,也是在相同的位置 2412:34; - 两者的
source location
显然是相同的。
从表面看,这两个事件应该可以匹配。如果事件匹配逻辑正确,这样的定位信息应该能对应成功。
接着,我们进一步检查事件的匹配机制到底是如何工作的。
我们的目标是搞清楚在判断两个调试事件是否匹配时,系统依据了哪些字段或逻辑条件。也就是说:
- 系统是否只基于 文件名 + 行号 + 列号 来判断两个事件是否属于同一个调试块?
- 或者还依赖其他标识,如
grid id
、函数地址、某种运行时 ID? - 如果热重载过程改变了这些关键标识(如内存地址、指针等),就会导致两个逻辑上相同的事件被识别为不同,进而无法匹配
begin_block
和end_block
。
这一点至关重要,因为:
- 如果事件匹配依赖了会在重载后改变的字段(比如内存地址、函数指针等),那么
end_block
在新代码加载后执行时就无法正确关闭此前的begin_block
; - 导致这个“悬挂的调试块”一直保留在线程状态中,成为性能分析树的异常根源;
- 这将使得每一帧都误认为存在一个未关闭的根块,进一步破坏整棵分析树的结构。
因此,我们下一步的重点是:
- 明确匹配逻辑到底用到了哪些字段;
- 判断这些字段是否在热重载前后会发生改变;
- 如果会改变,就需要重新设计匹配逻辑或在重载前主动清理这些未结束块。
总结如下:
当前调试步骤总结(中文):
-
观察:
- 两个事件来源于同一位置
game.cpp
的 2412:34; - 看起来应当可以正确匹配。
- 两个事件来源于同一位置
-
推测问题:
- 实际可能没有匹配成功;
- 原因可能是匹配逻辑依赖了某些会被热重载重置的字段。
-
正在调查:
- 调试系统内部是如何判断两个事件是否“属于同一个块”的;
- 是基于源代码位置,还是其他更易变的运行时信息。
-
关键问题:
- 如果匹配逻辑中包含了在热重载中会改变的内容(如指针或 grid 编号),将造成调试块无法闭合,产生“悬挂块”;
- 从而破坏性能分析的完整性。
接下来,我们需要具体定位匹配逻辑的代码,并确认其字段来源与行为。如果需要,我也可以帮助分析匹配函数内部逻辑,只需贴出对应的判断代码。
在 game_debug.cpp
中临时添加断言来检查 EventsMatch
的行为
我们在调试过程中深入检查了性能分析系统中调试块(debug block)的匹配机制,重点是分析事件记录过程是否存在逻辑错误,尤其是在处理打开块(opening event)时。以下是详细的中文总结:
问题背景分析:
我们希望观察调试块在热重载过程中的行为是否正确,尤其是“打开事件(opening event)”是否被正确记录并能与对应的“结束事件(end block)”匹配。为此临时把原本的条件判断替换为一个断言(assert),以确保当发生不符合预期的情况时可以立刻被发现。
一旦程序命中断言,我们观察到以下异常现象:
- 打开的事件(opening event)的
grid
竟然指向了一个结束块(end block); - 这是不合理的,因为打开事件的
grid
应该对应的是一个“打开中的块”,而不是某种已结束的结构; - 同时,事件记录中的
record_span
起点被标注为了帧序列开头,但这并不准确。
关键调查步骤:
-
检查事件结构:
- 我们打印并比较了当前打开事件(opening event)和现有事件(event)的结构,发现它们之间的
grid
指针设置似乎不对。
- 我们打印并比较了当前打开事件(opening event)和现有事件(event)的结构,发现它们之间的
-
查看调试元素内容:
opening event
对应的调试元素是LoadAssetWorkDirectly
,这是合理的,表示这是在资源加载任务中的某个工作块。
-
定位设置流程:
- 我们追踪了设置
debug block
的过程,发现事件是在调用AllocateDebugBlock
时被分配和设置的; - 但是进一步查看后发现,分配过程中将一个事件的指针赋值给了调试块结构中的某个字段,但这个指针是临时变量的地址,并没有存储到安全的内存区域中;
- 换句话说,我们把一个将要失效的指针当作“持久引用”存储进去了。
- 我们追踪了设置
根本问题定位:
我们把一个临时事件结构的地址作为指针保存进了 debug block
,而没有将其正确拷贝到持久内存或事件数组中。因为:
- 这个事件结构指针是分配时局部变量或临时缓存的地址;
- 它在事件生命周期结束后被释放或覆盖;
- 导致后续访问这个指针时会得到无效的数据;
- 这也解释了为什么
opening event
会指向一个不合理的结束块,甚至自身变得无效。
总结结论(中文):
- 当前断言命中说明事件匹配机制中存在逻辑问题,特别是
opening event
的来源不稳定; - 问题根源是错误地将临时指针作为持久引用存储,违反了事件系统的内存管理原则;
- 应当在设置调试块时,将事件从临时指针拷贝到稳定的事件表或结构中,避免使用生命周期不确定的引用;
- 这个 bug 很可能就是调试系统中无法正确关闭块、产生悬挂结构、性能分析树异常的直接原因;
- 接下来应修改调试块的初始化逻辑,确保引用的事件数据稳定可靠。
如需我进一步写出建议的修复代码或继续分析事件分配函数的具体实现逻辑,请继续说明,我可以立即协助。
注意到事件是临时的,因此现在改为使用 StoredEvent
我们在对调试事件处理流程进行深入分析时,识别出当前事件管理中存在一项关键的生命周期管理问题。以下是详细的中文总结:
分析背景:
我们正在处理的事件是短暂的(transitory),也就是说:
- 事件在被处理完之后就不再有效,不能再被依赖;
- 当前采用的是增量解析方式(incremental parsing),因此事件处理后会被清空或覆盖;
- 一旦缓冲区被刷新,事件对象就会失效。
关键问题识别:
由于事件失效,不能直接在调试块中保留对事件的引用。然而在现有设计中,调试块结构可能保存了对这些临时事件的引用,从而导致:
- 数据引用悬空;
- 匹配逻辑混乱;
- 分析结构可能遭到破坏。
正确做法明确:
我们可以放心引用的是复制并保存下来的事件(stored event),因为它的生命周期足够长,至少可以跨多个帧使用。相比之下:
- 当前事件是短暂的;
- 复制后的事件是持久的(或更长久的);
- 所以,我们应该只在调试块中引用持久化的事件副本,而不是临时数据。
更进一步的优化思路:
考虑到即便是**存储事件(stored events)**也可能在之后被释放(因为帧数据数量受限),我们提出了更保险的改进方案:
- 直接将需要的调试信息复制到调试块本身内部;
- 不再依赖外部事件对象的生命周期;
- 这样就能确保调试块内容在任何时候都稳定、独立、可靠。
例如:
- 将事件中的
file
,line
,frame index
,event ID
等重要信息,直接复制进调试块; - 避免保留原始事件的指针或引用。
当前实现检查:
我们回头确认了当前代码的行为,观察如下:
- 正确地调用了
StoreEvent
函数; - 成功地将
event
,element
,frame index
,first open GUID
等复制进入结构中; - 但可能尚未完全脱离对临时数据的依赖。
总结结论(中文):
- 当前事件处理方式为增量解析,事件本身生命周期极短;
- 调试块不能依赖这些短暂事件中的任何指针或引用;
- 应当优先使用复制后的“持久事件副本”;
- 最理想做法是直接将所需字段拷贝进调试块本体,保证结构独立性;
- 此改动能根本解决事件失效导致的调试数据损坏问题;
- 目前代码虽然部分逻辑正确,但仍存在可优化之处,值得进一步修正。
调试器中检查 Events
与 StoredEvent
我们现在继续检查这些调试事件,虽然其他部分可能仍然存在问题,但有一点已经可以确认是明显错误的。
当前观察重点:
我们查看了一个调试事件,属于打开状态(open event),在调试显示中是绿色的。然而发现了一个异常值:
- 该事件的 GUID 为 0(Good 为 0)
这是一个明显不对的现象:
- 在正常情况下,每个调试事件应当有唯一的标识符(GUID),用于匹配开始与结束事件、建立事件层级关系等;
- GUID 为 0 意味着它没有正确初始化,或者数据在创建或赋值时被忽略或清空了;
- 一个事件如果是“打开”的,意味着它是某种逻辑块的起始点,更应该拥有合法的标识符;
- 出现 GUID 为 0 的现象说明该事件不是一个有效的调试结构的一部分,或者已经破坏了调试系统的基本一致性。
进一步的思路:
目前这个问题可能来自:
- 事件创建时未正确生成或赋值 GUID;
- 事件结构被拷贝或传递过程中发生值丢失;
- 事件指针引用了临时或无效的对象,例如之前提到的“临时事件生命周期过短”的问题;
- 调试系统中用于设置 GUID 的逻辑分支未命中,即创建路径异常。
下一步建议:
为了定位和解决问题,我们应当:
- 回到事件分配和初始化的代码路径,检查 GUID 是如何生成和赋值的;
- 对调试块中的所有开放事件做一致性检查,确保所有“打开块”都有有效 GUID;
- 在调试视图中增加断言,强制所有开放事件必须拥有非零 GUID;
- 若发现使用的是临时事件副本,考虑使用持久版本或将 GUID 显式复制进去。
总结要点(中文):
- 当前看到的调试事件是一个“打开状态”事件;
- 它的 GUID 为 0,这是不正确的,说明事件结构不完整或创建逻辑出错;
- GUID 是建立事件关联关系的核心字段,必须保证存在且正确;
- 问题可能来自事件生命周期混乱、赋值遗漏或使用了临时无效数据;
- 需要回溯事件生成与赋值流程,并加入一致性断言以避免此类错误再次发生。
修改 game_debug.h
:让 open_debug_block
不再保存整个 debug_event
,而只保存所需数据
当前我们识别出一个关键性设计错误,并对调试系统进行了彻底的结构调整,旨在修复调试块匹配逻辑中的一系列隐患与冗余处理。
问题核心
我们原先在调试块(debug block)中直接存储了“打开事件”的引用(open event),但这是错误的。原因如下:
- “打开事件”只是一个临时对象,在事件解析结束后即会被释放;
- 调试系统中的块结构不应该引用一个生命周期不稳定的对象;
- 如果之后需要访问事件中关键数据,如
clock
或thread_id
,就会发生悬空引用或数据不一致。
正确做法与修改内容
我们已经对系统做了以下调整:
-
不再存储事件本体指针:
- 原来的设计中,open debug block 存储了一个指向 open event 的引用;
- 现在直接将我们需要的数据从事件中提取出来并存储,如
begin_clock
。
-
简化匹配逻辑:
- 原先有一个复杂的
events_match
判断逻辑,尝试通过比较事件结构判断配对; - 实际上,我们只需要比较线程 ID 是否一致;
- 现在将其简化为一个断言(assert):
thread_id
必须匹配。
- 原先有一个复杂的
-
移除冗余结构:
- 清理了用于事件匹配的一些无用字段和函数;
- 对
open_event.clock
等成员引用全部取消,替换为matching_block.begin_clock
等更稳定的字段; - 去掉了一些重复的
events_match
判定逻辑。
-
在 End Block 阶段立即处理需要的数据:
- 事件的任何重要信息都必须在
end_block
执行时立即提取和存储; - 不允许后续再依赖
open_event
,因为它已无效。
- 事件的任何重要信息都必须在
整体目标与收益
- 保证调试系统对调试块的引用都是长期有效的;
- 消除因事件生命周期失效导致的崩溃或未定义行为;
- 提高代码清晰度与可维护性;
- 简化事件配对逻辑,避免误判、遗漏或跨线程匹配。
总结重点(中文)
- 原来的做法错误地在调试块中存储了临时事件指针;
- 正确做法是提取所需字段(如
clock
和thread_id
)直接存储; - 配对逻辑现在只依赖线程 ID,已改为断言;
- 所有引用事件数据的场合都改为使用
matching_block
中稳定的数据; events_match
逻辑和相关引用字段已移除,调试系统更加简洁安全。
运行游戏,发现分析器数据依然会丢失
目前我们已经进入一个更稳定的状态,之前一些不正确的代码逻辑已经被清理掉,系统整体运行状态显著改善。以下是当前工作的总结:
当前状态分析
- 系统现在看起来运行正常,核心调试数据结构的管理更加清晰;
- 尝试切换回“世界模式”(world mode)后,仍然丢失了性能剖析数据(profile);
- 尽管如此,这次已经排除了之前存在的冗余逻辑与无效引用问题;
- 系统内部的一些“垃圾逻辑”已被移除,那些代码原本就是不正确的,现在清理干净之后,后续调试将更加高效可靠。
已完成的清理工作价值
- 修复了调试事件生命周期管理错误;
- 避免了使用临时对象指针造成的数据失效与潜在崩溃;
- 清除不必要的匹配函数与冗余逻辑判断;
- 优化了调试块的数据结构,提升可维护性与稳定性。
接下来的计划
- 当前仍存在剖析数据在切换模式后丢失的问题;
- 这个问题将留待下一轮排查,预计可以较快解决;
- 重点将放在“world mode”切换逻辑与 profile 数据同步机制上;
- 当前清理工作为后续排查打下了干净的基础,避免了被旧逻辑干扰。
总结重点(中文)
- 系统运行状态稳定性明显提升;
- 原有调试逻辑中的不当引用已彻底清理;
- 剖析数据丢失的问题仍未解决,但问题范围已大大缩小;
- 下一步将重点排查模式切换与 profile 数据之间的关系;
- 当前阶段的清理工作极为必要,提升了系统整体正确性与可调试性。
如需继续整理明天的调试排查思路,也可以为此提前准备一个定位路径。
Q&A
是否可以将调试 UI 拆分为独立程序,通过管道或套接字与游戏通信?这样调试数据不会占用游戏资源,游戏崩溃时依然能检查帧数据或保存
这是一个非常合理的想法,我们完全可以将调试 GUI 独立出来,变成一个与游戏程序通过管道(pipes)或套接字(sockets)通信的独立进程。这种架构设计可以带来多方面的优势:
🌐 架构分离的优点
-
避免调试开销影响游戏性能
游戏仅负责采集和发送调试数据,而不再进行调试可视化渲染,减少性能开销,尤其是在性能敏感的场景下尤为重要。 -
提升稳定性
如果游戏崩溃,调试界面依然可以运行,便于分析崩溃前的最后几帧状态,甚至可以将调试帧独立保存到磁盘中进行离线分析。 -
实现远程调试
架构分离后,可以在另一台机器上运行调试 GUI,这对于主机平台(如 PlayStation、Xbox 等)开发尤其重要。例如在主机上运行游戏,而在 PC 上查看调试信息,是常见需求。 -
便于商业化与工具链拓展
如果将来希望将调试系统打包成通用工具,分离式架构是专业工具的基础,有利于模块化、标准化。
当前实现架构的适配性
- 当前系统设计采用写入共享缓冲区的方式,已经具备一定的解耦结构;
- 将写入行为替换为通过网络或 IPC(进程间通信)发送数据不会造成结构上的剧烈变更;
- 唯一需要额外注意的部分是“事件相关性处理”(correlation 部分),需要小心同步和顺序的问题;
- 只要将这部分逻辑提取到独立程序中,通信协议定义清晰,即可实现完整功能迁移。
实现展望
可以考虑:
- 实现一个监听套接字的调试服务端程序,接收来自游戏的调试数据;
- 游戏只负责发包,并尽可能不参与调试数据的解析或显示;
- 使用自定义协议(或轻量级序列化格式)传输数据;
- 支持缓存、断线重连、调试帧回放等功能,增强调试效率与健壮性。
总结
将调试界面独立为一个与游戏通信的程序,确实是一个非常有价值且专业的架构优化方向,不但提升稳定性、性能,也为后续远程调试、跨平台开发、甚至商业化工具化铺平了道路。目前架构已经有基础,如果需要扩展到这一方向,实现成本也较低。
你在 GAME_INTERNAL
宏外部引用了 GlobalDebugTable
,而它是在 UpdateAndRender
中初始化的
在这个阶段,担心将全局调试表暴露到外部并没有使用适当的内部保护机制,可能会导致很多潜在的问题。全局调试表如果没有适当的控制和保护,就有可能被外部意外修改或误用,导致调试信息的不一致或者错误的调试结果。
主要问题:
-
外部暴露调试数据
如果没有防护机制,调试表可能会被外部代码修改,导致程序的调试过程受到干扰。 -
潜在的不一致性
如果调试表被不适当地修改,可能会导致不同模块之间的数据不一致,进而影响调试结果的准确性。
解决策略:
-
启用内部保护机制
必须确保调试表只在受控的环境下访问和修改。可以通过引入“内部守护程序”来防止外部直接修改调试数据。 -
清理冗余数据
等到调试功能关闭或不再使用时,才对调试表进行彻底清理,避免冗余数据的累积,从而影响程序性能或导致调试信息混乱。
通过这种方法,可以确保调试数据的安全性和准确性,并防止潜在的问题影响调试过程。
你觉得模块化编辑器(modular editing)目前体验如何?
目前,处理对象和编辑的速度还是比较慢的。原因是还需要做一些工作来简化清理过程,尤其是避免频繁切换不同的模式。只要完成这些工作,预计会变得更快,甚至可能比之前还要高效。
关于全局调试表的讨论,问题出在初始化时,它在更新和渲染函数中被使用,而这个过程没有适当地被内部保护机制限制。具体来说,调试表的初始化和使用没有进行适当的保护,导致它在外部可能会被修改,从而影响程序的稳定性和数据的完整性。
为什么不用 DebugBreak
替代 *(int*)0 = 0
,这样可以在需要时跳过断点同时也能触发调试器
我们讨论了一种名为“cubicle”的工具,它使用了一种深度插入(deep insertion)机制,但我们选择打破这种机制,改用“step over”方式。这种方式允许我们在表面上操作,而无需插入。原因在于,我们考虑到了Zibo桥梁和平台的需求。相比之下,大多数西方平台需要不同的处理方式,因为这些平台通常只在Windows系统上运行。对于Unix系统,虽然存在一个等效的工具,但其功能和操作方式与Windows版本有所不同。
什么是模块化编辑?
这是关于“模态编辑”的讨论。模态编辑是指在不同的编辑模式下,键盘输入的行为会有所不同。在一种模式下,比如绿色模式,键入的内容是普通的文本输入,而在另一种模式下,如红色模式,所有的输入实际上都会被视为命令。在这种模式下,不需要按住控制键(Control)或其他任何键来触发命令,所有的输入都是基于当前模式的行为。
你写自己的渲染器时是用左手坐标系还是右手坐标系?为什么?
在编写自己的渲染代码时,通常使用右手坐标系。因为右手坐标系是数学领域中最常用的标准,所以没有特别的理由去选择左手坐标系。
你禁用树渲染后注意到调整分析器窗口大小的白点在浅色背景上很难看清,就像加阴影前的文字一样
注意到当禁用树的渲染时,白点在浅色背景上变得难以看清,类似于文本之前难以阅读的情况,直到添加了阴影才有所改善。确实,如果进入美化阶段,应该做一些调整。首先,应该让这个点一开始就变得更大。另外,还要确保它的大小合适。现在它还没有做出这种调整。我们并没有特别处理这个点的绘制方式,因此需要明确一个问题:应该同时展示多少个配置文件?我觉得我们可能希望显示更多的配置文件。
你觉得边讲解边编码对你理解或编程本身有帮助吗?
在编程过程中,注释主要是为了记录一些以后可能会忘记的东西。通常,注释并不是帮助思考的工具,而是为了确保不会遗忘某些细节,特别是在处理复杂代码或长时间不再涉及某些部分时。
题外话:我注意到 #pragma pack
会改变结构体对齐方式,有办法保留原始对齐方式吗?
#pragma pack
的作用是改变结构体的对齐方式,它的目的是控制结构体成员的内存对齐,以节省内存空间。默认情况下,结构体成员的对齐方式通常会根据最大类型的对齐要求来决定。使用 #pragma pack
可以调整这个对齐方式,从而减少内存的浪费。然而,一旦使用了 #pragma pack
,结构体的内存布局会被压缩,这也可能导致性能问题,因为不同平台的 CPU 在访问不对齐的内存时可能会变慢。
如果想要保持原有的对齐方式,通常不使用 #pragma pack
,而是依照默认的对齐规则。如果已经应用了 #pragma pack
,想恢复原来的对齐方式,可以通过 #pragma pack(pop)
来恢复到之前的对齐设置。
是否能保存 DebugUI 的布局,让撕开的窗口在下次打开时仍保持上次状态?
在讨论游戏调试时,考虑到是否需要保存调试视图的布局以及当前打开的窗口,尽管这样的功能是有用的,但在当前的情况下,可能并不会特别去实现。相反,可能会选择在代码中实现这种功能,通过编程的方式来创建额外的视图。这样可以灵活控制调试信息的显示,而不需要每次都保存和恢复调试状态。
如果这是一个商业系统,尤其是当调试系统作为独立的组件进行发布时,保存和恢复调试视图的状态就变得非常重要。在这种情况下,肯定会加入这种功能,以确保开发人员和用户能够方便地恢复到之前的调试环境。因此,是否实现这个功能取决于当前的项目需求和目标。