Task 488: .OSR File Format

Task 488: .OSR File Format

File Format Specifications for .OSR

The .OSR file format is used by the rhythm game osu! to store replay data. It is a binary format with a header containing fixed and variable-length fields, followed by LZMA-compressed replay action data, and an optional trailing field. All multi-byte integers are stored in little-endian order. Strings use a special format: a byte (0x00 for empty, 0x0b for present), followed by a ULEB128 length and UTF-8 encoded string if present. The replay data, when decompressed, is a CSV-like text stream of actions in the format w|x|y|z, with an optional trailing RNG seed frame for newer versions.

List of all properties (fields) intrinsic to the file format:

  • Game mode (byte): 0 = osu!, 1 = osu!taiko, 2 = osu!catch, 3 = osu!mania.
  • Game version (int): The osu! version when the replay was created (e.g., 20131216).
  • Beatmap MD5 hash (string): MD5 hash of the beatmap file.
  • Player name (string): Name of the player who set the replay.
  • Replay MD5 hash (string): MD5 hash of replay properties for verification.
  • Number of 300s (short): Count of perfect hits (300s).
  • Number of 100s (short): Count of 100s (or equivalents in other modes).
  • Number of 50s (short): Count of 50s (or equivalents).
  • Number of Gekis (short): Count of Gekis (or Max 300s in mania).
  • Number of Katus (short): Count of Katus (or 200s in mania).
  • Number of misses (short): Count of misses.
  • Total score (int): Score displayed on the report.
  • Max combo (short): Greatest combo displayed.
  • Perfect/full combo (byte): 1 if perfect (no misses/slider breaks), 0 otherwise.
  • Mods used (int): Bitwise flags for mods (e.g., 64 for DoubleTime).
  • Life bar graph (string): Comma-separated time|health pairs (time in ms, health 0-1 float).
  • Timestamp (long): Windows ticks since 0001-01-01.
  • Compressed replay length (int): Byte length of the LZMA-compressed replay data.
  • Compressed replay data (byte array): LZMA-compressed stream of actions (decompresses to text like w|x|y|z per frame, where w=delta time ms (long), x=cursor X (float), y=cursor Y (float), z=keys pressed (int bitwise)).
  • Online score ID (long): ID for online leaderboard.
  • Additional mod info (double, optional): Present if Target Practice mod is used; stores total accuracy.

Two direct download links for .OSR files:

Ghost blog embedded HTML/JavaScript for drag-and-drop .OSR file dump:

<div id="drop-area" style="border: 2px dashed #ccc; padding: 20px; text-align: center;">
  Drop .OSR file here
