endianness/more prog mode

This commit is contained in:
Emil Lerch 2026-07-25 10:33:44 -07:00
parent 6a5bdf1e5e
commit ebef9c52e4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 146 additions and 53 deletions

View file

@ -635,15 +635,35 @@ Navigation:
value updates on Enter (or live as you type - TBD)
- When bit grid is focused: left/right/up/down navigate, Space toggles
- Tab cycles between Standard/Programmer mode
- Ctrl-W cycles bit width (8/16/32/64)
- Ctrl-W cycles bit width (8/16/32/64/128)
- Ctrl-E toggles endianness display (LE/BE)
Bit width is a non-destructive display lens:
- The stored value keeps full precision (u128). Cycling the width never
truncates it, so narrowing to a smaller width and then widening again
restores the hidden upper bits.
- The DEC/HEX/OCT/BIN/ASCII rows and the bit grid show the value masked to
the current width.
- While the stored value has bits set beyond the current width, a warning
line is shown ("value exceeds N bits - showing low bits (widen to
restore)"). It is a warning, not an error: the value is still usable.
- Explicit value edits (typing a decimal digit, toggling a bit, editing a
hex/oct/bin digit) commit to the current width, clearing any hidden upper
bits. Only width cycling is non-destructive; deliberately editing at a
narrow width means you are now working at that width.
Endianness:
- Default: little-endian (matches x86-64)
- Toggle with Ctrl-E
- Affects: HEX byte display order, ASCII interpretation order
- Does NOT affect: the underlying u64 value, DEC display, BIN display
- Header shows current setting: `Endian: LE` or `Endian: BE`
- Toggle with Ctrl-E; header shows current setting (`Endian: LE` / `Endian: BE`)
- Default: big-endian. The HEX row's primary job is to show the value in hex,
so by default it reads MSB-first and matches DEC/OCT/BIN. Little-endian is
the opt-in "how it sits in x86-64 memory" view.
- Affects: HEX byte display order and ASCII byte order only. In LE the display
bytes are reversed (e.g. 0xDEADBEEF -> `EF BE AD DE`).
- Does NOT affect: the underlying value, DEC, OCT, or BIN display, nor the
clipboard `raw` form of HEX (which stays the canonical MSB-first number).
- Rationale for BE default (revised from an earlier LE default): defaulting to
LE would reverse the HEX row so it no longer matches the number shown in the
DEC/BIN rows directly beside it, which is surprising in a calculator context.
Text/ASCII literals:
- Single-quoted strings in expressions: `'ELF'`, `'RIFF'`, `'a'`

View file

@ -263,7 +263,11 @@ Subcommands deferred until Phase 2 engine work is done.
- Focused field is visually highlighted (label gets inverse color)
- Removed Ctrl-B (field navigation replaces it)
- Expression field works as before (full programmer-mode expression evaluation)
- Endianness indicator in header (display only, toggle not yet implemented)
- ASCII row under HEX (read-only, xxd-style, aligned column-for-column)
- Endianness toggle (Ctrl-E): flips HEX and ASCII byte order, header shows LE/BE.
Default big-endian so HEX reads as the number (matching DEC/OCT/BIN); LE is the
opt-in x86-64 memory-layout view. DEC/OCT/BIN and the hex `raw`/clipboard form
are unaffected. Covered by formatter unit tests (BE and LE for hex and ascii).
- Verify: can arrow between fields, type hex value and see all fields update, toggle bits
### Task 5.3.2: Implement text/ASCII literal parsing [DONE]

View file

