refactor(io): unify QB45 binary GET/PUT layout in QbBinaryCodec
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 3 Sep 2026 20:51:52 +0000 (23:51 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 3 Sep 2026 20:51:52 +0000 (23:51 +0300)
The little-endian byte layout (INTEGER 2, LONG 4, SINGLE 4, DOUBLE 8,
fixed STRING*n space-padded) was written out four times in Interpreter:
scalar and record variants of both decode and encode, and the
record-target lookup (scalar cell vs array element) was copy-pasted
between decodeInto and encodeFrom.

QbBinaryCodec owns the layout now: decodeScalar/encodeScalar,
decodeRecord/encodeRecord over field descriptors. Numeric range
narrowing stays in the interpreter (it raises the trappable Overflow);
the codec is pure byte layout. Interpreter gains one recordTarget()
lookup used by byteSizeOf, decodeInto and encodeFrom.

Verification: 93/93 tests green; INDEX (RANDOM record GET/PUT),
QLBDUMP (BINARY GET into TYPE records) and SEARCH re-run — all
pixel-identical to the pre-refactor captures.

src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
src/main/java/eu/svjatoslav/crtbasic/io/QbBinaryCodec.java [new file with mode: 0644]

index 94b982f..81e6900 100644 (file)
@@ -7,6 +7,7 @@ import eu.svjatoslav.crtbasic.input.KeyboardQueue;
 import eu.svjatoslav.crtbasic.io.ConsoleOutput;
 import eu.svjatoslav.crtbasic.io.FileHandle;
 import eu.svjatoslav.crtbasic.io.IoSubsystem;
+import eu.svjatoslav.crtbasic.io.QbBinaryCodec;
 import eu.svjatoslav.crtbasic.machine.MachineMemory;
 import eu.svjatoslav.crtbasic.video.CapturedRegion;
 import eu.svjatoslav.crtbasic.video.ScreenMode;
@@ -1990,26 +1991,41 @@ public final class Interpreter {
         handle.flush();
     }
 
+    /**
+     * The record a GET/PUT target names: the scalar cell's value, or an
+     * array element. Null when the target is a plain scalar.
+     */
+    private BasicRecord recordTarget(final Ast.ReadTarget target, final int line) {
+        if (target.indices().isEmpty()) {
+            return cellFor(target.name()).value instanceof BasicRecord record ? record : null;
+        }
+        final BasicArray array = arrayFor(target.name(), target.indices().size(), line);
+        final int offset = array.offset(subscripts(target.indices(), line));
+        return offset >= 0 && array.data[offset] instanceof BasicRecord record ? record : null;
+    }
+
     /** Byte count of a GET/PUT target variable (record LEN or scalar type size). */
     private int byteSizeOf(final Ast.ReadTarget target, final int line) {
-        if (target.indices().isEmpty()) {
-            final Object value = cellFor(target.name()).value;
-            if (value instanceof BasicRecord record) {
-                return record.def.size;
-            }
-        } else {
-            final BasicArray array = arrayFor(target.name(), target.indices().size(), line);
-            final int offset = array.offset(subscripts(target.indices(), line));
-            if (offset >= 0 && array.data[offset] instanceof BasicRecord record) {
-                return record.def.size;
-            }
+        final BasicRecord record = recordTarget(target, line);
+        if (record != null) {
+            return record.def.size;
         }
         final String key = canonical(target.name());
         if (key.endsWith("$")) {
             // GET into a string variable reads its current length's bytes.
             return str(getTarget(target, line), line).length();
         }
-        return key.endsWith("%") ? 2 : key.endsWith("&") ? 4 : key.endsWith("#") ? 8 : 4;
+        return QbBinaryCodec.sizeOf(key.charAt(key.length() - 1));
+    }
+
+    /** Field descriptors of a record layout, in order, for the binary codec. */
+    private java.util.List<QbBinaryCodec.Field> codecFields(final BasicRecord record) {
+        final java.util.List<QbBinaryCodec.Field> fields = new ArrayList<>();
+        for (final Ast.TypeField field : record.def.fields) {
+            fields.add(new QbBinaryCodec.Field(field.type(),
+                    TypeDef.fieldSize(field, typeDefs), field.stringLength()));
+        }
+        return fields;
     }
 
     /** Decodes bytes (little-endian QB45 layout) into the target variable. */
@@ -2017,140 +2033,45 @@ public final class Interpreter {
         if (target.field() != null) {
             throw error("GET/PUT into a record field is not supported", line);
         }
-        if (target.indices().isEmpty() && cellFor(target.name()).value instanceof BasicRecord record) {
-            decodeRecord(record, bytes);
+        final BasicRecord record = recordTarget(target, line);
+        if (record != null) {
+            final Object[] values = QbBinaryCodec.decodeRecord(codecFields(record), bytes);
+            System.arraycopy(values, 0, record.values, 0, values.length);
             return;
         }
-        if (!target.indices().isEmpty()) {
-            final BasicArray array = arrayFor(target.name(), target.indices().size(), line);
-            final int offset = array.offset(subscripts(target.indices(), line));
-            if (offset >= 0 && array.data[offset] instanceof BasicRecord record) {
-                decodeRecord(record, bytes);
-                return;
-            }
-        }
-        // Scalar target: decode by the variable's type.
         final String key = canonical(target.name());
-        final Object value;
-        if (key.endsWith("$")) {
-            value = Cp437.stringOf(bytes);
-        } else {
-            value = switch (key.charAt(key.length() - 1)) {
-                case '%' -> (double) (short) (bytes.length >= 2
-                        ? (bytes[0] & 0xFF) | (bytes[1] << 8) : 0);
-                case '&' -> (double) (bytes.length >= 4
-                        ? (bytes[0] & 0xFF) | ((bytes[1] & 0xFF) << 8)
-                        | ((bytes[2] & 0xFF) << 16) | (bytes[3] << 24) : 0);
-                case '#' -> bytes.length >= 8 ? java.nio.ByteBuffer.wrap(bytes)
-                        .order(java.nio.ByteOrder.LITTLE_ENDIAN).getDouble() : 0.0;
-                default -> bytes.length >= 4 ? (double) java.nio.ByteBuffer.wrap(bytes)
-                        .order(java.nio.ByteOrder.LITTLE_ENDIAN).getFloat() : 0.0;
-            };
-        }
-        setTarget(target, value, line);
-    }
-
-    /** Decodes a record from its byte image (QB45 little-endian field layout). */
-    private void decodeRecord(final BasicRecord record, final byte[] bytes) {
-        int offset = 0;
-        for (int i = 0; i < record.def.fields.size(); i++) {
-            final Ast.TypeField field = record.def.fields.get(i);
-            final int size = TypeDef.fieldSize(field, typeDefs);
-            final int available = Math.max(0, Math.min(size, bytes.length - offset));
-            switch (field.type().toUpperCase()) {
-                case "STRING" -> {
-                    final byte[] slice = Arrays.copyOfRange(bytes, offset, offset + available);
-                    String text = Cp437.stringOf(slice);
-                    if (text.length() < field.stringLength()) {
-                        text += " ".repeat(field.stringLength() - text.length());
-                    }
-                    record.values[i] = text;
-                }
-                case "INTEGER" -> record.values[i] = available >= 2
-                        ? (double) (short) ((bytes[offset] & 0xFF) | (bytes[offset + 1] << 8)) : 0.0;
-                case "LONG" -> record.values[i] = available >= 4
-                        ? (double) ((bytes[offset] & 0xFF) | ((bytes[offset + 1] & 0xFF) << 8)
-                        | ((bytes[offset + 2] & 0xFF) << 16) | (bytes[offset + 3] << 24)) : 0.0;
-                case "SINGLE" -> record.values[i] = available >= 4
-                        ? (double) java.nio.ByteBuffer.wrap(bytes, offset, 4)
-                        .order(java.nio.ByteOrder.LITTLE_ENDIAN).getFloat() : 0.0;
-                case "DOUBLE" -> record.values[i] = available >= 8
-                        ? java.nio.ByteBuffer.wrap(bytes, offset, 8)
-                        .order(java.nio.ByteOrder.LITTLE_ENDIAN).getDouble() : 0.0;
-                default -> {
-                    // Nested TYPEs: not needed by the current program set.
-                }
-            }
-            offset += size;
-        }
+        setTarget(target, QbBinaryCodec.decodeScalar(key.charAt(key.length() - 1), bytes, 0),
+                line);
     }
 
     /** Encodes the target variable to its QB45 byte image. */
     private byte[] encodeFrom(final Ast.ReadTarget target, final int line) {
-        final BasicRecord record;
-        if (target.indices().isEmpty() && cellFor(target.name()).value instanceof BasicRecord r) {
-            record = r;
-        } else if (!target.indices().isEmpty()) {
-            final BasicArray array = arrayFor(target.name(), target.indices().size(), line);
-            final int offset = array.offset(subscripts(target.indices(), line));
-            record = offset >= 0 && array.data[offset] instanceof BasicRecord r ? r : null;
-        } else {
-            record = null;
-        }
+        final BasicRecord record = recordTarget(target, line);
         if (record == null) {
-            // Scalar: encode by type.
             final String key = canonical(target.name());
-            final Object value = getTarget(target, line);
-            if (key.endsWith("$")) {
-                return Cp437.bytesOf(str(value, line));
-            }
-            final java.nio.ByteBuffer buffer = java.nio.ByteBuffer
-                    .allocate(8).order(java.nio.ByteOrder.LITTLE_ENDIAN);
-            switch (key.charAt(key.length() - 1)) {
-                case '%' -> {
-                    buffer.putShort((short) (int) integer(num(value, line)));
-                    return Arrays.copyOf(buffer.array(), 2);
-                }
-                case '&' -> {
-                    buffer.putInt((int) (long) longInteger(num(value, line)));
-                    return Arrays.copyOf(buffer.array(), 4);
-                }
-                case '#' -> {
-                    buffer.putDouble(num(value, line));
-                    return Arrays.copyOf(buffer.array(), 8);
-                }
-                default -> {
-                    buffer.putFloat((float) num(value, line));
-                    return Arrays.copyOf(buffer.array(), 4);
-                }
-            }
+            final char suffix = key.charAt(key.length() - 1);
+            final Object narrowed = switch (suffix) {
+                case '$' -> str(getTarget(target, line), line);
+                case '%' -> integer(num(getTarget(target, line), line));
+                case '&' -> longInteger(num(getTarget(target, line), line));
+                default -> num(getTarget(target, line), line);
+            };
+            return QbBinaryCodec.encodeScalar(suffix, narrowed);
         }
-        final java.nio.ByteBuffer buffer = java.nio.ByteBuffer
-                .allocate(record.def.size).order(java.nio.ByteOrder.LITTLE_ENDIAN);
-        for (int i = 0; i < record.def.fields.size(); i++) {
-            final Ast.TypeField field = record.def.fields.get(i);
+        final java.util.List<QbBinaryCodec.Field> fields = codecFields(record);
+        final Object[] narrowed = new Object[fields.size()];
+        for (int i = 0; i < fields.size(); i++) {
             final Object value = record.values[i];
-            switch (field.type().toUpperCase()) {
-                case "STRING" -> {
-                    final byte[] text = Cp437.bytesOf(str(value, line));
-                    final byte[] padded = new byte[field.stringLength()];
-                    Arrays.fill(padded, (byte) ' ');
-                    System.arraycopy(text, 0, padded, 0,
-                            Math.min(text.length, field.stringLength()));
-                    buffer.put(padded);
-                }
-                case "INTEGER" -> buffer.putShort((short) (int) integer(num(value, line)));
-                case "LONG" -> buffer.putInt((int) (long) longInteger(num(value, line)));
-                case "SINGLE" -> buffer.putFloat((float) num(value, line));
-                case "DOUBLE" -> buffer.putDouble(num(value, line));
-                default -> buffer.put(new byte[TypeDef.fieldSize(field, typeDefs)]);
-            }
+            narrowed[i] = switch (fields.get(i).type().toUpperCase()) {
+                case "STRING" -> value == null ? "" : str(value, line);
+                case "INTEGER" -> integer(num(value, line));
+                case "LONG" -> longInteger(num(value, line));
+                default -> num(value, line);
+            };
         }
-        return buffer.array();
+        return QbBinaryCodec.encodeRecord(fields, narrowed, record.def.size);
     }
 
-
-
     /** PALETTE: attribute form, USING array form, or the bare reset. */
     private void execPalette(final Ast.PaletteStmt s) {
         if (s.usingArray() != null) {
diff --git a/src/main/java/eu/svjatoslav/crtbasic/io/QbBinaryCodec.java b/src/main/java/eu/svjatoslav/crtbasic/io/QbBinaryCodec.java
new file mode 100644 (file)
index 0000000..913b75f
--- /dev/null
@@ -0,0 +1,143 @@
+package eu.svjatoslav.crtbasic.io;
+
+import eu.svjatoslav.crtbasic.Cp437;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * The QB45 little-endian byte layout for GET/PUT on BINARY and RANDOM
+ * files: INTEGER 2, LONG 4, SINGLE 4, DOUBLE 8 bytes, fixed STRING * n
+ * space-padded. One implementation for scalars and record fields —
+ * previously the layout was written out four times (scalar/record ×
+ * encode/decode) inside the interpreter.
+ *
+ * <p>Numeric range narrowing (Overflow on out-of-range stores) is the
+ * caller's job: values passed to {@link #encodeScalar} and
+ * {@link #encodeRecord} must already be narrowed.
+ */
+public final class QbBinaryCodec {
+
+    /** One record field's layout: type name, byte size, fixed string length. */
+    public record Field(String type, int size, int stringLength) {
+    }
+
+    private QbBinaryCodec() {
+        // Utility class.
+    }
+
+    /** Byte size of a scalar GET/PUT target by type suffix ('%' 2, '&' 4, '#' 8, '!' 4). */
+    public static int sizeOf(final char suffix) {
+        return switch (suffix) {
+            case '%' -> 2;
+            case '#' -> 8;
+            default -> 4; // '&' and '!'
+        };
+    }
+
+    /** Decodes a scalar value from its byte image at {@code offset}. */
+    public static Object decodeScalar(final char suffix, final byte[] bytes, final int offset) {
+        final int available = Math.max(0, bytes.length - offset);
+        return switch (suffix) {
+            case '$' -> Cp437.stringOf(Arrays.copyOfRange(bytes, offset, bytes.length));
+            case '%' -> (double) (short) (available >= 2
+                    ? (bytes[offset] & 0xFF) | (bytes[offset + 1] << 8) : 0);
+            case '&' -> (double) (available >= 4
+                    ? (bytes[offset] & 0xFF) | ((bytes[offset + 1] & 0xFF) << 8)
+                    | ((bytes[offset + 2] & 0xFF) << 16) | (bytes[offset + 3] << 24) : 0);
+            case '#' -> available >= 8 ? ByteBuffer.wrap(bytes, offset, 8)
+                    .order(ByteOrder.LITTLE_ENDIAN).getDouble() : 0.0;
+            default -> available >= 4 ? (double) ByteBuffer.wrap(bytes, offset, 4)
+                    .order(ByteOrder.LITTLE_ENDIAN).getFloat() : 0.0;
+        };
+    }
+
+    /**
+     * Encodes a narrowed scalar ('%'/'&' as Double holding the narrowed
+     * integer, '#'/'!' as Double, '$' as String).
+     */
+    public static byte[] encodeScalar(final char suffix, final Object value) {
+        if (suffix == '$') {
+            return Cp437.bytesOf((String) value);
+        }
+        final double number = (Double) value;
+        final ByteBuffer buffer = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
+        switch (suffix) {
+            case '%' -> {
+                buffer.putShort((short) (int) number);
+                return Arrays.copyOf(buffer.array(), 2);
+            }
+            case '&' -> {
+                buffer.putInt((int) (long) number);
+                return Arrays.copyOf(buffer.array(), 4);
+            }
+            case '#' -> {
+                buffer.putDouble(number);
+                return Arrays.copyOf(buffer.array(), 8);
+            }
+            default -> {
+                buffer.putFloat((float) number);
+                return Arrays.copyOf(buffer.array(), 4);
+            }
+        }
+    }
+
+    /** Decodes a record's fields from its byte image; STRING fields are space-padded. */
+    public static Object[] decodeRecord(final List<Field> fields, final byte[] bytes) {
+        final Object[] values = new Object[fields.size()];
+        int offset = 0;
+        for (int i = 0; i < fields.size(); i++) {
+            final Field field = fields.get(i);
+            final int available = Math.max(0, Math.min(field.size(), bytes.length - offset));
+            switch (field.type().toUpperCase()) {
+                case "STRING" -> {
+                    String text = Cp437.stringOf(
+                            Arrays.copyOfRange(bytes, offset, offset + available));
+                    if (text.length() < field.stringLength()) {
+                        text += " ".repeat(field.stringLength() - text.length());
+                    }
+                    values[i] = text;
+                }
+                case "INTEGER" -> values[i] = decodeScalar('%', bytes, offset);
+                case "LONG" -> values[i] = decodeScalar('&', bytes, offset);
+                case "SINGLE" -> values[i] = decodeScalar('!', bytes, offset);
+                case "DOUBLE" -> values[i] = decodeScalar('#', bytes, offset);
+                default -> values[i] = null; // nested TYPEs: not needed by current programs
+            }
+            offset += field.size();
+        }
+        return values;
+    }
+
+    /**
+     * Encodes a record's fields into its byte image. Numeric values must
+     * already be narrowed by the caller; STRING fields are truncated or
+     * space-padded to their fixed length.
+     */
+    public static byte[] encodeRecord(final List<Field> fields, final Object[] values,
+                                      final int totalSize) {
+        final ByteBuffer buffer = ByteBuffer.allocate(totalSize).order(ByteOrder.LITTLE_ENDIAN);
+        for (int i = 0; i < fields.size(); i++) {
+            final Field field = fields.get(i);
+            final Object value = values[i];
+            switch (field.type().toUpperCase()) {
+                case "STRING" -> {
+                    final byte[] text = Cp437.bytesOf(value == null ? "" : (String) value);
+                    final byte[] padded = new byte[field.stringLength()];
+                    Arrays.fill(padded, (byte) ' ');
+                    System.arraycopy(text, 0, padded, 0,
+                            Math.min(text.length, field.stringLength()));
+                    buffer.put(padded);
+                }
+                case "INTEGER" -> buffer.putShort((short) (int) (double) (Double) value);
+                case "LONG" -> buffer.putInt((int) (long) (double) (Double) value);
+                case "SINGLE" -> buffer.putFloat((float) (double) (Double) value);
+                case "DOUBLE" -> buffer.putDouble((Double) value);
+                default -> buffer.put(new byte[field.size()]);
+            }
+        }
+        return buffer.array();
+    }
+}