</div>
<div id="output"></div>
<script>
  const dropArea = document.getElementById('drop-area');
  const output = document.getElementById('output');

  dropArea.addEventListener('dragover', (e) => { e.preventDefault(); dropArea.style.borderColor = '#000'; });
  dropArea.addEventListener('dragleave', () => { dropArea.style.borderColor = '#ccc'; });
  dropArea.addEventListener('drop', (e) => {
    e.preventDefault(); dropArea.style.borderColor = '#ccc';
    const file = e.dataTransfer.files[0];
    if (file.name.endsWith('.osr')) parseOSR(file);
    else output.innerHTML = 'Invalid file type.';
  });

  function parseOSR(file) {
    const reader = new FileReader();
    reader.onload = (e) => {
      const buffer = e.target.result;
      const view = new DataView(buffer);
      let offset = 0;

      const properties = {};

      properties.gameMode = view.getUint8(offset); offset += 1;
      properties.gameVersion = view.getInt32(offset, true); offset += 4;
      properties.beatmapMD5 = readString(view, offset); offset = properties.beatmapMD5.nextOffset;
      properties.playerName = readString(view, offset); offset = properties.playerName.nextOffset;
      properties.replayMD5 = readString(view, offset); offset = properties.replayMD5.nextOffset;
      properties.num300s = view.getUint16(offset, true); offset += 2;
      properties.num100s = view.getUint16(offset, true); offset += 2;
      properties.num50s = view.getUint16(offset, true); offset += 2;
      properties.numGekis = view.getUint16(offset, true); offset += 2;
      properties.numKatus = view.getUint16(offset, true); offset += 2;
      properties.numMisses = view.getUint16(offset, true); offset += 2;
      properties.totalScore = view.getInt32(offset, true); offset += 4;
      properties.maxCombo = view.getUint16(offset, true); offset += 2;
      properties.perfect = view.getUint8(offset); offset += 1;
      properties.mods = view.getInt32(offset, true); offset += 4;
      properties.lifeBarGraph = readString(view, offset); offset = properties.lifeBarGraph.nextOffset;
      properties.timestamp = Number(view.getBigInt64(offset, true)); offset += 8;
      properties.compressedLength = view.getInt32(offset, true); offset += 4;
      properties.compressedData = buffer.slice(offset, offset + properties.compressedLength); offset += properties.compressedLength;
      properties.onlineScoreID = Number(view.getBigInt64(offset, true)); offset += 8;
      // Optional double if Target Practice mod (bit 23 set)
      if (properties.mods & (1 << 23)) {
        properties.additionalModInfo = view.getFloat64(offset, true); offset += 8;
      }

      let html = '<h3>.OSR Properties:</h3><ul>';
      for (const [key, value] of Object.entries(properties)) {
        html += `<li><strong>${key}:</strong> ${typeof value === 'object' ? 'Binary data' : value}</li>`;
      }
      html += '</ul>';
      output.innerHTML = html;
    };
    reader.readAsArrayBuffer(file);
  }

  function readString(view, offset) {
    if (view.getUint8(offset) === 0x00) return { value: '', nextOffset: offset + 1 };
    offset += 1;
    let length = 0, shift = 0;
    while (true) {
      const byte = view.getUint8(offset++);
      length += (byte & 0x7F) << shift;
      if ((byte & 0x80) === 0) break;
      shift += 7;
    }
    const value = new TextDecoder('utf-8').decode(new Uint8Array(view.buffer, offset, length));
    return { value, nextOffset: offset + length };
  }
</script>
  1. Python class for .OSR handling:
import struct
import lzma
from io import BytesIO