@ -14,6 +14,7 @@
const std = @import("std");
const types = @import("types.zig");
const BitWidth = types.BitWidth;
const Endianness = types.Endianness;
/// A formatted value with both display and clipboard representations.
pub const FormattedValue = struct {
@ -55,13 +56,15 @@ pub fn formatFloat(buf: []u8, value: f64) FormattedValue {
}
/// Format an integer for programmer mode hex display.
/// Display: "FF FF FF FF" (space per byte)
/// Raw: "0xFFFFFFFF" (no separators, with prefix)
pub fn formatHex(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue {
/// Display: "FF FF FF FF" (space per byte), byte order per `endian`.
/// Raw: "0xFFFFFFFF" (no separators, canonical MSB-first value regardless of
/// endian, since raw is the number itself and copies to the clipboard as such).
pub fn formatHex(buf: []u8, value: u128, bit_width: BitWidth, endian: Endianness) FormattedValue {
const width = bit_width.bits();
const hex_digits: usize = @as(usize, width) / 4;
const num_bytes: usize = @as(usize, width) / 8;
// Write raw first: "0x" + hex digits
// Write raw first: "0x" + hex digits, canonical MSB-first.
buf[0] = '0';
buf[1] = 'x';
var pos: usize = 2;
@ -74,17 +77,20 @@ pub fn formatHex(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue {
}
const raw = buf[0..pos];
// Write display after raw: hex digits with space per byte (every 2 digits)
// Write display after raw: one byte at a time in `endian` order,
// space-separated. Only the byte order swaps; nibble order within a
// byte is always high-then-low.
const display_start = pos;
i = 0;
while (i < hex_digits) : (i += 1) {
if (i > 0 and i % 2 == 0) {
var k: usize = 0;
while (k < num_bytes) : (k += 1) {
if (k > 0) {
buf[pos] = ' ';
pos += 1;
}
const shift_amt: u7 = @intCast((hex_digits - 1 - i) * 4);
const nibble: u4 = @intCast((value >> shift_amt) & 0xF);
buf[pos] = hexDigit(nibble);
const byte = byteAt(value, num_bytes, k, endian);
buf[pos] = hexDigit(@intCast(byte >> 4));
pos += 1;
buf[pos] = hexDigit(@intCast(byte & 0xF));
pos += 1;
}
const display = buf[display_start..pos];
@ -92,6 +98,15 @@ pub fn formatHex(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue {
return .{ .display = display, .raw = raw };
}
/// Return the byte to display at position `k` (0 = leftmost) for a value of
/// `num_bytes` width, honoring endianness. Big-endian shows the most
/// significant byte first; little-endian shows the least significant first.
fn byteAt(value: u128, num_bytes: usize, k: usize, endian: Endianness) u8 {
const msb_index: usize = if (endian == .big) k else num_bytes - 1 - k;
const shift_amt: u7 = @intCast((num_bytes - 1 - msb_index) * 8);
return @intCast((value >> shift_amt) & 0xFF);
}
/// Format an integer for programmer mode binary display.
/// Display: "1111 0000 1010 1100" (space per nibble)
/// Raw: "0b1111000010101100" (no separators)
@ -170,23 +185,21 @@ pub fn formatOctal(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue {
return .{ .display = display, .raw = raw };
}
/// Format an integer's bytes as an ASCII representation for programmer mode.
/// One glyph per byte, most-significant byte first (matching the hex byte view).
/// One glyph per byte in `endian` order (matching the hex byte view).
/// Printable bytes (0x20-0x7E) render as themselves; every other byte renders
/// as '.', matching the convention used by `xxd` and similar hex dumps.
///
/// Display: " . . . a s c i i" - each byte is a leading space + glyph,
/// space-separated, so the row lines up column-for-column under formatHex.
/// Raw: "...ascii" - contiguous glyphs, clipboard-friendly.
pub fn formatAscii(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue {
pub fn formatAscii(buf: []u8, value: u128, bit_width: BitWidth, endian: Endianness) FormattedValue {
const num_bytes: usize = @as(usize, bit_width.bits()) / 8;
// Raw: contiguous glyphs, MSB first.
// Raw: contiguous glyphs, in display (endian) order.
var pos: usize = 0;
var k: usize = 0;
while (k < num_bytes) : (k += 1) {
const shift_amt: u7 = @intCast((num_bytes - 1 - k) * 8);
const byte: u8 = @intCast((value >> shift_amt) & 0xFF);
buf[pos] = asciiGlyph(byte);
buf[pos] = asciiGlyph(byteAt(value, num_bytes, k, endian));
pos += 1;
}
const raw = buf[0..pos];
@ -202,9 +215,7 @@ pub fn formatAscii(buf: []u8, value: u128, bit_width: BitWidth) FormattedValue {
}
buf[pos] = ' ';
pos += 1;
const shift_amt: u7 = @intCast((num_bytes - 1 - k) * 8);
const byte: u8 = @intCast((value >> shift_amt) & 0xFF);
buf[pos] = asciiGlyph(byte);
buf[pos] = asciiGlyph(byteAt(value, num_bytes, k, endian));
pos += 1;
}
const display = buf[display_start..pos];
@ -361,39 +372,61 @@ test "formatFloat: zero" {
test "formatHex: 8-bit" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xFF, .bits8);
const result = formatHex(&buf, 0xFF, .bits8, .big);
try testing.expectEqualStrings("FF", result.display);
try testing.expectEqualStrings("0xFF", result.raw);
}
test "formatHex: 16-bit" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xABCD, .bits16);
const result = formatHex(&buf, 0xABCD, .bits16, .big);
try testing.expectEqualStrings("AB CD", result.display);
try testing.expectEqualStrings("0xABCD", result.raw);
}
test "formatHex: 32-bit with grouping" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xDEADBEEF, .bits32);
const result = formatHex(&buf, 0xDEADBEEF, .bits32, .big);
try testing.expectEqualStrings("DE AD BE EF", result.display);
try testing.expectEqualStrings("0xDEADBEEF", result.raw);
}
test "formatHex: 64-bit with grouping" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xDEAD_BEEF_CAFE_BABE, .bits64);
const result = formatHex(&buf, 0xDEAD_BEEF_CAFE_BABE, .bits64, .big);
try testing.expectEqualStrings("DE AD BE EF CA FE BA BE", result.display);
try testing.expectEqualStrings("0xDEADBEEFCAFEBABE", result.raw);
}
test "formatHex: zero 32-bit" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0, .bits32);
const result = formatHex(&buf, 0, .bits32, .big);
try testing.expectEqualStrings("00 00 00 00", result.display);
try testing.expectEqualStrings("0x00000000", result.raw);
}
test "formatHex: little-endian reverses byte display" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xDEADBEEF, .bits32, .little);
// Bytes shown least-significant first (x86 memory order)
try testing.expectEqualStrings("EF BE AD DE", result.display);
// Raw stays the canonical number
try testing.expectEqualStrings("0xDEADBEEF", result.raw);
}
test "formatHex: little-endian 64-bit" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xDEAD_BEEF_CAFE_BABE, .bits64, .little);
try testing.expectEqualStrings("BE BA FE CA EF BE AD DE", result.display);
try testing.expectEqualStrings("0xDEADBEEFCAFEBABE", result.raw);
}
test "formatHex: little-endian 8-bit is identical (single byte)" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xFF, .bits8, .little);
try testing.expectEqualStrings("FF", result.display);
}
test "formatBinary: 8-bit" {
var buf: [256]u8 = undefined;
const result = formatBinary(&buf, 0xFF, .bits8);
@ -491,41 +524,48 @@ test "formatFloat: regular float" {
test "formatAscii: printable word 64-bit" {
var buf: [128]u8 = undefined;
// 0x0000006173636969 -> bytes 00 00 00 61 73 63 69 69 -> "...ascii"
const result = formatAscii(&buf, 0x0000006173636969, .bits64);
const result = formatAscii(&buf, 0x0000006173636969, .bits64, .big);
try testing.expectEqualStrings("...ascii", result.raw);
// Display aligns one glyph per byte, space-separated
try testing.expectEqualStrings(" . . . a s c i i", result.display);
}
test "formatAscii: little-endian reverses byte order" {
var buf: [128]u8 = undefined;
// Same value, little-endian: bytes reversed -> "iicsa..." with trailing dots
const result = formatAscii(&buf, 0x0000006173636969, .bits64, .little);
try testing.expectEqualStrings("iicsa...", result.raw);
}
test "formatAscii: 8-bit printable" {
var buf: [128]u8 = undefined;
const result = formatAscii(&buf, 0x41, .bits8);
const result = formatAscii(&buf, 0x41, .bits8, .big);
try testing.expectEqualStrings("A", result.raw);
try testing.expectEqualStrings(" A", result.display);
}
test "formatAscii: 8-bit non-printable becomes dot" {
var buf: [128]u8 = undefined;
const result = formatAscii(&buf, 0x00, .bits8);
const result = formatAscii(&buf, 0x00, .bits8, .big);
try testing.expectEqualStrings(".", result.raw);
}
test "formatAscii: control and high bytes become dots" {
var buf: [128]u8 = undefined;
// 0x1B (ESC) and 0xFF are both non-printable
const result = formatAscii(&buf, 0x1BFF, .bits16);
const result = formatAscii(&buf, 0x1BFF, .bits16, .big);
try testing.expectEqualStrings("..", result.raw);
}
test "formatAscii: boundary bytes 0x20 and 0x7E are printable" {
var buf: [128]u8 = undefined;
// 0x20 = space, 0x7E = '~'
const result = formatAscii(&buf, 0x207E, .bits16);
const result = formatAscii(&buf, 0x207E, .bits16, .big);
try testing.expectEqualStrings(" ~", result.raw);
}
test "formatAscii: 0x7F is non-printable" {
var buf: [128]u8 = undefined;
const result = formatAscii(&buf, 0x7F, .bits8);
const result = formatAscii(&buf, 0x7F, .bits8, .big);
try testing.expectEqualStrings(".", result.raw);
}

View file

@ -105,7 +105,10 @@ pub const Value = union(enum) {
pub const ProgrammerConfig = struct {
bit_width: BitWidth = .bits64,
signedness: Signedness = .signed,
display_endian: Endianness = .little,
/// Byte order for the HEX and ASCII rows only. Defaults to big-endian so
/// the HEX row reads as the number itself (matching DEC/OCT/BIN); the
/// little-endian view (x86 memory layout) is available via the toggle.
display_endian: Endianness = .big,
};
/// All possible engine errors.

View file

@ -96,7 +96,7 @@ fn formatStandardMultiBase(buf: []u8, dec_display: []const u8, value: f64) CliRe
var hex_buf: [256]u8 = undefined;
var oct_buf: [256]u8 = undefined;
var bin_buf: [512]u8 = undefined;
const hex = engine.formatter.formatHex(&hex_buf, int_val, bw);
const hex = engine.formatter.formatHex(&hex_buf, int_val, bw, .big);
const oct = engine.formatter.formatOctal(&oct_buf, int_val, bw);
const bin = engine.formatter.formatBinary(&bin_buf, int_val, bw);
@ -127,7 +127,7 @@ fn formatProgrammerResult(buf: []u8, result: engine.types.Integer, config: engin
var oct_buf: [256]u8 = undefined;
var bin_buf: [512]u8 = undefined;
const hex = engine.formatter.formatHex(&hex_buf, value, config.bit_width);
const hex = engine.formatter.formatHex(&hex_buf, value, config.bit_width, config.display_endian);
const dec = engine.formatter.formatDecimalUnsigned(&dec_buf, value);
const sdec = engine.formatter.formatDecimalSigned(&sdec_buf, signed);
const oct = engine.formatter.formatOctal(&oct_buf, value, config.bit_width);

View file

@ -195,7 +195,11 @@ pub const App = struct {
.bits64 => .bits128,
.bits128 => .bits8,
};
self.prog_value &= self.prog_config.bit_width.mask();
// NOTE: prog_value is intentionally NOT masked here. Width is a
// display lens over the full value, so narrowing then widening
// restores the hidden upper bits. The display masks to width and
// a warning is shown while the value does not fit (see
// drawProgrammerMode). Explicit value edits still commit to width.
if (self.bit_cursor >= self.prog_config.bit_width.bits()) {
self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1);
}
@ -203,6 +207,16 @@ pub const App = struct {
return;
}
// Ctrl-E: toggle display endianness (programmer mode)
if (self.mode == .programmer and key.matches('e', .{ .ctrl = true })) {
self.prog_config.display_endian = switch (self.prog_config.display_endian) {
.little => .big,
.big => .little,
};
ctx.redraw = true;
return;
}
// VALUE ZONE key handling
if (self.value_zone_active) {
self.handleValueZoneKey(key);
@ -382,7 +396,10 @@ pub const App = struct {
},
.dec_unsigned, .dec_signed => {
if (char >= '0' and char <= '9') {
self.prog_value = ((self.prog_value *% 10) +% (char - '0')) & self.prog_config.bit_width.mask();
// Operate on the in-width portion so hidden upper bits do
// not corrupt the arithmetic; the edit commits to width.
const m = self.prog_config.bit_width.mask();
self.prog_value = (((self.prog_value & m) *% 10) +% (char - '0')) & m;
}
},
.expression => {},
@ -455,7 +472,7 @@ pub const App = struct {
var hex_buf: [256]u8 = undefined;
var oct_buf: [256]u8 = undefined;
var bin_buf: [512]u8 = undefined;
const hex = engine.formatter.formatHex(&hex_buf, int_val, bw);
const hex = engine.formatter.formatHex(&hex_buf, int_val, bw, .big);
const oct = engine.formatter.formatOctal(&oct_buf, int_val, bw);
const bin = engine.formatter.formatBinary(&bin_buf, int_val, bw);
details = .{

View file

@ -34,11 +34,11 @@ pub fn drawHelp(surface: *vxfw.Surface, width: u16, height: u16) void {
draw.writeStr(surface, row, 2, "Programmer Mode", .{ .fg = C.purple, .bold = true });
row += 1;
const prog_keys = [_][2][]const u8{
.{ "Ctrl-B", "Toggle bit grid focus" },
.{ "Ctrl-W", "Cycle bit width (8/16/32/64)" },
.{ "`", "Toggle input / value zone" },
.{ "Ctrl-W", "Cycle bit width (8/16/32/64/128)" },
.{ "Ctrl-E", "Toggle endianness (BE / LE)" },
.{ "Space", "Toggle bit (in grid)" },
.{ "Arrows", "Navigate bits (in grid)" },
.{ "Esc", "Return to input (from grid)" },
.{ "Arrows", "Navigate fields and bits (value zone)" },
};
for (prog_keys) |kv| {
if (row >= height -| 4) break;

View file

@ -16,12 +16,21 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
// Config display
var config_buf: [80]u8 = undefined;
const config_str = std.fmt.bufPrint(&config_buf, "Bits: {d} Signed: {s} Endian: LE", .{
const config_str = std.fmt.bufPrint(&config_buf, "Bits: {d} Signed: {s} Endian: {s}", .{
bw.bits(),
if (app.prog_config.signedness == .signed) "yes" else "no",
if (app.prog_config.display_endian == .little) "LE" else "BE",
}) catch "Bits: ??";
draw.writeStr(surface, 2, 2, config_str, .{ .fg = C.muted });
// Truncation warning: the stored value has bits beyond the current width,
// so the display below shows only the low bits. Widening restores them.
if (app.prog_value & ~bw.mask() != 0) {
var warn_buf: [96]u8 = undefined;
const warn = std.fmt.bufPrint(&warn_buf, "! value exceeds {d} bits - showing low bits (widen to restore)", .{bw.bits()}) catch "! value exceeds width";
draw.writeStr(surface, 3, 2, warn, .{ .fg = C.pink, .bold = true });
}
// Bit grid
const grid_start: u16 = 4;
drawBitGrid(app, surface, grid_start, val, bw);
@ -53,7 +62,7 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
// HEX
var hex_buf: [256]u8 = undefined;
const hex = engine.formatter.formatHex(&hex_buf, val, bw);
const hex = engine.formatter.formatHex(&hex_buf, val, bw, app.prog_config.display_endian);
const hex_style: vaxis.Style = if (focused == .hex)
.{ .fg = C.bg, .bg = C.green, .bold = true }
else
@ -67,7 +76,7 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
// ASCII (derived, read-only): one glyph per byte, aligned under HEX.
var ascii_buf: [128]u8 = undefined;
const ascii = engine.formatter.formatAscii(&ascii_buf, val, bw);
const ascii = engine.formatter.formatAscii(&ascii_buf, val, bw, app.prog_config.display_endian);
draw.writeStr(surface, base_start + 3, 2, "ASCII:", .{ .fg = C.cyan });
draw.writeStr(surface, base_start + 3, 11, ascii.display, .{ .fg = C.orange });
@ -111,9 +120,9 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
app.drawInput(surface, height -| 2);
draw.fillRow(surface, height -| 1, ' ', .{ .fg = C.muted, .bg = C.bg });
const status = if (focused == .bits)
"Arrows:nav | Space:toggle | Up/Down:field | Ctrl-W:width | Tab:mode | Ctrl-C:quit"
"Arrows:nav | Space:toggle | Up/Down:field | Ctrl-W:width | Ctrl-E:endian | Tab:mode | Ctrl-C:quit"
else
"Up/Down:field | Enter:set value | Ctrl-W:width | Tab:mode | ?:help | Ctrl-C:quit";
"Up/Down:field | Enter:set value | Ctrl-W:width | Ctrl-E:endian | Tab:mode | ?:help | Ctrl-C:quit";
draw.writeStr(surface, height -| 1, 1, status, .{ .fg = C.muted, .bg = C.bg });
}