游戏引擎学习第152天

news2025/3/13 20:37:15

仓库:https://gitee.com/mrxiao_com/2d_game_3

回顾昨天的内容

这个节目展示了我们如何从零开始制作一款完整的游戏。我们不使用任何游戏引擎或库,而是从头开始创建一款游戏,整个开发过程都会呈现给大家。你将能够看到每一行代码的编写,了解游戏是如何从引擎到游戏玩法再到其他各个方面一一实现的。

目前,我们正在进行资产系统的改进工作。之前我们有一些初步的代码,能够从磁盘加载资产,比如从BMP和WAV文件加载资源。但是现在,我们对资产的管理做出了更为集中的调整。我们现在已经有了一种方法来构建自己的资产包文件,这些文件可以轻松地传输、存储、交换或进行其他处理。

这些资产包文件包含了运行游戏所需的所有数据,事实上,甚至可以有多个资产包,它们会组合在一起,包含不同的资源。每个资产包文件不仅包含所有的图像数据(如位图数据),还包括音频数据(如WAV文件),以及所有其他必需的元数据,用来描述这些资源的功能和用途。
这样,我们通过这种方式将所有的资源打包成单一的文件或多个文件,并能够方便地管理和传输它们,从而大大简化了游戏资源的管理和加载过程。

今天的计划

我们已经完成了大部分工作,接下来的任务是将资产流系统构建在现有的基础上,使得它不再直接访问BMP和WAV文件,而是通过我们已经创建的资产包文件来加载资源。我们之前已经完成了对系统的初步移植,但当时是一次性加载整个文件,现在的目标是通过异步方式从操作系统中获取数据,而不是在启动时就加载所有的资源文件。现在我们正在清理平台和AI部分的代码,以便能够更高效地加载数据。

今天的任务就是完成这一部分的工作。

接下来,我们回顾一下我们上次的进展。上次我们基本上已经完成了大部分的移植工作,剩下的只是进行最后的调整和完善。

目前资源的元数据已经被加载,因此 LoadBitmapLoadSound 的工作基本上就只剩下获取数据了

之前,加载不同类型的资源(如位图和音频文件)时,系统需要分别处理,因为我们需要从这些文件中获取数据,比如位图的尺寸或音频文件的样本数。这个区分是因为必须知道具体的资源数据格式。然而,随着我们将资源整合到资产文件中,这些细节已经在更高层级处理好了,所有必要的元数据已经被预先加载,因此现在加载资源时,我们已经完全知道数据的布局,不再需要进行额外的处理。

因此,我们现在的做法是简化这个过程,统一通过一段代码来加载资源,无论是位图数据还是音频数据,都可以通过相同的异步加载方式来处理。我们只需要提供数据的存储位置和内存地址,系统就能根据这些信息将数据加载到内存中,其他的就不需要再做了。

已经完成的部分是位图的加载,我们已经将之前的位图加载代码简化为统一的 LoadAssetWork 函数,这样就不再需要专门为位图加载进行额外的处理。接下来还需要完成音频资源的部分,进一步清理并简化加载逻辑,确保所有资源都能通过统一的方式加载。

开始移除 LoadSound 函数

接下来,目标是将音频的加载过程与位图加载过程统一,使得音频的加载方式与位图相同,从而简化和统一资源的加载流程。之前,音频的加载使用了一个 load_bitmap_work 的函数,但我们想要去掉这个函数,并改为直接使用类似位图加载的 load asset 函数,简化整个过程。

load_asset_work 中,之前主要做的事情是设置音频的样本数和通道数等,这些信息是从音频的资产数据(hha asset)中获取的。虽然现在的目标是避免在每次加载时都需要设置这些信息,但目前看来,还是需要在某些情况下从资产数据中复制这些信息到音频资源中。虽然有些人可能会觉得不需要复制这些信息,而是可以直接使用它们,但目前复制这些信息似乎并不会带来太大问题,所以暂时保留这个过程。

接下来,音频加载过程需要做的事与位图的加载类似。具体来说,load_sound_work 需要改成 load_asset_work,使得音频的加载也走统一的加载流程。与位图加载相同,在加载音频资源时,文件句柄初始化为零,因为文件处理部分还没有完成,之后会实现文件操作相关的代码。

此外,音频的内存目标地址会指向音频样本数据,并且可以在需要时引入一个专门的音频内存结构来存储这些数据。最终,音频资源的状态也会标记为已加载,就像位图一样。

最后,音频资源的槽位需要和正确的音频数据指针关联起来,确保加载后能够正确地使用音频数据。此外,GetSound 的函数已经做了修改,确保它在访问音频资源时能够检查是否已经加载,如果已经加载,则直接返回资源。

总体来说,目的是将音频的加载方式与位图的加载方式统一,简化代码并提高资源加载的效率。
在这里插入图片描述

(插曲)确保在检查资源是否加载和访问资源之间强制保证读取的顺序

在思考当前代码时,意识到需要稍微调整一下逻辑。最初使用了三元运算符来处理条件判断,这样做虽然简洁,但也存在潜在的问题。问题在于,编译器可能会将某些读取操作重新排序,这可能会导致数据读取的顺序错误。为了解决这个问题,需要确保在读取数据之前,之前的读取操作已经完成,以避免出现不正确的数据状态。

具体来说,要确保在进行某个读取操作之前,前面的读取操作已经完成。这是因为如果读取顺序不正确,可能会导致数据不一致或错误的结果。虽然目前在像Intel芯片上,这种问题的可能性较低,但考虑到将来可能会支持不同的硬件平台,比如树莓派或Android设备,这种顺序问题仍然需要特别注意,尤其是在ARM架构的设备上。

为了保证代码的正确性和健壮性,决定在读取之前加上检查,确保前面的读取操作已经完成。虽然这样做可能不会带来明显的性能差异,因为编译器可以优化掉不必要的操作,但这种预防措施可以确保在不同平台和不同架构下都能够正常运行。因此,还是决定保持这个更安全的做法。
在这里插入图片描述

(插曲)实现 CompletePreviousReadsBeforeFutureReads(确保之前的读取完成才执行新的读取)

现在,我将检查一下是否已经实现了必要的读取屏障,因为目前似乎还没有实现。基本上,我们需要确保在进行未来读取之前,所有之前的读取操作都已完成。这样做实际上相当于在代码中插入一个读取屏障。然而,我并不确定在特定平台(如LLVM)中,如何实现这个读取屏障。

因此,我想做的就是插入一个内存屏障,但仍不确定LLVM是否有专门针对读取的屏障功能。通常,内存屏障会确保某些操作的顺序,防止编译器在处理时进行不正确的重排,尤其是在多核处理器或复杂的架构中。

我决定暂时插入一个假设性的读取屏障,并标注出对于某些平台(如LLVM),是否支持特定的读取屏障功能。这将取决于编译器的实现。如果有更熟悉LLVM的开发者,他们可以告诉我们是否已添加这种可以专门指定读取屏障而非写入屏障的功能。

虽然我不确定是否需要这个屏障,但我觉得为了确保代码的安全性,最好还是写出明确的意图,添加适当的屏障。这对于确保编译器不会进行不必要的重排非常重要,虽然目前看起来这种重排的可能性较低,但是预防总是更稳妥的。
在这里插入图片描述

回到移除 LoadSound 函数的工作

我们正在处理音频加载部分,并希望使其与图像加载的处理方式一致。首先,我们要做的是提取音频信息,并对其进行处理,类似于处理位图数据的方式。为了保证加载过程的结构一致性,我们决定去除不再需要的旧代码,并将新代码结构化。

在此过程中,音频加载部分需要进行一系列初始化操作。首先,要为音频数据分配内存空间。为了做到这一点,我们需要计算出加载音频所需的内存大小。这个计算方式与位图加载中的方式相似:我们需要用通道数乘以每个通道的样本数,再乘以每个样本的大小,得到音频数据的总内存需求。

接下来,我们需要为音频数据分配内存。就像加载位图时一样,我们会从内存池中申请合适大小的内存空间来存储音频数据。

一旦内存分配完成,我们就开始将音频数据加载到内存中,确保音频的各个通道在加载后正确地指向相应的内存位置。在音频数据加载时,我们会遍历每个通道,并确保每个通道的样本指针都正确地指向加载的数据。

此外,我们还需要做一些初始化工作。音频的“样本计数”和“通道计数”等参数需要从音频数据中提取出来,并且确保这些参数在加载后能够正确地反映音频的结构和格式。加载完成后,我们会对音频数据进行后续处理,确保它能够在游戏中正确播放。

总的来说,音频加载的过程几乎与图像加载过程相同,主要的区别在于数据的结构不同。通过这种方式,我们确保了代码的一致性和模块化,使得后续的维护和修改变得更加简便高效。
在这里插入图片描述

将资源数据复制到内存中的合适位置

我们现在正在处理音频数据的加载部分,并且我们意识到可以直接将音频数据从资产文件中复制到内存中的指定位置,而不需要通过复杂的分配或移动操作来实现这一过程。这将使我们的音频加载流程更加简洁高效,因为数据已经在资产文件中按预定的格式存储,我们只需要简单地将其复制到合适的内存地址即可。

在这一过程中,我们发现我们之前已经创建了一个用于清零内存的函数(类似 memzerozero_memory 之类的功能),但我们似乎并没有一个专门的内存复制函数(类似 memcpy 的功能)。这意味着我们当前的工具链中并没有一个标准化的方式来将数据从一块内存区域复制到另一块内存区域。

因此,如果我们想要将音频数据直接复制到内存中,我们需要创建一个新的内存复制函数。这将类似于标准库中的 memcpy 函数,其主要功能是将一块内存中的内容复制到另一块内存中。其基本逻辑如下:

  1. 确定源地址和目标地址:我们需要确定数据在资产文件中的起始地址(源地址),以及数据在内存中被分配的目标地址。
  2. 计算数据大小:我们已经提前计算过音频数据的总内存大小,因此我们知道需要复制的字节数。
  3. 进行内存拷贝:将源地址中的数据逐字节复制到目标地址。
  4. 确保数据正确对齐:虽然大多数音频数据不需要严格的内存对齐,但在某些架构(如 ARM 设备)中,确保内存地址对齐可能会提高访问效率,因此如果我们未来需要支持这些设备,考虑内存对齐是必要的。

尽管目前我们没有这个复制函数,但我们完全可以基于我们之前的 memzero 函数快速创建一个 memcopy 函数。这将允许我们直接将音频数据块复制到内存中,而不需要通过复杂的中间转换或处理。

此外,将数据直接复制到内存中的优势还包括:

  • 避免冗余操作:不需要在加载音频数据时进行不必要的处理,减少了 CPU 的计算负担。
  • 保持数据格式一致:直接复制确保了数据格式不会在加载过程中发生意外变化。
  • 加快加载速度:数据复制比解析或重构更快,因此这种方式可以显著减少音频加载时间。

因此,下一步我们要做的就是编写一个类似 memcopy 的内存复制函数,然后将其应用到音频加载过程中,使音频数据直接从资产文件复制到内存中,最终提高加载效率和代码简洁性。
在这里插入图片描述

编写通用的内存拷贝函数

我们现在决定编写一个简单的内存复制函数,用于将数据从一块内存区域复制到另一块内存区域,类似于标准库中的 memcpy 功能。这个函数主要是为了方便我们在加载音频数据时,将音频样本数据直接从资产文件中的内存区域复制到我们分配的音频内存区域中,从而实现数据的直接加载,避免额外的处理步骤。

我们在实现这个函数之前,已经意识到这个函数并不会被广泛使用,因为在实际开发中,如果我们真的关心内存复制的性能,我们通常会基于更详细的情况对内存复制进行优化。比如:

  • 如果我们清楚数据的内存布局,我们可能会进行内存对齐优化,比如按照 128 位或更大的数据块进行复制;
  • 如果我们知道目标平台的特性,比如支持 SIMD 指令或特定缓存优化,我们可能会采用更高效的内存复制方式;
  • 如果数据具有固定格式或结构,比如音频数据是按样本排布的,我们可能直接通过批量传输或者 DMA 传输等方式进行数据移动,而不是单纯的字节复制。

但由于我们目前的需求非常简单,仅仅是将音频数据从资产文件加载到内存中,因此我们决定编写一个最简单、最基础的内存复制函数,即:

  • 接收一个内存大小参数,表示需要复制的字节数;
  • 接收一个源地址,表示源内存块的起始地址;
  • 接收一个目标地址,表示目标内存块的起始地址;
  • 循环遍历指定的字节数,将源地址的数据逐字节复制到目标地址。