class OSRFile:
    def __init__(self, filepath=None):
        self.properties = {}
        if filepath:
            self.read(filepath)

    def read(self, filepath):
        with open(filepath, 'rb') as f:
            data = f.read()
        view = BytesIO(data)
        self.properties['gameMode'] = struct.unpack('<B', view.read(1))[0]
        self.properties['gameVersion'] = struct.unpack('<i', view.read(4))[0]
        self.properties['beatmapMD5'] = self._read_string(view)
        self.properties['playerName'] = self._read_string(view)
        self.properties['replayMD5'] = self._read_string(view)
        self.properties['num300s'] = struct.unpack('<H', view.read(2))[0]
        self.properties['num100s'] = struct.unpack('<H', view.read(2))[0]
        self.properties['num50s'] = struct.unpack('<H', view.read(2))[0]
        self.properties['numGekis'] = struct.unpack('<H', view.read(2))[0]
        self.properties['numKatus'] = struct.unpack('<H', view.read(2))[0]
        self.properties['numMisses'] = struct.unpack('<H', view.read(2))[0]
        self.properties['totalScore'] = struct.unpack('<i', view.read(4))[0]
        self.properties['maxCombo'] = struct.unpack('<H', view.read(2))[0]
        self.properties['perfect'] = struct.unpack('<B', view.read(1))[0]
        self.properties['mods'] = struct.unpack('<i', view.read(4))[0]
        self.properties['lifeBarGraph'] = self._read_string(view)
        self.properties['timestamp'] = struct.unpack('<q', view.read(8))[0]
        compressed_length = struct.unpack('<i', view.read(4))[0]
        self.properties['compressedData'] = view.read(compressed_length)
        self.properties['onlineScoreID'] = struct.unpack('<q', view.read(8))[0]
        if self.properties['mods'] & (1 << 23):
            self.properties['additionalModInfo'] = struct.unpack('<d', view.read(8))[0]

    def _read_string(self, view):
        flag = struct.unpack('<B', view.read(1))[0]
        if flag == 0x00:
            return ''
        elif flag == 0x0b:
            length = 0
            shift = 0
            while True:
                byte = struct.unpack('<B', view.read(1))[0]
                length += (byte & 0x7f) << shift
                shift += 7
                if not (byte & 0x80):
                    break
            return view.read(length).decode('utf-8')
        return ''

    def print_properties(self):
        for key, value in self.properties.items():
            if key == 'compressedData':
                print(f"{key}: Binary data (length {len(value)})")
            else:
                print(f"{key}: {value}")

    def write(self, filepath):
        with open(filepath, 'wb') as f:
            f.write(struct.pack('<B', self.properties.get('gameMode', 0)))
            f.write(struct.pack('<i', self.properties.get('gameVersion', 0)))
            self._write_string(f, self.properties.get('beatmapMD5', ''))
            self._write_string(f, self.properties.get('playerName', ''))
            self._write_string(f, self.properties.get('replayMD5', ''))
            f.write(struct.pack('<H', self.properties.get('num300s', 0)))
            f.write(struct.pack('<H', self.properties.get('num100s', 0)))
            f.write(struct.pack('<H', self.properties.get('num50s', 0)))
            f.write(struct.pack('<H', self.properties.get('numGekis', 0)))
            f.write(struct.pack('<H', self.properties.get('numKatus', 0)))
            f.write(struct.pack('<H', self.properties.get('numMisses', 0)))
            f.write(struct.pack('<i', self.properties.get('totalScore', 0)))
            f.write(struct.pack('<H', self.properties.get('maxCombo', 0)))
            f.write(struct.pack('<B', self.properties.get('perfect', 0)))
            f.write(struct.pack('<i', self.properties.get('mods', 0)))
            self._write_string(f, self.properties.get('lifeBarGraph', ''))
            f.write(struct.pack('<q', self.properties.get('timestamp', 0)))
            compressed_data = self.properties.get('compressedData', b'')
            f.write(struct.pack('<i', len(compressed_data)))
            f.write(compressed_data)
            f.write(struct.pack('<q', self.properties.get('onlineScoreID', 0)))
            if self.properties.get('mods', 0) & (1 << 23):
                f.write(struct.pack('<d', self.properties.get('additionalModInfo', 0.0)))

    def _write_string(self, f, s):
        if not s:
            f.write(struct.pack('<B', 0x00))
            return
        f.write(struct.pack('<B', 0x0b))
        length = len(s.encode('utf-8'))
        while length > 0:
            byte = length & 0x7f
            length >>= 7
            if length > 0:
                byte |= 0x80
            f.write(struct.pack('<B', byte))
        f.write(s.encode('utf-8'))
  1. Java class for .OSR handling:
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;

public class OSRFile {
    private byte gameMode;
    private int gameVersion;
    private String beatmapMD5;
    private String playerName;
    private String replayMD5;
    private short num300s;
    private short num100s;
    private short num50s;
    private short numGekis;
    private short numKatus;
    private short numMisses;
    private int totalScore;
    private short maxCombo;
    private byte perfect;
    private int mods;
    private String lifeBarGraph;
    private long timestamp;
    private int compressedLength;
    private byte[] compressedData;
    private long onlineScoreID;
    private double additionalModInfo; // Optional

    public OSRFile(String filepath) throws IOException {
        read(filepath);
    }

    public OSRFile() {}

