Skip to Content
Course content

200: FileStream In Depth

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Listen, you've probably used File.ReadAllText or File.WriteAllLines by now. Those are great for quick config files or small logs, but they're "all-or-nothing" operations. They load the entire file into your RAM. If you're dealing with a 2GB game save file or a massive binary database, your app is going to crash before it even finishes reading the header.

I've got a binary file here called player.dat. It's a mess of bytes, but I know from the documentation that the player's "Experience Points" are stored as a 4-byte integer starting exactly at the 12th byte of the file. I want to read just those 4 bytes, update them, and save them back without touching the rest of the file.

The "Load Everything" Trap

My first instinct—the lazy one—is usually just to grab the whole byte array. Let's see what happens when I try to do this the simple way:

byte[] data = File.ReadAllBytes("player.dat");
// I know XP is at index 12
int xp = BitConverter.ToInt32(data, 12);
Console.WriteLine($"Current XP: {xp}");

This works. For a small file, it's fine. But here's the problem: if I want to change that XP value, I have to write the entire array back to disk using File.WriteAllBytes. I'm rewriting 100MB of data just to change 4 bytes. That's a waste of IO and an invitation for file corruption if the power cuts out mid-write. We need a scalpel, not a sledgehammer.

Slicing into the Binary

This is where FileStream comes in. It doesn't load the file; it opens a "pipe" to it. I can move a cursor (the position) to exactly where I want to be and read only what I need.

Let's try opening the stream and reading that XP value:

using (FileStream fs = new FileStream("player.dat", FileMode.Open, FileAccess.Read))
{
    byte[] buffer = new byte[4];
    fs.Position = 12; // Jump straight to the XP offset
    fs.Read(buffer, 0, 4);
    int xp = BitConverter.ToInt32(buffer, 0);
    Console.WriteLine($"XP found at position 12: {xp}");
}

Notice the fs.Position = 12. I'm telling the OS, "Don't bother with the first 11 bytes, just put the cursor here." The Read method then pulls the next 4 bytes into my buffer and advances the cursor to position 16. It's incredibly efficient because the memory footprint is just 4 bytes, regardless of how huge the file is.

Jumping and Overwriting

Now, let's say we want to give the player a bonus. I don't want to rewrite the file; I just want to overwrite those 4 bytes. To do that, I need to change my FileAccess and FileMode.

I'll try this, but I have to be careful. If I use FileMode.Create, I'll wipe the whole file. I need FileMode.Open.

using (FileStream fs = new FileStream("player.dat", FileMode.Open, FileAccess.ReadWrite))
{
    // Move to the XP spot
    fs.Position = 12;

    int newXp = 5000;
    byte[] xpBytes = BitConverter.GetBytes(newXp);

    // Write only the 4 bytes for XP
    fs.Write(xpBytes, 0, xpBytes.Length);
}

I just performed a "surgical strike" on the file. I opened it, jumped to byte 12, swapped the data, and closed it. The rest of the file remains untouched. One thing I've learned the hard way: always use that using block. If you don't dispose of the FileStream, the OS keeps a lock on the file. I've spent far too many hours wondering why I couldn't delete a file in Windows, only to realize my debug session was still holding the stream open.

Dealing with the Buffer

What if I don't know exactly where the data is? What if I'm searching for a "Magic Marker" (a specific sequence of bytes) that signals the start of a data block? I can't just jump; I have to scan.

I'll try reading in chunks. Reading one byte at a time is slow because every Read call is a request to the operating system. Instead, I'll use a larger buffer to bring a "chunk" of the file into memory, scan it, and move on.

using (FileStream fs = new FileStream("player.dat", FileMode.Open, FileAccess.Read))
{
    byte[] buffer = new byte[4096]; // Read 4KB at a time
    int bytesRead;
    while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
    {
        // Search through the buffer for my marker (e.g., 0xAA 0xBB)
        for (int i = 0; i < bytesRead - 1; i++)
        {
            if (buffer[i] == 0xAA && buffer[i+1] == 0xBB)
            {
                Console.WriteLine($"Marker found at position: {fs.Position - bytesRead + i}");
            }
        }
    }
}

By reading 4KB blocks, I'm reducing the number of system calls significantly. This is the core of how high-performance file parsing works: minimize the trips to the disk, maximize the work you do with the data once it's in your RAM.




📋 Practical Task

Exercise: The Binary Header Patch Tool

You are tasked with creating a tool that modifies the "Version Number" of a custom binary file. The file format specifies that the version is a 2-byte integer (UInt16) located at the very beginning of the file (offset 0). However, the file also has a "Checksum" byte at the very end of the file that must be incremented by 1 every time the version is changed.

Your Task: Write a program that does the following:

  • Opens a file named app.bin (you can create this file manually with any random bytes first).
  • Reads the first 2 bytes to display the current version.
  • Updates the first 2 bytes to a new version number (e.g., 2).
  • Seeks to the very last byte of the file.
  • Reads that last byte, increments its value by 1, and writes it back.
  • Ensures the stream is properly closed, even if an error occurs during the process.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.