内存复制的具体实现

我们首先定义一个 memory_index,用于跟踪需要复制的总字节数。然后我们使用两个 uint8_t 类型的指针,将源地址和目标地址都转换为字节指针(byte pointer),这样我们就可以逐字节地进行内存复制,而不需要考虑数据结构或类型对齐等问题。

在循环中:

  • 每次将源地址中的一个字节赋值到目标地址中;
  • 然后将源地址和目标地址的指针都递增;
  • 重复这个过程,直到所有数据都被复制完毕。

为什么我们不做优化

我们也明确指出,这个函数是一个极为基础的内存复制函数,因此它不会进行任何形式的性能优化:

  • 没有内存对齐优化:我们没有检查内存地址是否对齐,因此它不会利用 CPU 提供的对齐访问优势;
  • 没有批量复制:我们没有使用 memmove 或者 SIMD 指令进行块复制,因此性能较低;
  • 没有并行优化:我们没有考虑使用多线程或者 DMA 控制器进行数据传输,因此效率较低。

但我们也认为这是可接受的,因为:

  1. 当前我们只是简单地加载音频数据,不涉及任何性能瓶颈;
  2. 数据复制的时间几乎不会成为瓶颈,加载过程的核心瓶颈通常是磁盘 IO 或文件系统操作;
  3. 如果未来需要针对某些平台进行优化,我们完全可以替换掉这个基础的内存复制函数,直接编写更高效的版本。

为什么要转换指针

我们将源地址和目标地址转换成 uint8_t* 类型的字节指针,这是因为:

  • 在内存复制过程中,我们关注的是逐字节复制,而不是以特定数据类型为单位进行复制;
  • 如果直接使用 void* 类型,我们无法直接访问内存;
  • 如果使用 uint8_t*,我们就能保证无论数据类型是什么,都可以正确复制每一个字节的数据。

最终的代码结构

最终的内存复制函数结构非常简单,大致如下:

inline void Copy(memory_index Size, void *SourceInit, void *DestInit) {
    uint8 *Source = (uint8 *)SourceInit;
    uint8 *Dest = (uint8 *)DestInit;
    while (Size--) {
        *Dest++ = *Source++;
    }
}

这个函数的核心逻辑就是将两个指针分别向前推进,将源地址的数据复制到目标地址中,直到指定的大小被完全复制。

使用场景

我们将会在加载音频数据时使用这个 Copy 函数。当我们从资产文件加载音频数据时,我们会:

  1. 确定音频数据在文件中的偏移位置;
  2. 分配一块内存,专门存储该音频数据;
  3. 使用 Copy 将数据直接从文件内存中复制到分配的音频内存中;
  4. 完成音频数据的加载。

这种方式简单直接,避免了复杂的解码或转换过程,提高了数据加载的速度和简洁性。
在这里插入图片描述

设置音频通道的采样指针

我们现在开始处理音频加载过程中通道数据的分配和对齐,目的是确保每个通道的音频样本都能正确地指向内存中相应的数据位置,并且在内存中连续排布。具体的工作流程如下:


1. 遍历所有通道并设置采样指针

我们首先需要遍历音频文件中的所有通道,将每个通道的数据正确地指向内存中相应的采样数据位置。因此,我们编写了一个循环,循环次数为通道数 (ChannelCount),在循环中完成以下工作:

  • 为每个通道分配采样指针:通过 Sound->Samples[ChannelIndex] = SoundAt; 将当前通道的样本数据指向内存的当前位置;
  • 调整内存指针位置:每次处理完一个通道的数据后,将内存指针推进到下一个通道数据的位置,以确保每个通道的数据不会互相重叠。

在这里我们采用的逻辑是:

int16 *SoundAt = (int16 *)Memory;
for (uint32 ChannelIndex = 0; ChannelIndex < Sound->ChannelCount; ++ChannelIndex) {
    Sound->Samples[ChannelIndex] = SoundAt;
    SoundAt += ChannelSize;
}

其中:

  • Sound->Samples[ChannelIndex] = SoundAt 表示当前通道的样本数据;
  • SoundAt 表示当前内存指针,用于记录数据填充到哪一块内存;
  • 每次循环之后,将内存指针 SoundAt 增加一个通道的数据大小 (ChannelSize),确保数据连续分布。

2. 计算每个通道的数据大小

我们需要提前计算每个通道的数据大小 (ChannelSize),这个数据大小是:

ChannelSize = SampleCount * sizeof(int16_t)

其中:

  • SampleCount 是音频的总采样数;
  • sizeof(int16_t) 是每个采样点的数据大小,假设这里使用的是 16 位采样格式。

计算完成之后,每次复制完一个通道的数据后,将内存指针推进 ChannelSize 大小,这样下一个通道的数据就能正确排列在内存中。


3. 优化内存使用

我们意识到在这里我们并不需要显式存储内存指针 (sound_memory) 的初始位置,因为它仅用于在循环中分配内存,并不会在其他地方使用。因此,我们决定将:

Sound->Memory

直接简化为:

void* Memory;

我们不需要存储内存指针的初始地址,只需要确保:

  • 在循环中正确递增内存指针;
  • 保证数据连续存储;
  • 在循环结束后,所有通道的数据都已经正确排列。

这也意味着我们将内存指针仅用于内存分配而不做其他用途。


4. 完全去除冗余指针

由于我们最初有一个 Memory 变量,用于记录分配的内存地址,但现在我们发现该指针并没有实际用途

  • 它不会在函数外部使用;
  • 它仅在分配内存时被使用;
  • 分配完内存后,我们只关心通道数据是否正确指向该内存,而不关心初始指针地址。

因此,我们直接将分配内存的函数改为:

void *Memory = PushSize(&Assets->AssetArena, MemorySize);

这里:

  • PushSize 是分配内存的函数;
  • MemorySize 是我们计算的总内存大小;
  • 我们不再保存 Memory 的起始地址,而是直接将数据指向 Sound->Samples

5. 内存指针推进的核心逻辑

在循环中,我们通过:

Sound->Samples[ChannelIndex] = SoundAt;
SoundAt += ChannelSize;

完成了:

  • 将当前通道的数据起始地址指向 SoundAt
  • SoundAt 指针向前推进 ChannelSize,确保下一次循环的数据存储在正确位置;
  • 避免任何冗余的指针存储或额外的变量。

这样我们确保:

  • 内存地址连续:所有音频通道的数据按顺序连续排列;
  • 内存指针自动推进:无需手动计算地址,只需要增加 ChannelSize
  • 无冗余内存使用:我们只使用内存中的实际数据部分,不保留额外内存指针。

6. 为什么要使用连续内存

我们之所以采用连续内存的设计,而不是为每个通道分配独立内存块,是因为:

  • 更符合音频引擎需求:音频数据通常需要连续存储,方便播放时直接读取;
  • 简化内存管理:避免为每个通道分配不同内存块,减少内存碎片;
  • 提高加载速度:加载时直接将数据填充到一块连续内存中,避免内存重新分配和对齐操作。

7. 内存指针转换的意义

我们将 sound_memory 转换为 void* 的原因在于:

  • 避免类型依赖:在内存复制过程中,我们只关心内存地址,而不关心数据类型;
  • 灵活性更强:如果未来需要支持不同数据格式(如 float 或 double),我们无需修改内存指针定义;
  • 符合内存分配规范:标准内存分配函数(如 mallocPushSize)通常返回 void*,我们保持一致。

8. 为什么我们不保存内存起始地址

我们最终决定不保存内存起始地址 (void* Memory),原因如下:

  • 我们只关心通道数据,而不是内存块的起始地址;
  • 内存地址不会被重用,因此不需要保存;
  • 节省内存使用,避免存储无关信息。

9. 为什么不考虑内存对齐

我们没有进行内存对齐(alignment)的原因:

  • 音频数据本身就是连续的,没有跨通道的结构体或数据;
  • 内存分配直接使用 PushSize,该函数本身通常具备一定的对齐特性;
  • 采样数据是 16 位(int16_t),而大多数系统默认内存对齐至少为 16 位,因此不会产生未对齐问题。

如果未来需要优化,我们可以:

  • PushSize 改成更高级的内存对齐分配器;
  • 在复制数据时使用 SIMD 指令提高传输速度;
  • 针对不同平台使用内存对齐技术。

在这里插入图片描述

触发段错误

在这里插入图片描述

else 的位置不对
在这里插入图片描述

声音出问题 分配的地址不对

在这里插入图片描述

测试刚刚的修改,发现存在奇怪的点击声Bug,可能和今天的修改无关,稍后再调试

我们目前已经完成了音频加载功能的主要部分,并且确保了音频数据在内存中是连续存储的,各个通道的数据指针也都正确指向了内存中的相应位置。然而,在测试音频播放效果时,我们发现音频中存在一些“点击声”或者“断断续续”的杂音,这表明我们的音频加载或者播放流程中可能存在一些问题。接下来我们需要针对该问题进行分析和排查。


1. 确认当前音频加载功能是否正常

首先,我们快速确认了加载音频的核心流程,确保以下几件事情没有问题:

  • 内存分配:使用 PushSize 分配的内存大小是正确的;
  • 通道数据指针:通过 loaded_sound->samples[channel_index] 将每个通道的数据指针正确指向内存中的相应位置;
  • 内存地址推进:在加载过程中,内存地址通过 sound_memory += channel_size 推进,确保每个通道数据不会互相重叠。

我们再次检查了一遍这些内容,发现内存分配和数据指针的设置是完全正确的,没有发现任何问题。


2. 识别音频播放中的“点击声”

在播放过程中,我们注意到音频中存在一种轻微的“点击声”,表现为:

  • 在某些音频片段,听起来像是短促的断裂声或噪音;
  • 这种“点击声”并不连续,但在播放过程中会偶尔出现;
  • 感觉像是音频样本数据在某些特定时刻没有正确对齐,或者数据传输中断导致的。

这个问题让我们意识到可能有两种情况导致了这种现象:

  1. 数据加载过程中出错:某些音频样本数据未正确写入内存,导致音频数据中存在错误的波形;
  2. 音频混音器中存在问题:音频数据在传输给混音器时未正确处理,导致播放中断或音频抖动。

3. 快速排查加载阶段的潜在问题

为了确保加载过程没有问题,我们重点检查了以下内容:

3.1. 检查样本数是否正确

我们首先检查了加载过程中记录的样本数 (sample_count):

uint32_t sample_count = asset_file_header->sample_count;

在加载过程中,我们确保了:

  • 每个通道的数据大小是 sample_count * sizeof(int16_t)
  • 总内存大小是 channel_count * sample_count * sizeof(int16_t)
  • 内存地址在填充时正确指向了 loaded_sound->samples[channel_index]

通过检查发现样本数计算没有错误。


3.2. 检查内存分配是否正确

我们接着检查内存分配是否存在溢出或未分配足够内存的情况:

void* sound_memory = PushSize(arena, memory_size);

PushSize 分配内存时,我们确保:

  • memory_size 是计算出的总大小;
  • 在填充过程中,内存地址通过 sound_memory += channel_size 正确推进;
  • 并且没有在中途发生任何内存重叠或未分配足够内存的情况。

检查发现内存分配也是完全正确的


3.3. 检查数据填充是否正确

我们最后检查了数据填充过程:

loaded_sound->samples[channel_index] = sound_memory;
sound_memory += channel_size;

确保:

  • 每次填充时,内存指针指向了正确的位置;
  • 每次填充完成后,内存指针向前推进 channel_size
  • 不存在填充重叠或者遗漏。

经过检查,这一部分没有发现任何问题


4. 排查音频混音器中的问题

由于加载过程没有任何问题,我们推测问题很可能出现在音频混音器中。在音频混音器中,有以下几种可能导致“点击声”的原因:


4.1. 缓冲区边界未对齐

如果音频混音器在读取样本数据时,没有对齐缓冲区的边界,就可能导致音频数据读取断裂。例如:

  • 当一个音频缓冲区的结束位置刚好处于采样点的中间;
  • 在播放下一个音频缓冲区时,数据未正确拼接,导致音频播放中断;
  • 听起来就像是“咔哒”一声的点击音。

我们可以通过检查音频混音器中音频缓冲区的读取和拼接,确保它们是连续的,而不是在数据边界断裂。


4.2. 音频数据跨缓冲区传输