    private String readString(DataInputStream dis) throws IOException {
        byte flag = dis.readByte();
        if (flag == 0x00) return "";
        if (flag != 0x0b) throw new IOException("Invalid string flag");
        int length = 0;
        int shift = 0;
        while (true) {
            byte byteVal = dis.readByte();
            length += (byteVal & 0x7F) << shift;
            if ((byteVal & 0x80) == 0) break;
            shift += 7;
        }
        byte[] bytes = new byte[length];
        dis.readFully(bytes);
        return new String(bytes, "UTF-8");
    }

    private void writeString(DataOutputStream dos, String s) throws IOException {
        if (s.isEmpty()) {
            dos.writeByte(0x00);
            return;
        }
        dos.writeByte(0x0b);
        byte[] bytes = s.getBytes("UTF-8");
        int length = bytes.length;
        while (length > 0) {
            int byteVal = length & 0x7f;
            length >>= 7;
            if (length > 0) byteVal |= 0x80;
            dos.writeByte(byteVal);
        }
        dos.write(bytes);
    }

    public void read(String filepath) throws IOException {
        try (FileInputStream fis = new FileInputStream(filepath);
             DataInputStream dis = new DataInputStream(fis)) {
            gameMode = dis.readByte();
            gameVersion = Integer.reverseBytes(dis.readInt());
            beatmapMD5 = readString(dis);
            playerName = readString(dis);
            replayMD5 = readString(dis);
            num300s = Short.reverseBytes(dis.readShort());
            num100s = Short.reverseBytes(dis.readShort());
            num50s = Short.reverseBytes(dis.readShort());
            numGekis = Short.reverseBytes(dis.readShort());
            numKatus = Short.reverseBytes(dis.readShort());
            numMisses = Short.reverseBytes(dis.readShort());
            totalScore = Integer.reverseBytes(dis.readInt());
            maxCombo = Short.reverseBytes(dis.readShort());
            perfect = dis.readByte();
            mods = Integer.reverseBytes(dis.readInt());
            lifeBarGraph = readString(dis);
            timestamp = Long.reverseBytes(dis.readLong());
            compressedLength = Integer.reverseBytes(dis.readInt());
            compressedData = new byte[compressedLength];
            dis.readFully(compressedData);
            onlineScoreID = Long.reverseBytes(dis.readLong());
            if ((mods & (1 << 23)) != 0) {
                ByteBuffer bb = ByteBuffer.allocate(8);
                bb.order(ByteOrder.LITTLE_ENDIAN);
                bb.putDouble(dis.readDouble());
                additionalModInfo = bb.getDouble(0);
            }
        }
    }

    public void printProperties() {
        System.out.println("gameMode: " + gameMode);
        System.out.println("gameVersion: " + gameVersion);
        System.out.println("beatmapMD5: " + beatmapMD5);
        System.out.println("playerName: " + playerName);
        System.out.println("replayMD5: " + replayMD5);
        System.out.println("num300s: " + num300s);
        System.out.println("num100s: " + num100s);
        System.out.println("num50s: " + num50s);
        System.out.println("numGekis: " + numGekis);
        System.out.println("numKatus: " + numKatus);
        System.out.println("numMisses: " + numMisses);
        System.out.println("totalScore: " + totalScore);
        System.out.println("maxCombo: " + maxCombo);
        System.out.println("perfect: " + perfect);
        System.out.println("mods: " + mods);
        System.out.println("lifeBarGraph: " + lifeBarGraph);
        System.out.println("timestamp: " + timestamp);
        System.out.println("compressedLength: " + compressedLength);
        System.out.println("compressedData: Binary data (length " + compressedLength + ")");
        System.out.println("onlineScoreID: " + onlineScoreID);
        if ((mods & (1 << 23)) != 0) {
            System.out.println("additionalModInfo: " + additionalModInfo);
        }
    }

