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;
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. */
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) {
--- /dev/null
+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();
+ }
+}