另一种可能是音频数据在跨缓冲区传输时出现数据断裂

  • 例如音频缓冲区大小是 1024;
  • 当前缓冲区播放到 1023,接下来播放 1024;
  • 但音频数据可能出现了跳跃或者延迟,导致音频听起来有“点击声”。

我们可以通过:

  • 在音频缓冲区之间添加渐变(fade in/fade out);
  • 确保数据拷贝时不出现断裂
  • 检查音频缓冲区切换是否存在空白区

4.3. 音频数据未对齐

如果音频样本在内存中未按 16 位对齐,也可能导致“点击声”:

  • 例如音频数据是 16 位 (int16_t),但内存分配未对齐;
  • 造成某些采样点的数据错位,形成间歇性的“点击声”。

我们回头检查了内存分配:

void* memory = PushSize(arena, memory_size);

由于 PushSize 本身已经确保内存对齐,因此排除该问题。


4.4. 音频数据未零填充

如果音频数据未零填充 (zero padding),在播放末尾可能出现“点击声”:

  • 比如音频长度是 30123 个样本,缓冲区是 4096;
  • 播放到末尾时,未填充的缓冲区部分含有随机数据;
  • 导致音频最后播放出现异常。

我们可以通过在音频数据末尾进行零填充解决:

memset(sound_memory + used_size, 0, remaining_size);

这样可以避免播放末尾的“点击声”。


5. 下一步优化

目前我们已经确认:

  • 音频加载流程没有问题;
  • 内存分配完全正确;
  • 通道数据存储连续无断裂。

接下来,我们打算:

  1. 排查混音器中的缓冲区传输逻辑,确保不会产生数据跳跃;
  2. 音频播放结束时添加零填充,避免未初始化数据被播放;
  3. 跨缓冲区时添加渐变处理,消除点击声。

最终排查结果

问题位置排查结果是否导致“点击声”
内存分配正常
数据填充正常
通道指针正常
音频混音器可能存在问题

我们可以确定,点击声的问题并非加载过程导致,而是音频混音器存在一定的问题。我们下一步将排查音频混音器中的:

  • 缓冲区边界是否对齐
  • 数据跨缓冲区是否断裂
  • 未填充数据是否播放

修复这部分问题后,我们的音频播放效果将会更加流畅且无任何杂音。

如果release模式运行有时候会段错误,从新编译生成hha就行,不知道什么原因

C/C++ 中,Debug 模式和 Release 模式的主要区别体现在 优化、调试信息、运行时检查 等方面。下面是两者的核心区别:


🔍 1. 编译优化