    public void write(String filepath) throws IOException {
        try (FileOutputStream fos = new FileOutputStream(filepath);
             DataOutputStream dos = new DataOutputStream(fos)) {
            dos.writeByte(gameMode);
            dos.writeInt(Integer.reverseBytes(gameVersion));
            writeString(dos, beatmapMD5);
            writeString(dos, playerName);
            writeString(dos, replayMD5);
            dos.writeShort(Short.reverseBytes(num300s));
            dos.writeShort(Short.reverseBytes(num100s));
            dos.writeShort(Short.reverseBytes(num50s));
            dos.writeShort(Short.reverseBytes(numGekis));
            dos.writeShort(Short.reverseBytes(numKatus));
            dos.writeShort(Short.reverseBytes(numMisses));
            dos.writeInt(Integer.reverseBytes(totalScore));
            dos.writeShort(Short.reverseBytes(maxCombo));
            dos.writeByte(perfect);
            dos.writeInt(Integer.reverseBytes(mods));
            writeString(dos, lifeBarGraph);
            dos.writeLong(Long.reverseBytes(timestamp));
            dos.writeInt(Integer.reverseBytes(compressedLength));
            dos.write(compressedData);
            dos.writeLong(Long.reverseBytes(onlineScoreID));
            if ((mods & (1 << 23)) != 0) {
                dos.writeDouble(Double.longBitsToDouble(Long.reverseBytes(Double.doubleToLongBits(additionalModInfo))));
            }
        }
    }
}
  1. JavaScript class for .OSR handling:
class OSRFile {
  constructor(filepath = null) {
    this.properties = {};
    if (filepath) this.read(filepath);
  }

  async read(filepath) {
    const buffer = await fetch(filepath).then(res => res.arrayBuffer());
    const view = new DataView(buffer);
    let offset = 0;

    this.properties.gameMode = view.getUint8(offset); offset += 1;
    this.properties.gameVersion = view.getInt32(offset, true); offset += 4;
    const beatmapRes = this._readString(view, offset); this.properties.beatmapMD5 = beatmapRes.value; offset = beatmapRes.nextOffset;
    const playerRes = this._readString(view, offset); this.properties.playerName = playerRes.value; offset = playerRes.nextOffset;
    const replayRes = this._readString(view, offset); this.properties.replayMD5 = replayRes.value; offset = replayRes.nextOffset;
    this.properties.num300s = view.getUint16(offset, true); offset += 2;
    this.properties.num100s = view.getUint16(offset, true); offset += 2;
    this.properties.num50s = view.getUint16(offset, true); offset += 2;
    this.properties.numGekis = view.getUint16(offset, true); offset += 2;
    this.properties.numKatus = view.getUint16(offset, true); offset += 2;
    this.properties.numMisses = view.getUint16(offset, true); offset += 2;
    this.properties.totalScore = view.getInt32(offset, true); offset += 4;
    this.properties.maxCombo = view.getUint16(offset, true); offset += 2;
    this.properties.perfect = view.getUint8(offset); offset += 1;
    this.properties.mods = view.getInt32(offset, true); offset += 4;
    const lifeRes = this._readString(view, offset); this.properties.lifeBarGraph = lifeRes.value; offset = lifeRes.nextOffset;
    this.properties.timestamp = Number(view.getBigInt64(offset, true)); offset += 8;
    this.properties.compressedLength = view.getInt32(offset, true); offset += 4;
    this.properties.compressedData = buffer.slice(offset, offset + this.properties.compressedLength); offset += this.properties.compressedLength;
    this.properties.onlineScoreID = Number(view.getBigInt64(offset, true)); offset += 8;
    if (this.properties.mods & (1 << 23)) {
      this.properties.additionalModInfo = view.getFloat64(offset, true); offset += 8;
    }
  }

  _readString(view, offset) {
    const flag = view.getUint8(offset++);
    if (flag === 0x00) return { value: '', nextOffset: offset };
    if (flag !== 0x0b) throw new Error('Invalid string flag');
    let length = 0, shift = 0;
    while (true) {
      const byte = view.getUint8(offset++);
      length += (byte & 0x7f) << shift;
      if ((byte & 0x80) === 0) break;
      shift += 7;
    }
    const decoder = new TextDecoder('utf-8');
    const value = decoder.decode(new Uint8Array(view.buffer, offset, length));
    return { value, nextOffset: offset + length };
  }

