From c9b7951a734fb0a23bfcbd8cf0f290d384adb417 Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Thu, 3 Sep 2026 23:51:52 +0300 Subject: [PATCH] refactor(io): unify QB45 binary GET/PUT layout in QbBinaryCodec MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 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. --- .../crtbasic/interp/Interpreter.java | 185 +++++------------- .../svjatoslav/crtbasic/io/QbBinaryCodec.java | 143 ++++++++++++++ 2 files changed, 196 insertions(+), 132 deletions(-) create mode 100644 src/main/java/eu/svjatoslav/crtbasic/io/QbBinaryCodec.java diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java index 94b982f..81e6900 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java +++ b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java @@ -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 codecFields(final BasicRecord record) { + final java.util.List 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 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 index 0000000..913b75f --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/io/QbBinaryCodec.java @@ -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. + * + *

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 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 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(); + } +} -- 2.20.1