模式优化等级影响
Debug低或无优化(-O0代码保持原样,便于调试,但运行较慢
Release高优化(-O2-O3代码会被优化,提高运行效率,但可能影响调试

优化影响

  • 变量可能被优化掉:Release 模式中,某些变量可能会被编译器优化掉,导致 调试时看不到它们 或行为异常。
  • 代码执行顺序变化:Release 模式下,编译器可能会重新排列代码,提高执行效率,这可能会导致某些 未初始化变量在不同模式下行为不同
  • 循环展开、函数内联
    • Debug 模式:保持代码原样,不展开循环、不内联函数。
    • Release 模式:可能 展开循环内联小函数,减少调用开销。

🐞 2. 运行时检查

检查项DebugRelease
断言 (assert())启用被移除
数组越界检查可能启用通常关闭
未初始化变量检查部分启用通常关闭
栈溢出检测通常启用可能关闭

影响

  • Release 模式可能导致未初始化变量错误
    • Debug 模式下,未初始化变量 可能被赋默认值(如 0xCC),方便发现问题。
    • Release 模式可能 直接使用未初始化值,导致 随机行为或段错误(Segmentation Fault)
  • Release 模式下 assert() 被移除
    • assert(condition); 在 Debug 模式 会触发错误,但 Release 模式会被优化掉NDEBUG 宏定义)。

⚡ 3. 调试信息

调试支持DebugRelease
符号表完整保留部分或全部移除
gdb/lldb 调试支持✅ 完全支持⚠️ 变量可能被优化
代码行信息✅ 详细❌ 可能缺失

影响

  • Debug 模式可以 单步调试、查看变量,而 Release 模式下,部分变量可能会被优化掉,无法查看
  • Release 模式下,可能会看到 函数调用栈缺失信息,因为某些函数被优化掉或内联了。

🚀 4. 代码大小

  • Debug 模式:保留了完整的调试信息,生成的可执行文件较大
  • Release 模式:优化后,去掉无用代码,可执行文件较小

📌 5. 典型编译选项

选项DebugRelease
优化等级-O0-O2-O3
调试信息-g通常关闭(可以用 -g -O2 保留)
断言启用#define NDEBUG 关闭
运行时检查启用部分或完全关闭
链接库通常是 Debug 版库Release 版库

🛠 6. 可能的 Release 崩溃(段错误)原因

如果 Debug 模式运行正常,但 Release 模式崩溃(段错误 / 崩溃),常见的原因包括:

❌ 1. 未初始化变量

int x;   // 未初始化
printf("%d\n", x);  // Debug 可能输出 0,Release 可能是随机值

📌 解决方案

int x = 0;  // 显式初始化

❌ 2. 访问野指针

int *p;
*p = 10;  // Debug 可能有保护,Release 可能直接崩溃

📌 解决方案

int *p = nullptr;
if (p) *p = 10;

❌ 3. 数组越界

int arr[5];
arr[5] = 10;  // Debug 可能检测到错误,Release 可能直接崩溃

📌 解决方案

if (index >= 0 && index < 5) arr[index] = 10;

❌ 4. 释放已释放的内存

int *p = new int(5);
delete p;
delete p;  // Debug 可能检测,Release 可能崩溃

📌 解决方案

int *p = new int(5);
delete p;
p = nullptr;

❌ 5. assert() 在 Release 模式被移除

assert(ptr != nullptr);  // Debug 模式有效
ptr->method();  // Release 模式可能崩溃

📌 解决方案

if (!ptr) { printf("Error!\n"); exit(1); }

🛠 7. 如何调试 Release 模式崩溃

如果 Release 版本崩溃但 Debug 版本正常,可以尝试:

  1. 开启 Release 版调试信息

    g++ -O2 -g main.cpp -o main
    

    这样可以在 gdb/lldb 中调试 Release 版本。

  2. 降低优化等级

    • 试试 -O1-Og(可以保留部分优化但不影响调试)。
  3. 开启额外检查(如 -fsanitize=address

    g++ -O2 -g -fsanitize=address main.cpp -o main
    

    这样可以检测 内存泄漏、数组越界、野指针等问题

  4. 使用 printf / std::cout 输出变量值

    • 观察变量是否被优化掉值是否异常
  5. valgrind 检测内存错误(Linux)

    valgrind --tool=memcheck ./main
    
    • 检查 未初始化变量、非法访问、重复释放等问题

🎯 总结

区别DebugRelease
优化-O0(无优化)-O2 / -O3(高优化)
调试信息✅ 完整保留⚠️ 可能缺失
运行时检查✅ 断言、未初始化变量检测❌ 可能移除
代码执行更慢,但更可预测更快,但可能行为不同
崩溃排查容易调试可能因优化导致 Bug

💡 如何防止 Release 模式 Bug?

始终初始化变量
避免访问野指针 / 已释放的内存
-g 编译 Release 版本以便调试
-fsanitize=address 检查内存错误
valgrind 检测内存问题(Linux)


如果 Release 模式崩溃,可以先试着 -g 重新编译并用 gdb 调试,找到问题的根源! 🚀

下面我将为你编写一个简单的 C++ 程序,用来验证 MSVC 在 DebugRelease 模式下对未初始化变量、堆内存和释放后内存的处理差异。程序会分别展示栈变量、堆变量的行为,并打印它们的内存值供你观察。


验证程序代码

#include <stdio.h>
#include <stdlib.h>

int main() {
    // 1. 未初始化的栈变量
    int stackVar; // 未初始化
    printf("未初始化的栈变量: %08x (地址: %p)\n", stackVar, &stackVar);

    // 2. 未初始化的堆内存
    int* heapVar = (int*)malloc(sizeof(int)); // 分配但未初始化
    printf("未初始化的堆内存: %08x (地址: %p)\n", *heapVar, heapVar);

    // 3. 释放后的堆内存
    free(heapVar);
    printf("释放后的堆内存: %08x (地址: %p)\n", *heapVar, heapVar);

    // 防止程序立即退出
    printf("按 Enter 键退出...\n");
    getchar();
    return 0;
}

如何运行和验证

  1. 环境准备:

    • 使用 Visual Studio(推荐 2019 或 2022),因为这是 MSVC 编译器的典型环境。
    • 确保项目使用默认的 Debug 和 Release 配置。
  2. 步骤:

    • 创建一个新的 C++ 控制台应用程序项目。
    • 将上述代码粘贴到 main.cpp 中。
    • 分别在 DebugRelease 模式下编译并运行:
      • Debug: 在工具栏选择“Debug”,然后按 F5 运行。
      • Release: 在工具栏选择“Release”,然后按 F5 运行。
    • 观察输出结果。
  3. 调试器观察(可选):

    • 在 Debug 模式下,设置断点(F9),用调试器(F5)运行,查看变量窗口或内存窗口中的值。

预期输出

Debug 模式
  • 未初始化的栈变量: 很可能输出 0xCDCDCDCD,因为栈变量被填充为 0xCD
  • 未初始化的堆内存: 很可能输出 0xDDDDDDDD,因为新分配的堆内存被填充为 0xDD
  • 释放后的堆内存: 很可能输出 0xFDFDFDFD,因为释放后的内存被填充为 0xFD
  • 示例输出:
    未初始化的栈变量: cdcdcdcd (地址: 00000000xxxxxxx)
    未初始化的堆内存: dddddddd (地址: 00000000xxxxxxx)
    释放后的堆内存: fdfdfdfd (地址: 00000000xxxxxxx)
    按 Enter 键退出...
    

在这里插入图片描述

stackoverflow 的高赞回答
https://stackoverflow.com/questions/370195/when-and-why-will-a-compiler-initialise-memory-to-0xcd-0xdd-etc-on-malloc-fre

Release 模式
  • 未初始化的栈变量: 输出随机值(可能是之前栈上的垃圾数据)。
  • 未初始化的堆内存: 输出随机值(可能是之前堆上的残留数据)。
  • 释放后的堆内存: 输出随机值(内存未被填充,可能是未重用时的旧值)。
  • 示例输出(具体值随机):
    未初始化的栈变量: 12345678 (地址: 00000000xxxxxxx)
    未初始化的堆内存: 87654321 (地址: 00000000xxxxxxx)
    释放后的堆内存: 87654321 (地址: 00000000xxxxxxx)
    按 Enter 键退出...
    

在这里插入图片描述

注意事项

  1. 格式说明:

    • %08x 确保以 8 位十六进制显示值,方便观察完整模式(如 0xCDCDCDCD)。
    • %p 显示指针地址,地址因每次运行而异。
  2. 未定义行为:

    • 在 Release 模式下访问未初始化或已释放的内存是未定义行为(UB),可能不会立即崩溃,但值不可预测。
  3. 环境依赖:

    • 这些填充值是 MSVC Debug 模式的特性。如果用其他编译器(如 GCC 或 Clang),行为可能不同(例如 GCC 用 Valgrind 检查,而不是填充特定值)。
  4. 验证填充值:

    • 如果想更直观地看到内存内容,可以在 Visual Studio 调试器中:
      • Debug 模式下右键变量 -> “Add to Watch”,观察值。
      • 或 Debug -> Windows -> Memory,输入地址(如 &stackVar)查看原始内存。

验证结果分析

  • Debug 模式: 你会看到 0xCD0xDD0xFD 的填充值,证明 MSVC 在调试时主动干预内存。
  • Release 模式: 你会看到随机值,证明 Release 不填充任何值,未初始化变量的值取决于内存的先前状态。

扩展实验(可选)

如果你想进一步验证,可以:

  1. 添加边界溢出测试:

    int arr[2];
    arr[2] = 42; // 越界
    

    Debug 模式可能触发运行时检查(若启用 _CRT_SECURE_BOUNDS_CHECK),而 Release 可能直接导致未定义行为。

  2. 检查野指针:

    int* ptr = new int(42);
    delete ptr;
    *ptr = 100; // 野指针
    

    Debug 下可能看到 0xFD,Release 下可能是随机值或崩溃。


结论

这个程序能清楚展示 Debug 和 Release 模式在内存处理上的差异,验证了我之前提到的填充行为。你可以自己运行试试,如果有任何输出结果或疑问,告诉我,我再帮你分析!

现在的资源后台加载器只有430行代码,但已具备标签匹配加载的功能!

目前,我们已经大幅简化了代码结构,现在只剩下一个核心的 加载工作(loaded at work) 逻辑。同时,之前的许多代码已经变得不再必要,因此可以去除这些不再使用的部分。

从整体来看,这种优化极大地提升了架构的合理性,并且在 代码量较少的情况下 实现了较高的功能覆盖率。目前的代码文件大约 500 行,它已经能够完成 位图资源(bitmap assets)音频资源(sound assets)后台流式加载,甚至包括 音乐流式播放,这一点非常高效。

更重要的是,该系统还支持 标签匹配(tag matching),意味着游戏可以通过资产系统提供任意的资源描述,并根据这些描述进行匹配和加载。

随着进一步优化,部分代码可以继续移除,例如过去需要的 手动读取内存 代码,这将使代码量进一步缩减至 约 430 行。最终,我们只需要 430 行代码就实现了完整的 资产流式加载系统,包括 后台加载流式音乐播放,这在商业引擎中恐怕很难见到类似的简洁实现。


接下来,我们需要调整逻辑,使代码可以直接从 底层平台(Windows等)读取数据。实际上,大部分代码已经完成,我们只需要补充具体的 平台端实现

在这个过程中,我们还发现了一些 音频相关的 Bug。在播放音频时,能够听到 轻微的点击噪声(clicking noise),表明可能存在数据处理上的瑕疵。这些问题可能与 音频采样数据的处理方式 有关,需要进一步调试。

为了确保数据复制正确,可以先做一个 位图数据的拷贝测试,这有助于验证内存覆盖的正确性。


此外,需要在代码中加入一个 待办事项(TODO),以便在完成当前优化后尽快修复音频点击噪声的问题:

  • 修复音频点击噪声(clicking noise),尤其是在音频样本(samples)结束时。

这个 Bug 也可能来自 资产处理器(asset processor),因为在实现过程中,我们并未深入测试该模块的稳定性。目前的开发策略是 先完成框架搭建,再进行 细节调试,因此可能存在一些 隐藏的 Bug,需要后续排查。

总的来说,目前的进展良好,我们可以进一步简化代码,去除不必要的拷贝逻辑,使代码更加高效和精炼。
在这里插入图片描述

避免将整个资源文件加载到内存中

如果直接移除这些部分,它们就会完全消失。这样会导致游戏进入资源缺失的状态,即尝试使用资源实际并不存在,因此它们会显示为空白或者默认值。

尽管如此,游戏仍然能够正常运行,不会因为缺少这些资源而崩溃。这表明当前的架构是健壮的,能够在资源缺失的情况下继续执行。

接下来,需要解决的核心问题是:如何让这些资源正常加载并正确使用。需要深入研究当前的实现,并确保资源系统能够正确提供所需的数据,使游戏能够顺利运行。
在这里插入图片描述

在这里插入图片描述

将资源数据重新基址,并通过文件索引指向对应的文件内容

要让这些内容正常运行,首先必须正确获取文件句柄。当前的问题在于,文件句柄的值仍然是,所以当程序尝试使用这些文件时,会直接崩溃。

目前的情况是:

  • **标记数组(Tag Array)**可以直接加载并正确使用。
  • **资源(Assets)**由于需要引用标记,所以不能直接加载,它们的值需要重新调整,使其指向正确的位置。

之前的实现尝试直接加载资源,但发现这样做不可行。原因在于资源在从磁盘加载到内存时,其内容会发生变化,因此不能直接使用,而是需要重新定位它们的引用。

下一步的目标是:

  1. 确保正确获取文件句柄,使程序可以访问实际的文件数据。
  2. 重新调整资源的引用,使它们能正确指向相应的标记数据,并在加载后仍然保持正确的关系。
    在这里插入图片描述

寻找一个合适的位置放置资源结构体(Asset Struct)

为了让**资源(assets)**正确加载,需要调整其数据结构,使其能够正确存储和引用相应的信息。目前的问题是,资源结构是否应该作为一个独立的结构体,还是应该与资源槽(asset slot)合并?

目前的分析:

  • 资源(asset)和资源槽(asset slot)在大部分情况下是分开使用的,很少会同时访问。
  • 访问资源时,通常只需要查看资源本身的信息,如标记(tags),而不会涉及资源槽的信息
  • 资源槽主要用于管理加载状态、内存指针等,而资源本身则包含实际的标记和数据引用。

因此,决定让资源仍然是一个独立的结构体,而不是合并到资源槽中。

具体调整:

  1. 新增字段
    • FirstTagIndex:记录资源的第一个标记索引。
    • OnePastLastAssetIndex:记录资源的最后一个标记索引的下一个位置。
    • FileIndex:标识资源来自哪个文件。
    • DataOffset:标识资源在文件中的偏移位置。
  2. 数据对齐问题
    • 由于存储结构的对齐限制,在 FileIndexDataOffset 之后,可能需要填充额外的字段,以确保数据结构对齐并减少访问开销。
    • 目前预留了填充字段(pad/reserved),以备后续扩展或优化。

预期效果:

  • 这样调整后,资源的存储方式更加合理,加载时能够快速找到文件来源,并正确解析数据。
  • 结构清晰,避免了不必要的冗余,提高了代码的可维护性执行效率
    在这里插入图片描述

填充资源结构体的数据

我们现在的目标是正确提取和存储资源数据,并确保所有信息都能正确加载和访问。

数据提取和存储的核心步骤:

  1. 提取资源数据

    • 复制所需的资源数据,包括 DataOffset(数据偏移量)和 FileIndex(文件索引)。
    • FileIndex 可以直接通过遍历文件时的索引获取。
    • DataOffsethha_asset 结构中提取,用于正确定位资源在文件中的存储位置。
  2. 资源数组的索引调整

    • 需要确保 hha_asset 结构正确存储资源信息,并维护资源索引。
    • 通过 AssetIndex 访问资源数组,并遍历所有相关的资源类型。
    • 代码中去掉了一些冗余的索引逻辑,简化了资源计数和遍历逻辑,使其更加直观和易读。
    • 修正了 AssetCount 的递增逻辑,保证索引范围是合理的,并避免越界问题。
  3. 分配临时存储区域

    • 引入临时资源数组(HHAAssetArray 用于存储资源数据,减少直接修改全局资源数组的风险。
    • 该数组的内存分配采用临时内存(ttemporary_memory),仅在读取资源时使用,确保资源数据在处理完成后不占用额外空间。
    • 采用**瞬时内存(TransientArena)**进行分配,使其更高效并减少内存碎片。
    • 在使用完毕后,调用 pop EndTemporaryMemory 操作回收临时内存,避免不必要的资源占用。
  4. 确保格式兼容性

    • 需要检查 HHAAssetArray 的数据结构,确保其包含所有必要的资源信息,如位图(bitmap)和音频信息(sound info)。
    • 这样可以避免遗漏资源的关键数据,保证正确的加载流程。
    • 通过遍历 HHAAssetArray 结构,确认资源格式与当前处理方式的匹配情况。

优化与改进:

  • 避免重复存储:将资源数据临时存储在 HHAAssetArray,只在需要时加载到最终数组,减少冗余拷贝。
  • 提升索引管理效率:修正 AssetCount 计算逻辑,避免不必要的索引检查,提高遍历速度。
  • 优化内存管理:使用 TransientArena 进行临时数据存储,并确保 pushpop 机制能够正确管理分配的内存。
  • 代码结构简化:移除不必要的索引计算逻辑,减少复杂性,提高可读性和维护性。

预期结果:

  • 资源数据可以正确存储并被访问,不会因为索引错误或数据遗漏导致崩溃。
  • 代码更加简洁清晰,维护和优化更加方便。
  • 内存管理更加高效,减少不必要的内存占用,提高资源加载性能。
    在这里插入图片描述

资源结构体实际上就是hha_asset,只不过额外添加了一个文件索引(File Index)

我们决定对资源结构进行调整,以便更方便地访问资源的文件索引信息,并优化加载逻辑。

资源结构调整

  1. 在资源结构中添加文件索引

    • 资源结构将直接包含 FileIndex,这样每个资源都能记录自己来源于哪个文件。
    • 这样做的好处是:在访问资源时,我们可以直接获取其 FileIndex,而不必额外查询或存储这部分信息。
    • 这个方式类似于之前的设计,但进一步增强了资源数据的完整性,使资源与其来源文件明确关联。
  2. 修改资源访问逻辑

    • 代码中的 HHAAsset 访问方式修改为新的 Asset->HHA. 方式,以适配新的数据结构。
    • 这样,我们不仅能获取资源本身的信息,还能直接访问 FileIndex,用于后续文件操作。
  3. 获取文件句柄

    • 在访问资源数据时,需要根据 FileIndex 获取对应的文件句柄(file handle)。
    • 这里的改动让获取文件句柄的过程更加清晰、直接,不需要绕过多层索引或中间结构。
    • 代码逻辑将 GetFileHandleFor 作为一个通用方法,使得所有涉及文件读取的地方都能一致地获取文件句柄。
  4. 增强可读性与可维护性

    • 这样改动后,代码结构更加清晰,逻辑变得更加直观。
    • 未来如果需要对 asset 结构添加更多字段,也能方便扩展,而不影响原有的存储方式。

优化点

  • 减少冗余数据存储:资源结构中直接记录 FileIndex,避免重复查询文件来源。
  • 优化资源访问逻辑:通过 GetFileHandleFor 方法统一处理文件句柄的获取,提高代码复用性。
  • 提升可维护性:调整 Asset.HHA 访问方式,使得代码更具可读性,便于后续修改和优化。

预期效果

  • 访问资源时能够直接获得 FileIndex,无需额外查询,提高效率。
  • 代码逻辑更加直观,减少冗余,提升可维护性。
  • 统一文件句柄的获取方式,避免不必要的复杂操作,使资源管理更加清晰有序。
    在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

不要提前设计架构,避免浪费时间

在调整架构的过程中,我们反复尝试了不同的方法,特别是在资源与标签的处理方式上,最终通过实际需求的分析,确定了最佳方案。

架构调整的核心思考

  1. 资源(assets)和标签(tags)的处理方式不同

    • 资源在加载时会被处理,而标签不会。
    • 这一点决定了架构的设计方式,我们需要让资源结构具备适当的可操作性,而标签可以保持更简单的存储方式。
    • 之前尝试过将标签嵌入资源、也尝试过分离存储,最终通过观察代码逻辑,发现资源在加载时必须重新定位,而标签不需要。
  2. 实验驱动架构(Experimental Architecture)

    • 这种架构并不是在一开始就完全确定,而是通过不断尝试和调整找到最合适的结构。
    • 预先设计一套完整架构是不现实的,即使是最优秀的程序员,也不会在没有尝试的情况下直接做出完美设计。
    • 代码需要保持灵活性,等到对数据流和处理逻辑有充分理解后,再确定最终结构,这样才能避免过早优化带来的问题。
  3. 代码流畅性与稳定性

    • 关键在于代码书写风格要足够流畅,能够快速迭代,调整不同的实现方案。
    • 不应该在架构尚未成熟时就让代码定型(calcify),而是应该先观察实际的运行效果,再确定最终方案

最终架构调整

  1. 移除旧的资源数据结构

    • 之前的一些数据结构(如 assetstags 的混合存储方式)已经不再需要,全部交由 HHAContents 进行管理。
    • 这样,所有的资源和标签数据都可以通过 HHAContents 进行统一访问,简化了数据管理方式。
  2. 文件句柄与数据存储的改进

    • 资源结构中包含 FileIndex,使得每个资源都可以追溯到具体的来源文件。
    • 这样,在需要访问资源数据时,可以直接获取 FileIndex 并进行文件读取操作。
    • 统一使用 GetFileHandleFor 作为文件句柄获取的接口,减少冗余代码,提高可读性。
  3. 优化代码流

    • 代码逻辑变得更加清晰,能够更容易理解数据的流向。
    • 之前的一些 hhs_asset 访问方式被替换为新的 asset.HHA 方式,以适配新的数据结构。

最终效果

  • 资源结构更加合理,不再包含冗余信息,HHAContents 统一管理数据。
  • 代码逻辑清晰,访问资源时可以直接获取 FileIndex,无需额外查询,提高效率。
  • 代码流畅性提升,通过实验驱动架构优化,使得代码更易扩展、维护,并且更符合实际需求。
  • 避免过早优化,让代码在合理的阶段定型,而不是一开始就僵化架构。

下一步

接下来,我们将继续完善 HHAContents 的存储与读取方式,确保它能够稳定、高效地管理所有资源数据。
在这里插入图片描述

实现 GetFileHandleFor 函数(根据文件索引获取文件句柄)

现在的实现重点是获取文件句柄(file handle),并确保数据结构的正确性,使代码更加流畅和合理。

文件句柄的获取逻辑

  1. 实现 GetFileHandleFor

    • 该函数的作用是根据文件索引(file index)获取正确的文件句柄(file handle)
    • 它从**文件数组(assets.files)**中查找相应的文件句柄并返回。
  2. 数据验证

    • 首先,我们需要确保 file index 是有效的,不能越界或指向无效数据。
    • 通过 assert(file_index < assets.file_count) 进行边界检查,防止访问非法内存。
  3. 查找并返回文件句柄

    • 通过 assets.files[file_index].file_handle 获取文件句柄。
    • 这部分逻辑非常直接,没有复杂的处理,只是从数组中索引并返回相应的句柄。

代码整理与优化

  1. 统一 game_assets 访问方式

    • 之前代码中涉及多个资产管理结构(assetsgame_assets等),容易混淆。
    • 统一调整后,所有资源相关操作都通过 game_assets 访问,避免重复。
  2. 加载文件时直接获取句柄

    • 之前代码中,文件句柄的获取是一个独立的步骤。
    • 现在,在加载文件时直接存储句柄,这样后续访问时可以直接查找,提高效率。
  3. 改进数据存储格式

    • 资产数据 data_assets 现在归属于 HHAContents,避免了多个不同结构的重复存储。
    • 减少数据冗余,只保留必要的信息,并通过索引访问具体资源内容。

代码逻辑优化

  1. 数据拷贝

    • 在读取资产数据时,只需要拷贝必要的数据部分,而不是整个结构体。
    • 这样可以节省内存,并提高加载效率。
  2. 标签处理优化

    • 之前的实现会整体拷贝资产数据,包括标签部分。
    • 现在,我们先拷贝必要的数据,然后单独更新标签部分,这样可以减少无用的内存操作,提高性能。

最终效果

  • 文件句柄管理更加清晰,通过 GetFileHandleFor 统一访问,提高可读性和可靠性。
  • 数据结构更加合理,减少冗余存储,所有数据都归属于 HHAContents 进行管理。
  • 代码逻辑优化,减少不必要的拷贝操作,提高加载效率和运行速度。
  • 提高可维护性,统一 game_assets 访问方式,减少混淆,使代码更加直观。

下一步

接下来,我们将进一步优化资产的读取流程,并确保 HHAContents 结构能稳定管理所有资源数据。
在这里插入图片描述

在这里插入图片描述

文件I/O代码非常简单。如果我们不写错的话,直接编写并测试所有文件I/O代码会节省很多时间,否则我们还需要额外写测试代码

目前代码的基本功能已经完成,但存在一个很大的问题:我们完全不知道它是否可行。由于代码尚未运行,可能存在大量逻辑错误、拼写错误或实现上的问题,因此必须进行调试和验证。

调试的两种方式

在这种情况下,我们有两种选择:

  1. 单独测试文件 I/O 代码

    • 编写专门的测试代码,验证文件读取是否正常。
    • 这样可以确保底层文件操作没有问题,之后再测试上层代码。
  2. 整体调试整个代码(更可能的选择):

    • 直接运行完整代码,检查错误并逐步修复。
    • 这样可以同时发现文件 I/O 和资产管理代码中的问题。

考虑到文件 I/O 代码相对简单,验证它的正确性不会太复杂,因此更倾向于直接调试整个系统,而不是花时间为 I/O 代码单独编写测试代码。

如何权衡测试

调试方法的选择不是固定的,而是要根据开发效率进行权衡

  • 如果文件 I/O 代码复杂,并且依赖较多,最好先单独测试它。
  • 如果文件 I/O 代码简单,调试时很容易验证其正确性,则可以选择直接整体调试。
  • 写测试代码的时间 vs. 直接调试的时间,如果写测试代码的时间远长于直接调试,那么直接调试更合理。
  • 测试代码本身也可能有 bug,如果文件 I/O 代码很简单,那么编写测试代码的价值不大。

测试的本质

测试是一种工具,不应该机械地决定是否编写测试,而是要根据实际情况做出权衡

  • 盲目推崇“测试驱动开发”(TDD)可能会导致开发时间过长。
  • 完全不写测试也可能导致难以排查错误。
  • 测试的作用是提高开发效率,如果某个测试能帮助更快发现问题,就值得去写;否则可以省略。

下一步

  • 直接运行代码并调试,看看文件 I/O 是否正常工作。
  • 如果遇到问题,先检查文件 I/O 逻辑是否正确,再验证资产管理代码。
  • 逐步修正 bug,确保整个系统能够正确加载和管理资产数据。

在这里插入图片描述

实现平台层的文件操作函数

接下来需要进入平台相关的代码层,具体来说,我们需要实现还未完成的函数,这些函数之前已经在接口层声明过,但尚未提供具体实现。

需要实现的函数

当前的代码调用了一些文件操作相关的函数,这些函数属于平台层的接口,它们的作用包括:

  • 获取某种类型的所有文件Win32GetAllFilesOfTypeBegin
  • 打开文件Win32OpenFile
  • 从文件中读取数据Win32ReadDataFromFile
  • 文件读取出错处理Win32FileError

这些函数是非平台相关的代码 调用的,我们需要在平台层实现它们,以便系统能够正常读取文件数据。

实现思路

代码中的大部分文件操作逻辑已经写好,只是之前的实现方式不同。因此,目标是:

  1. 拆分已有逻辑,组织成单独的函数

    • 之前的DEBUGPlatformReadEntireFile已经包含了文件读取的逻辑,现在需要把它拆分,让Win32OpenFile仅负责打开文件,其他操作交给Win32ReadDataFromFile等函数。
  2. 实现Win32OpenFile

    • Win32OpenFile的职责是只执行文件打开操作,不涉及读取数据。
    • 先调用底层 API 获取文件句柄(handle)。
    • 记录该文件的元信息,例如大小、类型等。
  3. 调整关闭文件的逻辑

    • 之前的代码可能直接在读取完文件后关闭它,但新的结构需要分离文件关闭的操作,以适应新的函数划分。

具体实现步骤

  1. DEBUGPlatformReadEntireFile中提取文件打开部分,放入Win32OpenFile
  2. Win32ReadDataFromFile中处理数据读取逻辑
  3. 确保文件错误处理函数file_error能够正确捕获异常
  4. 初步实现一个简单版本,确保基本功能可用,然后再优化代码

这样做的好处是:

  • 代码结构更清晰,各个函数的职责更加单一。
  • 提高代码复用率,不同的操作可以更灵活地组合。
  • 方便后续优化,比如可以更容易地替换底层 API 或者添加缓存机制。

接下来,将按照这个思路开始实现这些函数,确保平台层的文件操作完整可用。
在这里插入图片描述

不需要关闭文件,因此不需要编写 Win32CloseFile 函数

目前的代码没有关闭文件句柄,因为文件句柄会一直保留,不会在文件读取完成后立即释放。因此,当前的实现不需要显式地关闭文件

关于文件句柄管理

  • 现状:文件打开后,我们一直保留这些句柄,并不会主动关闭它们。
  • 潜在改进:可以记录一个close_handle函数的占位代码,如果未来需要实现文件关闭功能,可以在某些特定情况下调用,例如:
    • 文件不再被使用时手动关闭
    • 程序退出时统一释放
    • 某种更新或重载操作时,关闭旧的文件并重新加载新的内容

如何处理文件关闭

虽然当前没有实现CloseHandle,但可以先在代码中预留相应的注释,以便未来如果需要动态管理文件时,可以随时补充CloseFile函数。例如:

// Placeholder: Close file if we ever decide to release handles dynamically
internal PLATFORM_FILE_ERROR(Win32CloseFile){
    CloseHandle(FileHandle);
}

这样,如果后续需要优化资源管理,只需要在合适的时机调用CloseFile函数,而不必重新修改整个架构。

当前决策

  • 不主动关闭文件句柄,因为它们一直保留在程序运行过程中。
  • 记录一个CloseFile的函数占位符,方便未来在适当的时候进行资源释放。
  • 如果未来文件句柄过多,或者操作系统资源受限,可以再调整策略,决定何时释放句柄。
    在这里插入图片描述

编写 Win32OpenFile 函数(用于打开文件)

在这段内容中,讨论了文件句柄的实现方式和相关的结构设计。首先提到打开文件时的处理流程,并指出实际上只需要关注文件句柄的管理,其他如文件大小的获取并不必要。接下来,讨论了如何设计一个平台文件句柄的结构,包含文件句柄本身和错误码。以下是具体细节的总结:

文件句柄管理:

  1. 平台文件句柄结构

    • 需要一个结构体来表示文件句柄,这个结构包含:
      • 文件句柄(实际的操作系统句柄)
      • 错误码(表示是否出错)
  2. 文件打开的流程

    • 打开文件时,创建一个平台文件句柄结构并进行初始化。对于成功的打开,文件句柄会被赋值,且错误码设为0,表示没有错误。
    • 如果文件打开失败,则会将错误码设置为相应的错误值。
  3. 内存分配

    • 文件句柄需要在堆上分配内存,并通过指针返回给调用者。
    • 由于不需要在文件操作过程中关闭文件句柄,句柄分配的内存永远不会被释放。因此,设计时需要考虑到内存的管理,尤其是在需要大量文件句柄时。
    • 在实际开发中,为了避免内存泄漏,可能会在后续的版本中增加文件句柄关闭的逻辑,或者采用虚拟内存分配(如 VirtualAlloc)来进行内存管理。
  4. 错误码的意义

    • 错误码的定义发生了改变,原来表示错误的标志位会改为NoErrors,表示没有错误。
    • 这种方式使得文件句柄的初始化状态比较清晰,也便于调试和后续的错误处理。

设计决策:

  • 不需要关心文件大小:文件大小的获取并不在当前需求中处理,因此这个功能被移除了。重点放在获取文件句柄上。
  • 内存管理的简化:使用堆内存分配文件句柄结构体,并假设只有有限的文件句柄需要处理。文件句柄的管理不需要复杂的内存回收逻辑,因为在这个场景下文件句柄数量相对较少,最多只有十个左右。
  • 内存分配的选择:虽然使用了堆内存分配,但为了简化设计,不使用更复杂的内存管理方法(如 arena 分配),认为在当前的需求下这样足够高效。

总结起来,这段代码设计是为了简化文件句柄管理,通过内存分配和错误码控制,确保文件打开时能够正确获取句柄并处理错误。同时也留有余地,未来可以考虑优化内存管理和句柄的释放等问题。

在这里插入图片描述

在这里插入图片描述

编写 Win32ReadDataFromFile 函数(用于读取文件数据)

在这段内容中,讨论了从文件中读取数据的处理过程。具体来说,如何在读取文件时使用文件句柄、偏移量和大小,并且确保数据的正确读取。以下是详细的总结:

读取文件数据的步骤:

  1. 获取文件句柄

    • 读取数据时,首先需要获得文件句柄作为输入参数,确保使用的是正确的文件句柄(Source)。这个文件句柄会在后续的读取操作中使用。
  2. 处理偏移量和大小

    • 读取操作需要指定偏移量(Offset)和数据大小(Size)。这些参数决定了从文件中的哪个位置开始读取,以及读取的数据量。
  3. 使用 seek 函数

    • 读取文件时,需要先使用 seek 函数来设置文件指针的位置。这个步骤非常重要,因为如果文件的读取位置不正确,就无法正确读取数据。
  4. 读取数据

    • 在设置了正确的文件偏移量后,执行文件读取操作,读取指定大小的数据。读取的数据将从文件中提取并返回。
  5. 文件读取的实现

    • 读取数据的实现过程与读取的大小和偏移量相关,确保数据的读取是从正确的位置开始,并且读取的大小是符合要求的。

总结起来,这段代码主要关注如何通过文件句柄、偏移量和大小来正确读取文件的数据,特别是在需要准确控制读取位置时,seek操作是必不可少的步骤。
在这里插入图片描述

讨论重叠I/O(Overlapped I/O)的概念

在这段内容中,主要讨论了如何通过使用 Windows 中的 overlapped 结构来进行文件读取,避免使用传统的 seek 操作。以下是详细的总结:

读取文件的改进方法:

  1. 传统的 ReadFile 操作

    • 在常规的 ReadFile 操作中,文件的读取并没有直接提供设置读取偏移量的方式,这意味着如果想要从文件的特定位置开始读取,必须使用 seek 来移动文件指针。这在多线程环境中可能会引发问题,因为不同线程可能会同时修改文件指针的位置,导致数据读取错误。
  2. 使用 overlapped 结构

    • 为了避免使用 seek 操作,可以使用 ReadFile 函数中的 lpoverlapped 参数。这个参数接受一个 overlapped 结构,它允许在文件读取时指定偏移量。这对于避免多线程竞争条件非常有用,因为它允许在不改变文件指针位置的情况下,从指定的偏移量开始读取文件。
  3. overlapped 结构的作用

    • overlapped 结构包含了读取数据时所需的偏移量。尽管 ReadFile 的传统形式不支持直接设置读取偏移量,但如果使用 overlapped 结构,可以通过设置 offsetoffset high 来指定读取位置。偏移量是一个 64 位的值,需要将低 32 位和高 32 位分别处理。
  4. 如何设置 overlapped 结构

    • 在设置 overlapped 结构时,需要将其初始化为零,并设置 offsetoffset high 来指定文件读取的起始位置。低 32 位的偏移量直接从原始值中获取,高 32 位的偏移量通过右移和掩码操作获得。
  5. 避免使用 seek 操作

    • 使用 overlapped 结构的好处是可以避免使用 seek 来调整文件指针,从而使得多线程环境下的文件读取更加安全。通过 overlapped 的方式,不同的线程可以同时读取文件,而无需担心一个线程通过 seek 修改文件指针导致其他线程读取错误数据。
  6. 设置文件读取的偏移量

    • 一旦设置好 overlapped 结构中的偏移量,读取操作就可以直接从指定位置开始,而无需进行 seek 操作。这样就能够避免线程间的干扰,确保读取过程更加高效和线程安全。

总结:

  • 传统的读取方式:通常需要通过 seek 改变文件指针来设定读取的起始位置,容易在多线程环境中引发问题。
  • 改进方式:通过使用 overlapped 结构,可以在调用 ReadFile 时直接指定读取的偏移量,从而避免 seek 操作,提升多线程读取的安全性。
  • 操作过程:通过正确设置 offsetoffset high,可以确保文件从正确的位置开始读取,而不需要手动调整文件指针。
    在这里插入图片描述

在这里插入图片描述

Windows无法读取超过4GB的数据,因此需要添加一个断言,防止出现这种情况

在这段内容中,讨论了如何处理大于4GB的文件读取问题,以及如何在文件读取失败时处理错误。以下是详细总结:

文件读取问题和解决方案:

  1. Windows 限制

    • Windows 系统在读取文件时存在一个限制,即它不能直接读取大于 4GB 的文件。这是由于操作系统的某些内存和文件处理机制的限制,导致无法处理超过此大小的单个文件读取请求。
  2. 如何解决这一问题

    • 由于大多数资源文件(asset file)不太可能达到如此巨大的文件大小,因此假设通常不会遇到超过 4GB 的文件。但为了防止将来出现这种情况,需要留出处理大文件的空间。
    • 在当前的实现中,可以通过“截断”文件大小的方式,假设单个文件的大小永远不会超过 4GB。这样,程序会继续执行并处理文件,但如果在某些情况下文件超过 4GB,会触发一个断言(assert)来提醒开发人员处理超大文件的情况。
  3. 错误处理

    • 当读取文件失败时,需要正确地设置和报告错误。具体而言,如果在读取文件时发生错误,程序会在 FileError 位置记录错误信息,并指出读取失败的文件句柄。
    • 错误处理通过调用一个专门的错误报告机制,向系统报告读取文件失败的问题。
  4. 断言的使用

    • 断言(assert)用于确保文件大小不会超出 4GB 的限制。如果程序运行时尝试读取超过此大小的文件,断言会触发,从而提示开发人员需要对这种情况进行处理或升级程序。
    • 这样,程序在遇到异常情况时能够提供即时反馈,避免继续执行导致更严重的错误。

总结:

  • Windows 文件读取限制:Windows 操作系统不能读取大于 4GB 的文件,因此需要在程序中采取措施,确保读取的文件大小不会超过此限制。
  • 解决方案:假设大多数资源文件的大小都小于 4GB,程序通过“截断”处理来确保不会处理过大的文件。若文件超过 4GB,系统会通过断言机制给出警告。
  • 错误处理:在文件读取失败时,通过专门的错误报告机制记录失败的文件句柄,并报告错误,确保程序能够正确处理异常情况。

编写 Win32FileError 函数(处理文件错误)

在这段内容中,讨论了 FileError 处理函数的当前实现状态和未来改进的可能性。以下是详细总结:

1. FileError 函数的作用和当前状态

  • 当前,FileError 函数没有实际执行任何操作,实际上它只是简单地标记没有错误。函数本身没有执行任何进一步的处理,比如记录错误消息或输出调试信息。
  • 该函数本应接收文件句柄和错误信息,处理错误并标记是否存在错误。然而,目前在实现中,它并没有做这些操作,主要是通过修改 NoError 的状态来标记错误发生。
  • 一旦调用 FileError 函数,NoError 状态就会被设置为 false,表示系统出现了错误。

2. 错误消息的处理

  • 虽然目前函数没有实际处理错误信息,但可以在将来增加输出错误消息的功能。例如,可以将错误消息记录到日志中或输出给开发人员,以便调试。
  • 如果是开发阶段的内部版本(例如调试版本),可以通过输出更详细的错误消息来帮助开发人员诊断问题,可能包括文件的路径或错误类型等信息。

3. 改进的潜力

  • 在未来版本中,如果有必要,可以改进 FileError 函数的功能,使其能够提供更多的错误上下文信息,特别是在开发阶段。这样有助于调试,并且为开发人员提供更丰富的错误报告。
  • 也可以选择在某些特定的构建中启用更详细的调试输出,这样可以帮助团队更好地理解和解决可能的错误。

4. 错误标志 NoError

  • 重要的是,一旦调用 FileError,系统就会把 NoError 设置为 false,表示当前存在错误。此时,后续操作会基于此错误标志,避免继续执行可能会受到错误影响的代码。

总结:

  • 当前实现FileError 函数目前主要通过设置 NoErrorfalse 来标识错误,并未进一步处理错误信息。
  • 未来改进:可以在未来的版本中添加更多的错误信息输出,尤其是在开发或调试过程中,记录详细的错误消息或提供更丰富的调试信息。
  • 错误标志:通过修改 NoError 标志,表明系统在某个点上发生了错误,避免错误的进一步传播。
    在这里插入图片描述

在这里插入图片描述

如果句柄无效,直接忽略文件操作

在这段内容中,讨论了关于处理文件读取错误、失败状态以及如何避免在错误的文件句柄上继续执行读取操作的逻辑。以下是详细的总结:

1. 避免在错误状态下进行文件读取

  • 一旦文件操作进入了失败状态,系统应该避免继续尝试读取该文件。因为在错误的文件句柄上进行读取可能会导致不可预见的结果,因此要确保在文件句柄出错后,后续的读取请求被忽略。
  • 换句话说,如果发现文件句柄已经处于失败状态,那么就不再尝试在这个句柄上进行任何读取操作,这样可以避免进一步的错误传播和不确定行为。

2. 关于 NoErrors 标志

  • 提到 NoErrors 不是一个成员变量,因此在实现时需要确认该标志的定义和位置。当前存在一些不确定性,需要查看 NoErrors 的具体实现位置。
  • 对于文件句柄的处理,错误标志可能并不是直接与文件句柄绑定的,需要确保正确获取和使用错误标志,以便在出现错误时标记文件操作状态。

3. 关于文件名称的问题

  • 当前没有明确定义如何获取文件名,因此考虑暂时硬编码文件名来处理相关逻辑。
  • 提到文件句柄和文件名的关系,虽然可以获取文件句柄(如 winter2 handle),但当前系统没有直接的文件名信息,可能需要在将来的实现中完善这部分内容。

4. 关于文件句柄的类型转换

  • 提到在处理文件句柄时,可能存在需要进行类型转换的问题。文件句柄的类型需要与平台的具体要求相匹配,因此需要确保正确处理句柄的类型和参数。

总结:

  • 错误状态处理:一旦文件操作进入错误状态,系统将避免对该文件进行任何后续的读取操作,以避免潜在的风险。
  • 文件句柄错误标志:需要确认如何管理文件句柄的错误状态,NoErrors 可能需要进一步定义和改进。
  • 文件名获取问题:目前没有明确获取文件名的机制,因此暂时硬编码文件名来处理文件操作逻辑。
  • 类型转换问题:需要处理文件句柄的类型转换,确保与系统的要求相匹配。

总的来说,重点在于确保一旦文件操作失败,系统能够停止进一步的读取操作,同时处理文件句柄和错误标志的相关逻辑。
在这里插入图片描述

在这里插入图片描述

测试今天的代码

在这段内容中,主要讨论了如何暂时处理文件名的部分,并为文件加载功能进行测试。具体操作步骤如下:

1. 临时处理文件名

  • 当前为了测试加载功能,决定暂时忽略文件名的真实内容,直接使用一些虚假的数据进行测试。文件名部分将被简单地“作弊”处理,即直接硬编码一个假文件名,目的是为了能够顺利进行测试。
  • 为了测试加载功能,将假定文件始终是从同一个文件加载,不考虑不同的文件路径或名称。

2. 文件计数设置

  • 为了方便测试,设置文件计数为1,假设只有一个文件需要加载。这可以简化测试场景,避免涉及到多个文件的复杂情况。

3. 加载测试文件

  • 假定测试时,始终加载同一个测试文件,并进行相关的加载操作。这样做可以确保在没有其他复杂情况干扰的情况下验证加载功能是否正常。

4. 文件名大小写问题

  • 注意到文件名在不同地方可能会出现大小写不一致的问题。这个问题在处理文件名时并不影响当前的测试目的,因为文件名的内容在此阶段是故意“作弊”处理的,只是为了验证加载操作的功能。

总结:

  • 文件名处理:为了测试加载功能,暂时不使用真实文件名,而是硬编码一个虚假的文件名。
  • 文件计数:为了简化测试,将文件计数设置为1。
  • 加载测试文件:假定始终加载相同的文件进行测试。
  • 文件名大小写:文件名在不同地方可能有大小写不一致的情况,但当前不影响测试功能,因为文件名是被故意简化的。

在这里插入图片描述

快速调试查看执行情况

在这段内容中,讨论了如何开始进行调试并逐步验证加载过程。具体步骤如下:

1. 快速逐步执行

  • 当前的目标是简单地逐步执行代码并观察其行为。虽然时间有限(只有1到2分钟),但这个步骤主要是为了确保流程的基础部分能够正常运行。

2. 处理第一个文件

  • 将开始调试时先获取第一个文件。步骤是从文件列表中提取出第一个文件,并确保正确设置文件标签的基础值。
  • 使用标签基础值(TagBase)为0,作为文件标识符的起始点。标签值决定了指针的偏移量,从而可以正确地定位文件的各个部分。

4. 读取文件信息

  • 接下来,需要读取文件的相关数据。这意味着需要确保能够正确解析和读取出文件的基本信息,如文件的头部或结构信息。

总结:

  • 调试目标:在有限的时间内进行快速的逐步调试,观察代码是否按预期执行。
  • 非正常终止:由于无法确保第一次调试就完全正确,因此调试会暂时中断,等待下一次更加深入的调试。
  • 文件处理:首先读取第一个文件,设置正确的标签基础值,确保文件指针正确定位并开始读取文件数据。

这段时间的调试并不是最终的结果,而是一个简单的检查过程,下一次调试会深入探讨潜在的错误并进行修复。
在这里插入图片描述

发现我们忘记读取文件头部信息,导致读取失败

在这段内容中,讨论了读取文件头部时出现的问题,以及对于代码的分析和疑惑。具体总结如下:

1. 读取文件头部的问题

  • 在调试过程中,发现代码中出现了一个问题:在读取文件头之前,似乎已经开始了其他操作,这是不合逻辑的。
  • 代码中出现了令人困惑的部分,疑似错误地插入了不应存在的代码,导致逻辑混乱。对此,表示了强烈的不满,认为这些错误的代码应当被修正。

2. 头部信息的读取

  • 头部信息应该是在读取文件内容之前明确读取的,并且读取头部后才会计算资产类型数组的大小。出现的问题是,之前的代码没有按照这个顺序进行操作,导致了混乱。
  • 通过检查代码,发现资产类型数组大小的计算应当是在文件头被读取之后才进行的,而不是在此之前。

3. 打开文件和读取数据

  • 接下来,代码成功地打开了文件,并分配了相应的内存。这表明文件读取的准备工作已经完成。
  • 通过读取文件的内容,确认文件头部被成功读取,并且没有出现错误。
  • 使用了重叠结构来确保读取操作正确地从文件的指定位置开始,尤其是头部的开始位置。

4. 对代码的怀疑与调试

  • 在处理文件头部时,遇到了一些疑问,特别是文件头部的大小为何不能正确计算。出现了对于代码的疑虑,认为有些地方的实现不合理或存在错误。
  • 尽管如此,文件头部似乎已经被正确读取,并且没有出现错误。

总结:

  • 代码问题:在读取文件头时,发现代码执行顺序不正确,文件头应在其他操作之前读取,但某些地方的逻辑出现了混乱。
  • 读取头部:确保在正确的时机读取文件头部,以便后续操作能够顺利进行,特别是对资产类型数组大小的计算。
  • 文件操作:成功打开文件并读取数据,确认文件头已成功加载。
  • 调试思考:在过程中对某些代码的实现提出疑问,推测可能存在不必要的错误代码,导致了程序的异常行为。

这些问题将在进一步的调试和修正中被解决,确保程序按照预期正常运行。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

Visual Studio 无法显示当前可执行文件之外的符号大小信息

这段内容的主要讨论的是调试过程中的发现和文件加载的具体实现。以下是详细的中文总结:

1. 读取资产类型数组大小

  • 在代码中,通过读取资产类型数组的大小,确定了资产的数量(每个资产占4个字节),并使用这个大小来加载后续的资产数据。
    在这里插入图片描述

3. 加载文件头和检查魔法值

  • 文件头包含了一些重要的信息,包括魔法值、版本号、类型计数和资产类型计数等。通过检查这些值,确保文件格式正确且符合预期。
  • 在读取文件头之后,确认了读取的内容是否合理,没有出现异常。
    在这里插入图片描述

4. 加载资产类型数组

  • 代码继续读取资产类型数组,并验证其中的魔法值和版本号,确保它们符合预期。
  • 对于每个资产类型,累加其总数,接着将这些数据推送到数组中。

5. 读取标签

  • 加载标签时,代码通过计算标签的偏移量,读取相应的标签数据,并根据实际情况决定是否继续读取。标签的读取过程是基于文件中的偏移位置,确保文件数据按预期读取。
    在这里插入图片描述

6. 处理资产类型

  • 对每个资产类型进行处理,检查文件中是否存在相应的资产,并读取每个资产类型的数据。
  • 对于某些资产类型(如类型0),如果没有相关数据,代码会跳过这些数据,继续处理其他类型的资产。
    在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

7. 文件读取和处理

  • 在整个过程中,代码依次读取所有的资产数据,每次读取一个资产类型的数组,直到所有资产类型的数据都被读取完毕。
  • 这个过程假设每个文件中每个资产类型的数据只会被读取一次,然后继续处理其他资产类型的数据。

8. 总结

  • 整体流程是逐步读取文件中的资产数据,并根据文件头和标识符的值来验证数据的完整性。
  • 代码通过不断检查文件的结构和数据,确保每个资产类型被正确加载,并且所有的数据都被读取且处理完毕。
  • 调试过程中发现的一些细节问题表明,调试器的配置和代码实现中可能有些不一致的地方,导致了部分调试信息的丢失。

在这里插入图片描述

断言触发,因为读取了一个空资源,我们暂时忽略这个问题

在这里插入图片描述

在这一段过程中,程序遇到的一个问题是,加载了 50 个类,而预期是加载 51 个。经过思考,发现这是正常的,因为第一个资产是一个“无资产”(no asset),该资产实际上并不会被存储。因此,实际加载的类数少于预期的数量,这是可以理解的。

这个问题并不需要立即解决,可以暂时跳过这个错误,继续处理后续的步骤,等到之后再进一步分析和解决。

总结来说,当前问题与资产的存储有关,尤其是第一个“无资产”不会被存储,因此加载的资产数比预期的少。这是一个可接受的情况,不需要马上修复,可以先跳过此错误。
在这里插入图片描述

在这里插入图片描述

测试结果:大部分功能已经正常工作!

目前的实现还没有完全正确,尽管流媒体已经成功从资产文件中读取数据,但仍然存在一些问题,比如没有正确显示阴影(shadows)。虽然遇到了一些小问题,但整体进展还是令人满意的。接下来还需要进一步的工作来解决这些问题。

在需要比内存池更动态的内存管理时,您会使用什么?是VirtualAlloc/malloc,还是其他方案?

当需要比默认的 Arena 更加动态的内存管理时,通常会使用更通用的内存分配器。Arena 是一种高效的内存分配方式,它适用于大部分情况,尤其是当内存分配模式比较稳定时。然而,在一些特殊的情况下,比如需要动态管理内存的资产(assets),Arena 就不够用了。资产的大小是动态变化的,在加载和卸载时,我们需要能够高效地管理内存块,以便灵活地进行内存操作,这时候就需要使用更动态的内存分配方法。

在处理这种情况时,可以使用通用内存分配器。通常,使用通用分配器时,我们会先定义希望使用的内存量,并为其分配一个内存块,然后就可以从这个块中动态地分配内存。如果不确定内存访问模式和分配模式,使用通用分配器是一个不错的选择,因为它提供了更灵活的内存管理。

然而,大多数时候,我们通常能够预见到内存分配模式,因此 Arena 更为高效。在 Arena 中,内存块的分配和释放更为直接,并且可以批量处理,不需要逐个释放,这样不仅速度更快,而且更容易避免内存泄漏等问题。所以,大部分程序中的内存分配都可以依赖 Arena 来实现,尤其是在需要高效和整洁的内存管理时。

但如果遇到需要随机访问或不规则内存分配的情况,使用通用内存分配器也是完全可以接受的,前提是明确需求和内存模式。

insofaras:在Linux版本上,您打算做?

在Linux版本的开发中,可能不会。主要原因是Linux的支持环境非常不稳定,兼容性差,尤其是在应用程序的兼容性方面。因此,可能会采取与大多数游戏类似的方式,比如使用STL(标准模板库)进行编程,或者直接依赖一些常见的服务和API,因为这样做通常是兼容性最好的选择。

Linux上的应用程序兼容性较差,因此我们需要尽量采用一些已经被广泛使用的技术和方法,尽可能地与其他游戏使用的工具和服务保持一致。这样做可以增加在不同Linux发行版上的稳定性和可靠性,因为这些工具和服务已经被验证过,具有更好的兼容性。

具体来说,可以观察当前大多数游戏使用的API,并尽量采用相同的方式进行编程。比如,研究常见的音频接口,或者其他与硬件、操作系统交互的方式,并尽量使用这些成熟的方案来避免Linux平台的兼容性问题。

题外话,您的代码调试通常这么顺利吗?几乎没有Bug?

编程过程中,虽然一般情况下我能避免大多数错误,但依然不可避免地会遇到一些 bug。很多时候,第一次运行程序时它能接近完美地运行,这在编程经验丰富的人身上比较常见,因为随着编程经验的积累,可以避免一些常见错误,脑海中也能清楚地跟踪需要注意的地方。然而,即使是经验丰富的程序员,程序中依然会有错误,只是这些错误通常比较隐蔽,不像初学者那样容易显现。

对于新手来说,第一次编写代码时,错误率通常较高,因为他们的大脑还没有完全适应如何追踪所有需要关注的细节。而对于有多年编程经验的人来说,错误的发生频率会大大降低,尽管如此,错误仍然会出现,不过这些错误通常是更细微和不那么显眼的。

随着编程经验的积累,做程序的人会在每次写代码时减少错误,代码的质量会逐渐提高。虽然没有人能够做到每次编写的代码都是完全没有错误的,但随着经验的积累,错误变得更加难以察觉和解决。对于新手来说,遇到大量问题并感到挫败是正常的,随着时间的推移,问题会减少,编程过程也会变得更加顺畅。

总结来说,编写代码时会不可避免地犯错,这对任何程序员来说都是常态。经验丰富的程序员虽然能够减少错误,但错误仍然存在,只是变得更加细微和难以察觉。随着编程的持续积累,错误的发生频率会降低,但每次编程时都会有些微小的bug需要解决。

在设计API时,您是先定义接口还是直接实现功能?

在编程中,总是应该先编写使用代码(usage code),而不是直接实现接口。这是我一直坚持的原则。首先,写出使用代码的好处在于,使用代码能够清晰地定义接口(API)的预期行为。通过使用代码,能够明确地描述在实现时需要达到的效果,而这会帮助更好地理解实现过程中的需求。

通常,唯一的例外情况是当完全不了解某个功能如何工作的情况下,可能需要先做一些实验,探索如何构建它。即便如此,一旦明白了如何工作,就应立刻回到常规流程,编写使用代码,并由此定义出初步的接口。这个接口在实现过程中可能会略作调整,通常是因为性能或其他实际限制,使得原本预期的接口无法完全实现,进而需要做些修改。这些修改通常是在实现过程中根据实际情况做出的微调。

但是,无论如何,编写使用代码始终是第一步。这种做法帮助明确了接口的实际需求,并且能够在后续的实现过程中及时发现并调整。可以说,百分之百的时间里都应该遵循这个顺序:先写使用代码,再根据实际情况调整接口。这是一个必须遵守的规则,帮助开发者确保接口设计和实现能够高度一致。

什么是“Usage Code”?

在编程中,**使用代码(usage code)**是指我们首先编写的代码部分,它展示了如何使用某个API来完成实际任务。在设计API时,应该先从“使用代码”开始,而不是直接去定义API本身。这是因为,编写使用代码能够帮助我们更清晰地理解自己到底需要什么功能,也可以确保我们设计出来的API能够很好地服务于实际需求。

举个例子,我们要处理一个文件读取的功能。假设我们有一个资产文件,目标是读取文件的数据并使用它。在开始之前,我们可能并没有定义好API,因此我们先写出使用代码,明确我们需要什么。比如,我们只需要知道如何读取文件中的某些特定部分,而不是一次性把整个文件加载到内存中。此时,我们的API设计就是根据这个需求来决定的。

当我们明确了需求,下一步是设计平台层的API,用来读取文件。此时,我们的使用代码已经定义了我们想要实现的功能。比如,使用代码可能会直接指定:从文件中读取某个特定类型的数据,获取文件头信息,然后再进行处理。这样,平台层的API设计就会基于这些使用需求进行调整,而不是根据我们对API能力的模糊假设。

在编程过程中,不要先去实现一个复杂的API,而是应该从简单的“使用代码”开始。这样做的好处是可以确保你真正理解了需求,避免无效的假设和不必要的复杂设计。同时,通过编写使用代码,我们可以避免设计出一个即复杂又无用的API——因为我们会清楚地知道需要什么,而不是根据不确定的目标去猜测。

如果直接从API定义入手,可能会导致开发者浪费时间实现那些实际并不需要的功能,最终可能会造成不必要的复杂性。而通过先写使用代码,开发者可以确保API的设计简洁、清晰,且真正解决实际问题。比如,在处理文件读取时,设计时会避免过多的细节暴露,专注于实际需求,如获取文件内容而不是关心具体平台上如何实现目录遍历等。

总之,写出使用代码是理解需求和设计合适API的关键步骤,它帮助开发者从实际需求出发,避免了盲目实现复杂功能。很多时候,开发者往往会低估这一步的重要性,而这正是导致许多代码质量不高的根本原因。

您是否认为现在的很多软件代码过于“臃肿”?是否应该精简代码?

许多常见程序的代码通常存在臃肿的问题,包含大量不必要的代码和复杂的结构。这种臃肿的代码不仅增加了程序的体积,还可能影响性能和可维护性。现代程序中,过多的依赖库、框架以及冗长的模块化设计,往往让简单的功能实现变得复杂和庞大。因此,有必要学会如何简化代码,去除多余的部分,专注于实现核心功能。很多时候,代码的简洁性直接影响到程序的执行效率和后续的开发维护工作。通过精简和优化代码,可以提高程序的性能,减少错误的发生,并且提升代码的可读性与可管理性。

通过对比现代代码和早期简洁的代码,可以发现,今天的编程环境更倾向于使用大量工具和库来加快开发过程,然而,这些工具有时也会增加代码的复杂度。为了避免代码过度膨胀,有必要培养编写简洁、高效代码的习惯,保持代码的清晰与精简,这不仅能提高开发效率,也有助于长期的维护与更新。

如果不提前设计API接口,那是否需要很多年的经验才能准确预判?

在开发过程中,很多问题并不需要数十年的经验去解决。通过逐步思考和尝试,很多看似复杂的任务其实可以很简单地处理。例如,在处理一个游戏资产时,我们首先需要了解所有文件,接着加载文件头,然后分配空间来存储所有数据,并加载标签和资产。这个过程看起来很复杂,但实际上,如果从头开始思考,每一步都可以简单化,直到出现问题需要解决。

如果我们开始思考一个问题,但发现自己没有现成的工具来完成某个步骤,就会意识到这就是需要解决的地方。比如,在没有合适方法获取文件列表时,我们就假设自己有一个可以获取文件的工具来处理这一部分,而不去过度思考其细节。这样处理,可以快速进行开发,稍后再对细节进行优化和调整。

当处理文件时,比如需要读取文件头,我们可以假设已有工具能完成这个操作。尽管在开始时,我们并不知道如何具体实现这些操作,但通过简单的假设,可以让开发进程继续推进。工具或框架的功能可以在后期逐步补充和完善,而这并不妨碍初步的开发工作。

整个过程中,重要的是不要在一开始就过度复杂化问题,而是简化目标,逐步实现功能。在面对实现不了的需求时,反而是一个契机,让我们在后续的迭代中进行调整,优化实现方式。

这个过程其实就像是日常生活中的逻辑思考:我们想做什么,如果没有工具去做,就去找或创造工具。而这种思考方式是很直观、简洁的,不需要一开始就对所有的细节做出深度思考。通过这种方式,开发者可以快速实现一个大概的框架,并在后期根据实际情况逐步细化和优化。

所以您的意思是——就像给怪兽做鞋子时,不假设它有几只脚,而是先观察它留下的脚印,然后再设计鞋子?

在开发过程中,类似的思维方式是非常有用的。举个例子,就像在为怪物做鞋子时,开始时并不会假设怪物有多少条腿或者多少个脚趾。相反,我们首先让怪物四处走动,然后根据它留下的足迹来制作鞋子。这种方法意味着,我们不需要一开始就知道所有细节,关键是根据实际情况逐步调整。

换句话说,在开始开发一个系统时,我们不必先假设所有的具体需求和条件,而是通过实际的操作或测试,观察结果,进而根据这些“足迹”去调整和完善我们的设计。这样的思维方式能帮助避免过度预设的复杂性,也使得开发过程更加灵活和高效。

建议您把这种开发风格称作“Snuffy-Oriented Programming”(松散驱动编程)。

在开发过程中,提到了一种新的编程方式,称为“snuffle-oriented programming”(SNOP,或称作“snuffle analogous-oriented programming”)。这种方式强调在编写代码时,首先考虑的是用户代码的需求,而不是一开始就过度关心底层的实现。具体来说,就是类似于在为怪物制作鞋子时,首先不去猜测怪物有多少条腿、多少个脚趾,而是先让怪物走动,根据它留下的足迹来设计和制作鞋子。这个过程就是根据需求来逐步开发,而不是一开始就假设所有细节。

这种方式的核心是“用户代码优先”,即在开始编写代码时,先思考用户想要实现的功能,先写出高层次的代码框架,之后再根据需要细化和完善实现。通过这种方式,可以避免过度复杂的底层设计,使得代码更具灵活性和可扩展性。每次编写代码时,都可以从这个思路出发,思考如何让代码更简洁、更高效,而不是一开始就过度优化。

此外,学习过程中并不是一次性掌握所有的内容,而是通过反复学习和思考来巩固知识。在开发过程中,很多时候通过不断反思和调整,能更好地理解问题和解决问题。每次碰到问题时,重新审视需求并进行改进,能有效避免因过于复杂的实现而导致的错误和挫折。

总的来说,这种编程方法倡导在开发时保持简洁、灵活,并随时根据需求调整和优化设计,避免一开始就陷入过度设计和复杂实现的陷阱。这种思维方式对于提高代码质量和开发效率非常有帮助。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2314480.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

考研数学非数竞赛复习之Stolz定理求解数列极限

在非数类大学生数学竞赛中&#xff0c;Stolz定理作为一种强大的工具&#xff0c;经常被用来解决和式数列极限的问题&#xff0c;也被誉为离散版的’洛必达’方法&#xff0c;它提供了一种简洁而有效的方法&#xff0c;使得原本复杂繁琐的极限计算过程变得直观明了。本文&#x…

故障诊断——neo4j入门

文章目录 neo4jQuickStartDemo neo4j QuickStart 详情可见博客&#xff1a;https://www.cnblogs.com/nhdlb/p/18703804&#xff0c;使用docker拉取最近的一个版本进行创建 docker run -it -d -p 7474:7474 -p 7687:7687 \ -v /disk5/neo4j_docker/data:/data \ -v /disk5/ne…

【JavaWeb】快速入门——HTMLCSS

文章目录 一、 HTML简介1、HTML概念2、HTML文件结构3、可视化网页结构 二、 HTML标签语法1、标题标签2、段落标签3、超链接4、换行5、无序列表6、路径7、图片8、块1 盒子模型2 布局标签 三、 使用HTML表格展示数据1、定义表格2、合并单元格横向合并纵向合并 四、 使用HTML表单收…

若依框架-给sys_user表添加新字段并获取当前登录用户的该字段值

目录 添加字段 修改SysUser类 修改SysUserMapper.xml 修改user.js 前端获取字段值 添加字段 若依框架的sys_user表是没有age字段的&#xff0c;但由于业务需求&#xff0c;我需要新添加一个age字段&#xff1a; 修改SysUser类 添加age字段后&#xff0c;要在SysUser类 …

前端监测窗口尺寸和元素尺寸变化的方法

前端监测窗口尺寸变化和元素尺寸变化的方法 window.resize 简介 window.resize事件是浏览器提供的一种事件&#xff0c;用于监听窗口大小的改变。这意味着当用户调整浏览器窗口大小时&#xff0c;相关的JavaScript代码将被触发执行。这为开发者提供了一种机制&#xff0c;可…

ubuntu 部署deepseek

更新 apt update 升级 apt upgrade 格式化硬盘 mkfs.ext4 /dev/sdb 安装nginx 查看端口 一、安装Ollama Ollama是一个开源的大型语言模型&#xff08;LLM&#xff09;推理服务器&#xff0c;为用户提供了灵活、安全和高性能的语言模型推理解决方案。 ollama/docs/linux.m…

MySQL库和表的操作详解:从创建库到表的管理全面指南

目录 一、MySQL库的操作详解 〇、登录MySQL 一、数据库的创建与字符集设置 1. 创建数据库的语法 2. 创建数据库示例 查看创建出来的文件: bash下查看MySQL创建的文件 二、字符集与校验规则 1. 查看系统默认设置 2. 查看支持的字符集与校验规则 3. 校验规则对查询的影响…

PyTorch 系列教程:使用CNN实现图像分类

图像分类是计算机视觉领域的一项基本任务&#xff0c;也是深度学习技术的一个常见应用。近年来&#xff0c;卷积神经网络&#xff08;cnn&#xff09;和PyTorch库的结合由于其易用性和鲁棒性已经成为执行图像分类的流行选择。 理解卷积神经网络&#xff08;cnn&#xff09; 卷…

Java 大视界 -- Java 大数据中的数据可视化大屏设计与开发实战(127)

&#x1f496;亲爱的朋友们&#xff0c;热烈欢迎来到 青云交的博客&#xff01;能与诸位在此相逢&#xff0c;我倍感荣幸。在这飞速更迭的时代&#xff0c;我们都渴望一方心灵净土&#xff0c;而 我的博客 正是这样温暖的所在。这里为你呈上趣味与实用兼具的知识&#xff0c;也…

「Unity3D」UGUI将元素固定在,距离屏幕边缘的某个比例,以及保持元素自身比例

在不同分辨率的屏幕下&#xff0c;UI元素按照自身像素大小&#xff0c;会发生位置与比例的变化&#xff0c;本文仅利用锚点&#xff08;Anchors&#xff09;使用&#xff0c;来实现UI元素&#xff0c;固定在某个比例距离的屏幕边缘。 首先&#xff0c;将元素的锚点设置为中心&…

Deep research深度研究:ChatGPT/ Gemini/ Perplexity/ Grok哪家最强?(实测对比分析)

目前推出深度研究和深度检索的AI大模型有四家&#xff1a; OpenAI和Gemini 的deep research&#xff0c;以及Perplexity 和Grok的deep search&#xff0c;都能生成带参考文献引用的主题报告。 致力于“几分钟之内生成一份完整的主题调研报告&#xff0c;解决人力几小时甚至几天…

关于sqlalchemy的ORM的使用

关于sqlalchemy的ORM的使用 二、创建表三、使用数据表、查询记录三、批量插入数据四、关于with...as...:的使用 二、创建表 使用Mapped来映射字段 from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker,Mapped,mapped_columnBa…

【leetcode hot 100 148】排序序列

解法一&#xff1a;&#xff08;双重循环&#xff09;第一个循环head&#xff0c;逐步将head的node加入有序列表&#xff1b;第二个循环在有序列表中找到合适的位置&#xff0c;插入node。 /*** Definition for singly-linked list.* public class ListNode {* int val;* …

【Linux】在VMWare中安装Ubuntu操作系统(2025最新_Ubuntu 24.04.2)#VMware安装Ubuntu实战分享#

今天田辛老师为大家带来一篇关于在VMWare虚拟机上安装Ubuntu系统的详细教程。无论是学习、开发还是测试&#xff0c;虚拟机都是一个非常实用的工具&#xff0c;它允许我们在同一台物理机上运行多个操作系统。Ubuntu作为一款开源、免费且用户友好的Linux发行版&#xff0c;深受广…

AutoGen学习笔记系列(十三)Advanced - Logging

这篇文章瞄的是AutoGen官方教学文档 Advanced 章节中的 Logging 篇章&#xff0c;介绍了怎样在使用过程中添加日志信息&#xff0c;其实就是使用了python自带的日志库 logging。 官网链接&#xff1a;https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-g…

scrcpy pc机远程 无线 控制android app 查看调试log

背景&#xff1a; 公司的安卓机&#xff0c;是那种大屏幕的连接usb外设的。不好挪动&#xff0c;占地方&#xff0c;不能直接连接pc机上的android stduio来调试。 所以从网上找了一个python adb.exe控制器&#xff0c;可以局域网内远程控制开发的app,并在android stduio上看…

UE5.5 Niagara发射器更新属性

发射器属性 在 Niagara 里&#xff0c;Emitter 负责控制粒子生成的规则和行为。不同的 Emitter 属性决定了如何发射粒子、粒子如何模拟、计算方式等。 发射器 本地空间&#xff08;Local Space&#xff09; 控制粒子是否跟随发射器&#xff08;Emitter&#xff09;移动。 ✅…

MongoDB备份与还原

备份恢复工具介绍 1&#xff09;mongoexport/mongoimport 2&#xff09;mongodump/mongorestore 备份工具区别 mongoexport/mongoimport 导入/导出的是JSON格式或者CSV格式 mongodump/mongorestore 导入/导出的是BSON格式。二进制方式&#xff0c;速度快 1&#xff09;…

计算机:基于深度学习的Web应用安全漏洞检测与扫描

目录 前言 课题背景和意义 实现技术思路 一、算法理论基础 1.1 网络爬虫 1.2 漏洞检测 二、 数据集 三、实验及结果分析 3.1 实验环境搭建 3.2 模型训练 最后 前言 &#x1f4c5;大四是整个大学期间最忙碌的时光,一边要忙着备考或实习为毕业后面临的就业升学做准备,…

Java 大视界 -- Java 大数据在智能安防视频摘要与检索技术中的应用(128)

&#x1f496;亲爱的朋友们&#xff0c;热烈欢迎来到 青云交的博客&#xff01;能与诸位在此相逢&#xff0c;我倍感荣幸。在这飞速更迭的时代&#xff0c;我们都渴望一方心灵净土&#xff0c;而 我的博客 正是这样温暖的所在。这里为你呈上趣味与实用兼具的知识&#xff0c;也…