  printProperties() {
    for (const [key, value] of Object.entries(this.properties)) {
      console.log(`${key}: ${key === 'compressedData' ? `Binary data (length ${value.byteLength})` : value}`);
    }
  }

  write(filepath) {
    // Writing in JS typically requires Node.js or blob download; here's a blob example for browser
    const buffer = new ArrayBuffer(1024 * 1024); // Estimate size
    const view = new DataView(buffer);
    let offset = 0;

    view.setUint8(offset, this.properties.gameMode || 0); offset += 1;
    view.setInt32(offset, this.properties.gameVersion || 0, true); offset += 4;
    offset = this._writeString(view, offset, this.properties.beatmapMD5 || '');
    offset = this._writeString(view, offset, this.properties.playerName || '');
    offset = this._writeString(view, offset, this.properties.replayMD5 || '');
    view.setUint16(offset, this.properties.num300s || 0, true); offset += 2;
    view.setUint16(offset, this.properties.num100s || 0, true); offset += 2;
    view.setUint16(offset, this.properties.num50s || 0, true); offset += 2;
    view.setUint16(offset, this.properties.numGekis || 0, true); offset += 2;
    view.setUint16(offset, this.properties.numKatus || 0, true); offset += 2;
    view.setUint16(offset, this.properties.numMisses || 0, true); offset += 2;
    view.setInt32(offset, this.properties.totalScore || 0, true); offset += 4;
    view.setUint16(offset, this.properties.maxCombo || 0, true); offset += 2;
    view.setUint8(offset, this.properties.perfect || 0); offset += 1;
    view.setInt32(offset, this.properties.mods || 0, true); offset += 4;
    offset = this._writeString(view, offset, this.properties.lifeBarGraph || '');
    view.setBigInt64(offset, BigInt(this.properties.timestamp || 0), true); offset += 8;
    const compressedData = this.properties.compressedData || new ArrayBuffer(0);
    view.setInt32(offset, compressedData.byteLength, true); offset += 4;
    new Uint8Array(buffer, offset, compressedData.byteLength).set(new Uint8Array(compressedData)); offset += compressedData.byteLength;
    view.setBigInt64(offset, BigInt(this.properties.onlineScoreID || 0), true); offset += 8;
    if (this.properties.mods & (1 << 23)) {
      view.setFloat64(offset, this.properties.additionalModInfo || 0, true); offset += 8;
    }

    const blob = new Blob([buffer.slice(0, offset)]);
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filepath || 'output.osr';
    a.click();
    URL.revokeObjectURL(url);
  }

  _writeString(view, offset, s) {
    if (!s) {
      view.setUint8(offset, 0x00);
      return offset + 1;
    }
    view.setUint8(offset, 0x0b); offset += 1;
    const encoder = new TextEncoder();
    const bytes = encoder.encode(s);
    let length = bytes.length;
    while (length > 0) {
      let byte = length & 0x7f;
      length >>= 7;
      if (length > 0) byte |= 0x80;
      view.setUint8(offset, byte); offset += 1;
    }
    new Uint8Array(view.buffer, offset, bytes.length).set(bytes); offset += bytes.length;
    return offset;
  }
}
  1. C class (using C++ for class support):
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
#include <cstdint>
#include <endian.h> // For little-endian conversions if needed

class OSRFile {
private:
    uint8_t gameMode;
    int32_t gameVersion;
    std::string beatmapMD5;
    std::string playerName;
    std::string replayMD5;
    uint16_t num300s;
    uint16_t num100s;
    uint16_t num50s;
    uint16_t numGekis;
    uint16_t numKatus;
    uint16_t numMisses;
    int32_t totalScore;
    uint16_t maxCombo;
    uint8_t perfect;
    int32_t mods;
    std::string lifeBarGraph;
    int64_t timestamp;
    int32_t compressedLength;
    std::vector<uint8_t> compressedData;
    int64_t onlineScoreID;
    double additionalModInfo; // Optional

