Problem Statement
Given a hexadecimal string (hascii) and its binary specification (a list of byte field names, their offsets, and byte lengths), design classes to parse all byte-fields, cast bytes into suitable types for storage, and allow the user to write to a byte-field. For this purpose, I've created a simple binary specification:
string hasciiData = "07E30A0240490FD8402df8548000000048656C6C6F576F726C64218000000000000000";
With the following binary specification:
string _year = "07E3"; // 2019 16-bit int, 0x07E3
string _month = "0A"; // 10 8-bit int, 0x0A
string _day = "02"; // 2 8-bit int, 0x02
string _pi = "40490FD8"; // IEEE-754 Single Precision Float, 0x4049 0FD8
string _eulers = "402DF854"; // IEEE-754 Single Precision Float, 0x402D F854
string _secretValue = "80000000"; // 32-bit int, 0x80000000 (Decimal -2147483648)
string _secretMsg = "48656C6C6F576F726C6421"; // ASCII string "HelloWorld!", 0x48656C6C6F576F726C6421
string _bigInt = "8000000000000000"; // 64-bit int, 0x8000 0000 0000 0000 (Decimal -9223372036854775808)
Class Design
The design separates byte-level specification from the code that reads bytes from the string. The "specification" encapsulates the logical structure of the hascii data (storing details like the names of byte fields, byte offsets and field lengths) so that it can be used to parse (read) and write to the hascii string.
Manipulation of hascii characters (hascii bytes are pairs of hascii characters) are handled by the HasciiParser class. The PD0Format class stores the specification, implemented using multiple dictionaries that map each byte field name (string) to a tuple (int, int) where Tuple.Item1
is the base-1 byte position of hascii bytes (i.e., the index value, which starts at 0 and corresponds to byte position #1, increments by 2 since there are 2 hex characters per byte) and Tuple.Item2
is the length of the byte field (in units of 'bytes' as in the "Year" bytefield begins at byte position #1 and contains 2 bytes).
protected ByteSpecification dateSpec = new ByteSpecification()
{
{"Year", (1, 2)},
{"Month", (3, 1)},
{"Day", (4, 1)},
};
The specification also includes a dictionary mapping byte field names to functions that cast bytes to appropriate types (determined by the binary specification).
Hascii character manipulation is decoupled from the binary specification so that if you were to say, rearrange the order of bytefields or add additional byte fields, you need only change the Specification
dictionaries.
Code
using System;
using System.Collections.Generic;
using System.Text;
using ByteSpecification = System.Collections.Generic.Dictionary<string, (int, int)>;
using CastOperation = System.Collections.Generic.Dictionary<string, System.Func<string, object>>;
namespace ByteParsing
{
public class HasciiParser
{
public static string HexToString(string hascii)
{
StringBuilder sb = new StringBuilder();
for (int i = 0; i < hascii.Length; i += 2)
{
string hexByte = hascii.Substring(i, 2);
sb.Append(Convert.ToChar(Convert.ToUInt32(hexByte, 16)));
}
return sb.ToString();
}
public static string StringToHex(string str, int maxWidth, bool padLeft=true, char padChar='0')
{
StringBuilder sb = new StringBuilder();
foreach (char c in str)
{
sb.AppendFormat("{0:X}", Convert.ToInt32(c));
}
if (padLeft)
{
return sb.ToString().PadLeft(maxWidth, padChar);
}
return sb.ToString().PadRight(maxWidth, padChar);
}
public DateTime HexToDatetime(string hascii)
{
int year = Convert.ToInt32(hascii.Substring(0, 4), 16);
int month = Convert.ToInt32(hascii.Substring(4, 2), 16);
int day = Convert.ToInt32(hascii.Substring(6, 2), 16);
return new DateTime(year, month, day);
}
public static float HexToFloat(string hascii)
{
// https://stackoverflow.com/a/7903300/3396951
uint num = uint.Parse(hascii, System.Globalization.NumberStyles.AllowHexSpecifier);
byte[] floatVals = BitConverter.GetBytes(num);
return BitConverter.ToSingle(floatVals, 0);
}
}
public class PD0Format
{
public string _hasciiData; // Normally private
public PD0Format(string hasciiData)
{
_hasciiData = hasciiData;
}
protected CastOperation castop = new CastOperation()
{
{"Year", (hascii_str) => Convert.ToInt32(hascii_str, 16)},
{"Month", (hascii_str) => Convert.ToInt32(hascii_str, 16)},
{"Day", (hascii_str) => Convert.ToInt32(hascii_str, 16)},
{"Pi", (hascii_str) => HasciiParser.HexToFloat(hascii_str)},
{"EulersNumber", (hascii_str) => HasciiParser.HexToFloat(hascii_str)},
{"SecretValue", (hascii_str) => Convert.ToInt32(hascii_str, 16)},
{"SecretMessage", (hascii_str) => HasciiParser.HexToString(hascii_str)},
{"BigInt", (hascii_str) => Convert.ToInt64(hascii_str, 16)}
};
protected ByteSpecification dateSpec = new ByteSpecification()
{
{"Year", (1, 2)},
{"Month", (3, 1)},
{"Day", (4, 1)},
};
protected ByteSpecification mathConstantsSpec = new ByteSpecification()
{
{"Pi", (5, 4)},
{"EulersNumber", (9, 4)}
};
protected ByteSpecification secretsSpec = new ByteSpecification()
{
{"SecretValue", (13, 4)},
{"SecretMessage", (17, 11)},
{"BigInt", (28, 8)}
};
private string GetHasciiBytes(ByteSpecification byteSpec, string fieldName)
{
// ByteSpecification assumes base-1 indexing. Substring requires base-0 indexing so we must subtract 2 (reason below).
// Because there are two hex characters to a byte, we have to multiply the startIndex of string.Substring
// by 2. To get to the startIndex of a byte, we must substract by multiples of 2.
// Item2 of ByteSpecification's dictionary value represents the number of bytes. Since two hex characters represent
// a byte, the number of characters to extract using string.Substring is Item2 * 2
return _hasciiData.Substring(byteSpec[fieldName].Item1 * 2 - 2, byteSpec[fieldName].Item2 * 2);
}
public dynamic this[string category, string fieldName]
{
get
{
ByteSpecification spec = null;
switch (category.ToLower())
{
case "date":
spec = dateSpec;
goto case "cast";
case "constants":
spec = mathConstantsSpec;
goto case "cast";
case "secrets":
spec = secretsSpec;
goto case "cast";
case "cast":
string hascii_bytes = GetHasciiBytes(spec, fieldName); // Retrieve bytes from underlying string
return castop[fieldName](hascii_bytes); // Cast to appropriate type, according to mapping defined in CastOperation
}
return new ArgumentException();
}
set
{
switch (category.ToLower())
{
case "secrets":
int insertLocation = secretsSpec[fieldName].Item1 * 2 - 2;
int maxCharFieldWidth = secretsSpec[fieldName].Item2 * 2; // Used for padding when the number of hex chars isn't even
string val = null;
switch (fieldName)
{
case "SecretValue":
val = String.Format("{0:X}", value).PadLeft(maxCharFieldWidth, '0'); // Convert value to hascii representation
goto case "EmplaceString";
case "SecretMessage":
val = HasciiParser.StringToHex(value, maxCharFieldWidth);
goto case "EmplaceString";
case "BigInt":
val = String.Format("{0:X}", value).PadLeft(maxCharFieldWidth, '0');
goto case "EmplaceString";
case "EmplaceString":
_hasciiData = _hasciiData.Remove(insertLocation, maxCharFieldWidth); // Remove the characters currently present
_hasciiData = _hasciiData.Insert(insertLocation, val ?? throw new InvalidOperationException());
Console.WriteLine(_hasciiData);
break;
}
break;
case "date":
throw new NotImplementedException();
case "constants":
throw new NotImplementedException();
}
}
}
}
class Program
{
static void Main(string[] args)
{
/*
string _year = "07E3"; // 2019 16-bit int, 0x07E3
string _month = "0A"; // 10 8-bit int, 0x0A
string _day = "02"; // 2 8-bit int, 0x02
string _pi = "40490FD8"; // IEEE-754 Single Precision Float, 0x4049 0FD8
string _eulers = "402DF854"; // IEEE-754 Single Precision Float, 0x402D F854
string _secretValue = "80000000"; // 32-bit int, 0x80000000 (Decimal -2147483648)
string _secretMsg = "48656C6C6F576F726C6421"; // ASCII string "HelloWorld!", 0x48656C6C6F576F726C6421
string _bigInt = "8000000000000000"; // 64-bit int, 0x8000 0000 0000 0000 (Decimal -9223372036854775808)
*/
string hasciiData = "07E30A0240490FD8402df8548000000048656C6C6F576F726C64218000000000000000";
PD0Format ensemble = new PD0Format(hasciiData);
int recordYear = ensemble["date", "Year"];
int recordMonth = ensemble["date", "Month"];
int recordDay = ensemble["date", "Day"];
Console.WriteLine(new DateTime(recordYear, recordMonth, recordDay));
float Pi = ensemble["constants", "Pi"];
float exp1 = ensemble["Constants", "EulersNumber"];
Console.WriteLine($"Pi: {Pi}\nEuler's Number: {exp1}");
int secretValue = ensemble["secrets", "SecretValue"];
string secretMsg = ensemble["secrets", "SecretMessage"];
long bigInt = ensemble["secrets", "BigInt"];
Console.WriteLine($"Secret Value: {secretValue}\nSecret Msg: {secretMsg}\nbigInt: {bigInt}");
// Usage: Writing
string defaultData = "0000000000000000000000000000000000000000000000000000000000000000000000";
PD0Format defaultRecord = new PD0Format(defaultData);
// 35791394 corresponds to 0x02222222 written as "02222222" in hascii
defaultRecord["secrets", "SecretValue"] = 35791394;
// "FooBarBaz" corresponds to 0x00 0046 6F6F 4261 7242 617A written as "0000466F6F42617242617A" in hascii
defaultRecord["secrets", "SecretMessage"] = "FooBarBaz";
// 1229782938247303441 corresponds to 0x1111 1111 1111 1111 written as "1111111111111111" in hascii
defaultRecord["secrets", "BigInt"] = 1229782938247303441;
// Original defaultData: "0000000000000000000000000000000000000000000000000000000000000000000000"
// Modified defaultData: "000000000000000000000000022222220000466F6F42617242617A1111111111111111"
Console.WriteLine(defaultRecord._hasciiData);
Console.ReadLine(); // Prevent console from closing
}
}
}
Program Output
10/2/2019 00:00:00
Pi: 3.141592
Euler's Number: 2.718282
Secret Value: -2147483648
Secret Msg: HelloWorld!
bigInt: -9223372036854775808
0000000000000000000000000222222200000000000000000000000000000000000000
000000000000000000000000022222220000466F6F42617242617A0000000000000000
000000000000000000000000022222220000466F6F42617242617A1111111111111111
000000000000000000000000022222220000466F6F42617242617A1111111111111111
goto
s there o_O they are sometimes useful but they are pretty scarry here. \$\endgroup\$switch/goto
pattern used here is a step towards cutting down repetition and improving readability. Ultimately, it was used as a substitute for "method extraction" because the number of cases is small. If the binary specification called for hundreds of fields, I'd convert it to a function. I hope the flow of execution is self-evident: exactly 1case
-statement runs initially to initialize a set of variables. Execution then jumps tocase
that does the actual work with those variables. \$\endgroup\$