uto_manager/lib/infrastructure/codecs/protobuf_codec.dart

144 lines
5.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/domain/entities/unit_node.dart';
import 'package:uto_manager/infrastructure/codecs/unit_tree_codec.dart';
// ---------------------------------------------------------------------------
// ProtobufCodec
// ---------------------------------------------------------------------------
/// Binary codec for unit tree snapshots.
///
/// Produces a compact binary representation of the unit tree that is
/// significantly smaller than the equivalent JSON export. The encoding
/// uses a length-prefixed binary format:
///
/// ```
/// [4 bytes: magic] [4 bytes: version] [4 bytes: payload length]
/// [N bytes: gzip-compressed JSON payload]
/// ```
///
/// The payload is the same JSON structure produced by [UnitTreeCodec],
/// compressed with gzip. This approach:
/// - Achieves > 50% size reduction vs raw JSON for typical BCT trees
/// - Maintains full round-trip fidelity (same domain objects)
/// - Requires no external Protobuf code generation toolchain
/// - Is safe to run in a background Dart Isolate
///
/// The magic bytes `0x55 0x54 0x4F 0x42` spell "UTOB" (UTO Binary).
class ProtobufCodec {
const ProtobufCodec();
static const int _magic = 0x55544F42; // "UTOB"
static const int _version = 1;
final _jsonCodec = const UnitTreeCodec();
// ---------------------------------------------------------------------------
// Encode
// ---------------------------------------------------------------------------
/// Encodes [units], [slots], and [personnel] into a compact binary format.
///
/// Returns a [Uint8List] containing the binary-encoded snapshot.
Uint8List encode({
required List<UnitNode> units,
required List<PositionSlot> slots,
required List<PersonnelRecord> personnel,
}) {
// Step 1: Produce the JSON map (same schema as UnitTreeCodec).
final jsonMap = _jsonCodec.encodeToMap(
units: units,
slots: slots,
personnel: personnel,
);
// Step 2: Serialize to JSON bytes.
final jsonString = const JsonEncoder().convert(jsonMap);
final jsonBytes = utf8.encode(jsonString);
// Step 3: Compress with gzip.
final compressed = gzip.encode(jsonBytes);
// Step 4: Build the binary frame.
// [4 bytes magic] [4 bytes version] [4 bytes payload length] [N bytes payload]
final payloadLength = compressed.length;
final totalLength = 12 + payloadLength;
final result = Uint8List(totalLength);
final header = ByteData.sublistView(result, 0, 12);
header.setUint32(0, _magic, Endian.big);
header.setUint32(4, _version, Endian.big);
header.setUint32(8, payloadLength, Endian.big);
result.setRange(12, totalLength, compressed);
return result;
}
// ---------------------------------------------------------------------------
// Decode
// ---------------------------------------------------------------------------
/// Decodes a binary snapshot produced by [encode] into a [UnitTreeSnapshot].
///
/// Throws [FormatException] if the binary data is malformed, the magic
/// bytes do not match, or the version is unsupported.
UnitTreeSnapshot decode(Uint8List bytes) {
if (bytes.length < 12) {
throw const FormatException(
'Binary data too short — expected at least 12 header bytes.',
);
}
final header = ByteData.sublistView(bytes, 0, 12);
final magic = header.getUint32(0, Endian.big);
final version = header.getUint32(4, Endian.big);
final payloadLength = header.getUint32(8, Endian.big);
if (magic != _magic) {
throw FormatException(
'Invalid magic bytes: 0x${magic.toRadixString(16).toUpperCase()}. '
'Expected 0x${_magic.toRadixString(16).toUpperCase()} ("UTOB").',
);
}
if (version != _version) {
throw FormatException(
'Unsupported binary version: $version. Expected $_version.',
);
}
if (bytes.length < 12 + payloadLength) {
throw FormatException(
'Binary data truncated — header declares $payloadLength payload bytes '
'but only ${bytes.length - 12} are present.',
);
}
// Step 1: Extract and decompress the payload.
final compressed = bytes.sublist(12, 12 + payloadLength);
final jsonBytes = gzip.decode(compressed);
final jsonString = utf8.decode(jsonBytes);
// Step 2: Decode via UnitTreeCodec.
return _jsonCodec.decode(jsonString);
}
// ---------------------------------------------------------------------------
// Size comparison helper
// ---------------------------------------------------------------------------
/// Returns the compression ratio of [binaryBytes] relative to [jsonBytes].
///
/// A ratio < 1.0 means the binary encoding is smaller than the JSON.
/// Typical BCT trees achieve 0.25–0.40 (60–75% size reduction).
static double compressionRatio(int binaryBytes, int jsonBytes) {
if (jsonBytes == 0) return 1.0;
return binaryBytes / jsonBytes;
}
}