    std::string readString(std::ifstream& ifs) {
        uint8_t flag;
        ifs.read(reinterpret_cast<char*>(&flag), 1);
        if (flag == 0x00) return "";
        if (flag != 0x0b) throw std::runtime_error("Invalid string flag");
        uint32_t length = 0;
        uint32_t shift = 0;
        while (true) {
            uint8_t byte;
            ifs.read(reinterpret_cast<char*>(&byte), 1);
            length += (byte & 0x7f) << shift;
            if ((byte & 0x80) == 0) break;
            shift += 7;
        }
        std::string s(length, '\0');
        ifs.read(&s[0], length);
        return s;
    }

    void writeString(std::ofstream& ofs, const std::string& s) {
        if (s.empty()) {
            ofs.put(0x00);
            return;
        }
        ofs.put(0x0b);
        uint32_t length = s.size();
        while (length > 0) {
            uint8_t byte = length & 0x7f;
            length >>= 7;
            if (length > 0) byte |= 0x80;
            ofs.put(byte);
        }
        ofs.write(s.data(), s.size());
    }

public:
    OSRFile(const std::string& filepath = "") {
        if (!filepath.empty()) read(filepath);
    }

    void read(const std::string& filepath) {
        std::ifstream ifs(filepath, std::ios::binary);
        if (!ifs) throw std::runtime_error("Cannot open file");

        ifs.read(reinterpret_cast<char*>(&gameMode), 1);
        ifs.read(reinterpret_cast<char*>(&gameVersion), 4); gameVersion = le32toh(gameVersion);
        beatmapMD5 = readString(ifs);
        playerName = readString(ifs);
        replayMD5 = readString(ifs);
        ifs.read(reinterpret_cast<char*>(&num300s), 2); num300s = le16toh(num300s);
        ifs.read(reinterpret_cast<char*>(&num100s), 2); num100s = le16toh(num100s);
        ifs.read(reinterpret_cast<char*>(&num50s), 2); num50s = le16toh(num50s);
        ifs.read(reinterpret_cast<char*>(&numGekis), 2); numGekis = le16toh(numGekis);
        ifs.read(reinterpret_cast<char*>(&numKatus), 2); numKatus = le16toh(numKatus);
        ifs.read(reinterpret_cast<char*>(&numMisses), 2); numMisses = le16toh(numMisses);
        ifs.read(reinterpret_cast<char*>(&totalScore), 4); totalScore = le32toh(totalScore);
        ifs.read(reinterpret_cast<char*>(&maxCombo), 2); maxCombo = le16toh(maxCombo);
        ifs.read(reinterpret_cast<char*>(&perfect), 1);
        ifs.read(reinterpret_cast<char*>(&mods), 4); mods = le32toh(mods);
        lifeBarGraph = readString(ifs);
        ifs.read(reinterpret_cast<char*>(&timestamp), 8); timestamp = le64toh(timestamp);
        ifs.read(reinterpret_cast<char*>(&compressedLength), 4); compressedLength = le32toh(compressedLength);
        compressedData.resize(compressedLength);
        ifs.read(reinterpret_cast<char*>(compressedData.data()), compressedLength);
        ifs.read(reinterpret_cast<char*>(&onlineScoreID), 8); onlineScoreID = le64toh(onlineScoreID);
        if (mods & (1 << 23)) {
            ifs.read(reinterpret_cast<char*>(&additionalModInfo), 8);
            uint64_t bits = le64toh(*reinterpret_cast<uint64_t*>(&additionalModInfo));
            additionalModInfo = *reinterpret_cast<double*>(&bits);
        }
    }

    void printProperties() const {
        std::cout << "gameMode: " << static_cast<int>(gameMode) << std::endl;
        std::cout << "gameVersion: " << gameVersion << std::endl;
        std::cout << "beatmapMD5: " << beatmapMD5 << std::endl;
        std::cout << "playerName: " << playerName << std::endl;
        std::cout << "replayMD5: " << replayMD5 << std::endl;
        std::cout << "num300s: " << num300s << std::endl;
        std::cout << "num100s: " << num100s << std::endl;
        std::cout << "num50s: " << num50s << std::endl;
        std::cout << "numGekis: " << numGekis << std::endl;
        std::cout << "numKatus: " << numKatus << std::endl;
        std::cout << "numMisses: " << numMisses << std::endl;
        std::cout << "totalScore: " << totalScore << std::endl;
        std::cout << "maxCombo: " << maxCombo << std::endl;
        std::cout << "perfect: " << static_cast<int>(perfect) << std::endl;
        std::cout << "mods: " << mods << std::endl;
        std::cout << "lifeBarGraph: " << lifeBarGraph << std::endl;
        std::cout << "timestamp: " << timestamp << std::endl;
        std::cout << "compressedLength: " << compressedLength << std::endl;
        std::cout << "compressedData: Binary data (length " << compressedData.size() << ")" << std::endl;
        std::cout << "onlineScoreID: " << onlineScoreID << std::endl;
        if (mods & (1 << 23)) {
            std::cout << "additionalModInfo: " << additionalModInfo << std::endl;
        }
    }

    void write(const std::string& filepath) {
        std::ofstream ofs(filepath, std::ios::binary);
        if (!ofs) throw std::runtime_error("Cannot open file");

        ofs.write(reinterpret_cast<const char*>(&gameMode), 1);
        int32_t leGameVersion = htole32(gameVersion);
        ofs.write(reinterpret_cast<const char*>(&leGameVersion), 4);
        writeString(ofs, beatmapMD5);
        writeString(ofs, playerName);
        writeString(ofs, replayMD5);
        uint16_t leNum300s = htole16(num300s);
        ofs.write(reinterpret_cast<const char*>(&leNum300s), 2);
        uint16_t leNum100s = htole16(num100s);
        ofs.write(reinterpret_cast<const char*>(&leNum100s), 2);
        uint16_t leNum50s = htole16(num50s);
        ofs.write(reinterpret_cast<const char*>(&leNum50s), 2);
        uint16_t leNumGekis = htole16(numGekis);
        ofs.write(reinterpret_cast<const char*>(&leNumGekis), 2);
        uint16_t leNumKatus = htole16(numKatus);
        ofs.write(reinterpret_cast<const char*>(&leNumKatus), 2);
        uint16_t leNumMisses = htole16(numMisses);
        ofs.write(reinterpret_cast<const char*>(&leNumMisses), 2);
        int32_t leTotalScore = htole32(totalScore);
        ofs.write(reinterpret_cast<const char*>(&leTotalScore), 4);
        uint16_t leMaxCombo = htole16(maxCombo);
        ofs.write(reinterpret_cast<const char*>(&leMaxCombo), 2);
        ofs.write(reinterpret_cast<const char*>(&perfect), 1);
        int32_t leMods = htole32(mods);
        ofs.write(reinterpret_cast<const char*>(&leMods), 4);
        writeString(ofs, lifeBarGraph);
        int64_t leTimestamp = htole64(timestamp);
        ofs.write(reinterpret_cast<const char*>(&leTimestamp), 8);
        int32_t leCompressedLength = htole32(compressedData.size());
        ofs.write(reinterpret_cast<const char*>(&leCompressedLength), 4);
        ofs.write(reinterpret_cast<const char*>(compressedData.data()), compressedData.size());
        int64_t leOnlineScoreID = htole64(onlineScoreID);
        ofs.write(reinterpret_cast<const char*>(&leOnlineScoreID), 8);
        if (mods & (1 << 23)) {
            uint64_t bits = htole64(*reinterpret_cast<uint64_t*>(&additionalModInfo));
            ofs.write(reinterpret_cast<const char*>(&bits), 8);
        }
    }
};