From cd1f79a69afc539603fed91e113b25884f5007d2 Mon Sep 17 00:00:00 2001 From: David Given Date: Sun, 18 Oct 2026 23:42:59 +0200 Subject: [PATCH 1/5] Add an autogenerated A2R3 class containing format definitions. --- MODULE.bazel.lock | 10 +- .../com/cowlark/fluxengine/external/A2R3.java | 135 ++++++++++++++++++ .../cowlark/fluxengine/external/BUILD.bazel | 1 + .../cowlark/fluxengine/external/A2R3Test.java | 64 +++++++++ .../cowlark/fluxengine/external/BUILD.bazel | 10 ++ 5 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 java/com/cowlark/fluxengine/external/A2R3.java create mode 100644 javatests/com/cowlark/fluxengine/external/A2R3Test.java diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index d0b19d173..5e457e1c0 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -21,8 +21,8 @@ "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e", "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1", - "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4", - "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d", + "https://bcr.bazel.build/modules/apple_support/2.5.2/MODULE.bazel": "bb1b7081e390a8d3885f642cb0073dfcf8e7a36525e079fa8f5835af7079c0fe", + "https://bcr.bazel.build/modules/apple_support/2.5.2/source.json": "dd51ee925a550e92b55eb41c4c809a967da311a2d2520241252a238a48b39c4c", "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b", "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101", @@ -154,9 +154,9 @@ "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", + "https://bcr.bazel.build/modules/rules_cc/0.2.15/MODULE.bazel": "6a0a4a75a57aa6dc888300d848053a58c6b12a29f89d4304e1c41448514ec6e8", "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84", "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07", - "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", "https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8", @@ -337,7 +337,7 @@ }, "@@rules_python+//python/extensions:config.bzl%config": { "general": { - "bzlTransitiveDigest": "dzD8Q2YmrP3fz8saWLHPmlwPLO91ImtTmP/c9JKTStM=", + "bzlTransitiveDigest": "L+2ZCpb7GIJqvWaXpzkfk/80d916sIFVmSPzrSm6of8=", "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", "recordedInputs": [ "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", @@ -505,7 +505,7 @@ }, "@@rules_python+//python/uv:uv.bzl%uv": { "general": { - "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", + "bzlTransitiveDigest": "IgkgxNrtlAec7OnaFrWoCC5NQhHcY/aIz4jsqcNMm8Y=", "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=", "recordedInputs": [ "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", diff --git a/java/com/cowlark/fluxengine/external/A2R3.java b/java/com/cowlark/fluxengine/external/A2R3.java new file mode 100644 index 000000000..85e9c6088 --- /dev/null +++ b/java/com/cowlark/fluxengine/external/A2R3.java @@ -0,0 +1,135 @@ +package com.cowlark.fluxengine.external; + +import org.indunet.fastproto.ByteOrder; +import org.indunet.fastproto.annotation.BinaryType; +import org.indunet.fastproto.annotation.Expect; +import org.indunet.fastproto.annotation.StringType; +import org.indunet.fastproto.annotation.UInt16Type; +import org.indunet.fastproto.annotation.UInt32ArrayType; +import org.indunet.fastproto.annotation.UInt32Type; +import org.indunet.fastproto.annotation.UInt8Type; + +/* A2R 3.x structures from https://applesaucefdc.com/a2r/. All offsets are relative to + * the structure being decoded, and all multibyte integers are little-endian. + * Chunk bodies exclude ChunkHeader. Capture and track entries exclude the final EndMarker; + * callers must inspect the next mark before decoding an entry. Flux bytes remain packed, + * with 255 extending a timing value. Metadata remains tab-delimited UTF-8 text. */ +public final class A2R3 +{ + public static final int CHUNK_INFO = 0x4f464e49; + public static final int CHUNK_RWCP = 0x50435752; + public static final int CHUNK_SLVD = 0x44564c53; + public static final int CHUNK_META = 0x4154454d; + public static final int INFO_CHUNK_VERSION = 1; + public static final int RWCP_CHUNK_VERSION = 1; + public static final int SLVD_CHUNK_VERSION = 2; + public static final int DEFAULT_PS_PER_TICK = 62500; + + public static final int DRIVE_525_SS_40_QUARTER_STEP = 1; + public static final int DRIVE_35_DS_80_APPLE_CLV = 2; + public static final int DRIVE_525_DS_80 = 3; + public static final int DRIVE_525_DS_40 = 4; + public static final int DRIVE_35_DS_80 = 5; + public static final int DRIVE_8_DS = 6; + public static final int DRIVE_3_DS_80 = 7; + public static final int DRIVE_3_DS_40 = 8; + + public static final int TIMING = 1; + public static final int BITS = 2; + public static final int XTIMING = 3; + public static final int CAPTURE_MARK = 0x43; + public static final int TRACK_MARK = 0x54; + public static final int END_MARK = 0x58; + public static final byte[] FILEHEADER = + {'A', '2', 'R', '3', (byte) 0xff, 0x0a, 0x0d, 0x0a}; + + private A2R3() + { + } + + public static class FileHeader + { + @Expect(offset = 0, bytes = {'A', '2', 'R', '3', (byte) 0xff, 0x0a, 0x0d, 0x0a}) + transient int _expects; + } + + public static class ChunkHeader + { + @UInt32Type(offset = 0, byteOrder = ByteOrder.LITTLE) public long id; + @UInt32Type(offset = 4, byteOrder = ByteOrder.LITTLE) public long size; + } + + /* The creator is exactly 32 UTF-8 bytes, padded with spaces by the writer. */ + public static class Info + { + @Expect(offset = 0, bytes = {INFO_CHUNK_VERSION}) transient int _expects; + @StringType(offset = 1, length = 32, charset = "UTF-8") + public String creator = " "; + @UInt8Type(offset = 33) public int driveType; + @UInt8Type(offset = 34) public int writeProtected; + @UInt8Type(offset = 35) public int synchronizedCapture; + @UInt8Type(offset = 36) public int hardSectorCount; + } + + /* The entries start at offset 16 in the chunk body. Reserved bytes must be zero. */ + public static class RawCaptureHeader + { + @Expect(offset = 0, bytes = {RWCP_CHUNK_VERSION}) transient int _expects; + @UInt32Type(offset = 1, byteOrder = ByteOrder.LITTLE) + public long resolution = DEFAULT_PS_PER_TICK; + @BinaryType(offset = 5, length = 11) public byte[] reserved = new byte[11]; + } + + public static class SolvedHeader + { + @Expect(offset = 0, bytes = {SLVD_CHUNK_VERSION}) transient int _expects; + @UInt32Type(offset = 1, byteOrder = ByteOrder.LITTLE) + public long resolution = DEFAULT_PS_PER_TICK; + @BinaryType(offset = 5, length = 11) public byte[] reserved = new byte[11]; + } + + /* Index signals are absolute tick offsets. Location is a quarter track for drive type 1, + * or (cylinder << 1) + side for all other drive types. Counts and sizes must match arrays. */ + public static class Capture + { + @Expect(offset = 0, bytes = {CAPTURE_MARK}) transient int _expects; + @UInt8Type(offset = 1) public int captureType; + @UInt16Type(offset = 2, byteOrder = ByteOrder.LITTLE) public int location; + @UInt8Type(offset = 4) public int indexSignalCount; + @UInt32ArrayType(offset = 5, lengthRef = "$indexSignalCount", byteOrder = ByteOrder.LITTLE) + public long[] indexSignals = new long[0]; + } + + public static class Track + { + @Expect(offset = 0, bytes = {TRACK_MARK}) transient int _expects; + @UInt16Type(offset = 1, byteOrder = ByteOrder.LITTLE) public int location; + @UInt8Type(offset = 3) public int mirrorDistanceOutward; + @UInt8Type(offset = 4) public int mirrorDistanceInward; + @BinaryType(offset = 5, length = 6) public byte[] reserved = new byte[6]; + @UInt8Type(offset = 11) public int indexSignalCount; + @UInt32ArrayType(offset = 12, lengthRef = "$indexSignalCount", byteOrder = ByteOrder.LITTLE) + public long[] indexSignals = new long[0]; + } + + /* Follows the variable index array in either Capture or Track. FastProto lengthRef + * references fields directly, so the variable-position tail is a separate structure. */ + public static class FluxData + { + @UInt32Type(offset = 0, byteOrder = ByteOrder.LITTLE) public long dataSize; + @BinaryType(offset = 4, lengthRef = "$dataSize") public byte[] data = new byte[0]; + } + + public static class EndMarker + { + @Expect(offset = 0, bytes = {END_MARK}) transient int _expects; + } + + /* Includes the META chunk header so the UTF-8 byte length is available to FastProto. */ + public static class Metadata + { + @Expect(offset = 0, bytes = {'M', 'E', 'T', 'A'}) transient int _expects; + @UInt32Type(offset = 4, byteOrder = ByteOrder.LITTLE) public long size; + @StringType(offset = 8, lengthRef = "$size", charset = "UTF-8") public String text = ""; + } +} diff --git a/java/com/cowlark/fluxengine/external/BUILD.bazel b/java/com/cowlark/fluxengine/external/BUILD.bazel index 3ce49caac..208eab71f 100644 --- a/java/com/cowlark/fluxengine/external/BUILD.bazel +++ b/java/com/cowlark/fluxengine/external/BUILD.bazel @@ -23,5 +23,6 @@ java_library( "//java/com/cowlark/fluxengine/core", "//java/com/cowlark/fluxengine/data", "//java/com/cowlark/fluxengine/wiring", + "@maven//:org_indunet_fastproto_core", ], ) diff --git a/javatests/com/cowlark/fluxengine/external/A2R3Test.java b/javatests/com/cowlark/fluxengine/external/A2R3Test.java new file mode 100644 index 000000000..cabb23414 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/external/A2R3Test.java @@ -0,0 +1,64 @@ +package com.cowlark.fluxengine.external; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.indunet.fastproto.FastProto; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class A2R3Test +{ + @Test + public void captureWithUnsignedIndexSignals() + { + byte[] bytes = ByteBuffer.allocate(20).order(ByteOrder.LITTLE_ENDIAN) + .put((byte) 'C').put((byte) 3).putShort((short) 300).put((byte) 2) + .putInt(0x80000000).putInt(0xffffffff).putInt(3) + .put(new byte[] {(byte) 255, (byte) 255, 10}).array(); + A2R3.Capture capture = FastProto.decode(bytes, A2R3.Capture.class); + assertEquals(300, capture.location); + assertArrayEquals(new long[] {0x80000000L, 0xffffffffL}, capture.indexSignals); + A2R3.FluxData data = FastProto.decode(Arrays.copyOfRange(bytes, 13, 20), A2R3.FluxData.class); + assertArrayEquals(new byte[] {(byte) 255, (byte) 255, 10}, data.data); + assertArrayEquals(Arrays.copyOf(bytes, 13), FastProto.encode(capture)); + assertArrayEquals(Arrays.copyOfRange(bytes, 13, 20), FastProto.encode(data)); + } + + @Test + public void trackWithoutIndexSignals() + { + byte[] bytes = ByteBuffer.allocate(18).order(ByteOrder.LITTLE_ENDIAN) + .put((byte) 'T').putShort((short) 12).put((byte) 1).put((byte) 2) + .put(new byte[6]).put((byte) 0).putInt(2).put(new byte[] {64, 65}).array(); + A2R3.Track track = FastProto.decode(bytes, A2R3.Track.class); + assertEquals(12, track.location); + assertEquals(0, track.indexSignals.length); + A2R3.FluxData data = FastProto.decode(Arrays.copyOfRange(bytes, 12, 18), A2R3.FluxData.class); + assertArrayEquals(new byte[] {64, 65}, data.data); + assertArrayEquals(Arrays.copyOf(bytes, 12), FastProto.encode(track)); + assertArrayEquals(Arrays.copyOfRange(bytes, 12, 18), FastProto.encode(data)); + } + + @Test + public void fixedHeadersAndMetadata() + { + assertArrayEquals(A2R3.FILEHEADER, FastProto.encode(new A2R3.FileHeader())); + assertArrayEquals(new byte[] {'X'}, FastProto.encode(new A2R3.EndMarker())); + assertEquals(37, FastProto.encode(new A2R3.Info()).length); + byte[] rawHeader = FastProto.encode(new A2R3.RawCaptureHeader()); + assertEquals(16, rawHeader.length); + assertEquals(62500, ByteBuffer.wrap(rawHeader).order(ByteOrder.LITTLE_ENDIAN).getInt(1)); + assertEquals(2, FastProto.encode(new A2R3.SolvedHeader())[0]); + byte[] metadata = {'M', 'E', 'T', 'A', 9, 0, 0, 0, 't', 'i', 't', 'l', 'e', 9, + (byte) 0xc3, (byte) 0xa9, 10}; + A2R3.Metadata decoded = FastProto.decode(metadata, A2R3.Metadata.class); + assertEquals("title\té\n", decoded.text); + assertArrayEquals(metadata, FastProto.encode(decoded)); + } +} diff --git a/javatests/com/cowlark/fluxengine/external/BUILD.bazel b/javatests/com/cowlark/fluxengine/external/BUILD.bazel index 3127346ac..f53d94ac4 100644 --- a/javatests/com/cowlark/fluxengine/external/BUILD.bazel +++ b/javatests/com/cowlark/fluxengine/external/BUILD.bazel @@ -75,3 +75,13 @@ java_test( "@maven//:junit_junit", ], ) + +java_test( + name = "A2R3Test", + srcs = ["A2R3Test.java"], + deps = [ + "//java/com/cowlark/fluxengine/external", + "@maven//:junit_junit", + "@maven//:org_indunet_fastproto", + ], +) From 2010bf77e39d1f87a08ab3365806531f0f568d7a Mon Sep 17 00:00:00 2001 From: David Given Date: Sun, 18 Oct 2026 23:56:38 +0200 Subject: [PATCH 2/5] Do the plumbing for the A2R2/A2R3 conversion. --- MODULE.bazel.lock | 6 +- .../fluxengine/fluxsource/A2R2FluxSource.java | 159 ++++++++++++++++++ ...rator.java => A2R2FluxSourceIterator.java} | 0 .../fluxengine/fluxsource/A2RFluxSource.java | 155 ++--------------- .../fluxengine/fluxsource/FluxSource.java | 2 +- .../cowlark/fluxengine/external/BUILD.bazel | 1 + .../fluxsource/A2RFluxSourceTest.java | 40 +++-- 7 files changed, 208 insertions(+), 155 deletions(-) create mode 100644 java/com/cowlark/fluxengine/fluxsource/A2R2FluxSource.java rename java/com/cowlark/fluxengine/fluxsource/{A2RFluxSourceIterator.java => A2R2FluxSourceIterator.java} (100%) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 5e457e1c0..ec08c99ba 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -266,7 +266,7 @@ "moduleExtensions": { "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { "general": { - "bzlTransitiveDigest": "NRXra7941UfmNUyIxnLt82V5hULluVGL2nBsijTl4j4=", + "bzlTransitiveDigest": "BsgERqYptkJeeC8wFaLj251IoTZTSy/58WA6kVp0q8I=", "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=", "recordedInputs": [ "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools", @@ -288,7 +288,7 @@ }, "@@rules_android+//bzlmod_extensions:apksig.bzl%apksig_extension": { "general": { - "bzlTransitiveDigest": "qEcqhUuyYlPmvAtyYRqS+4Y7cRHA2Vho3JIBJZJJX+s=", + "bzlTransitiveDigest": "0A8O20aQTIZnt20CyMK1wzktVUyJO1Y3jI6cS1WftKQ=", "usagesDigest": "zr/niBQ/s2fHozWAsg4vI70wAxcuFjG+QtM15qGkq9o=", "recordedInputs": [ "REPO_MAPPING:rules_android+,bazel_tools bazel_tools" @@ -306,7 +306,7 @@ }, "@@rules_android+//bzlmod_extensions:com_android_dex.bzl%com_android_dex_extension": { "general": { - "bzlTransitiveDigest": "gTuOqknGgqPw9YQSCY+qXxgxVwiLRGdBCsCe4i4q/xw=", + "bzlTransitiveDigest": "stTLqB1DjqvC1RcosQO9gDBmAlvTegb/9MYDyiRNuFo=", "usagesDigest": "c1Y/KGGjUYCyd8zNIVTUh1bynVXRFz6xGKaSCBpQANM=", "recordedInputs": [ "REPO_MAPPING:rules_android+,bazel_tools bazel_tools" diff --git a/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSource.java b/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSource.java new file mode 100644 index 000000000..2fe3f2a30 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSource.java @@ -0,0 +1,159 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.Locations; +import com.cowlark.fluxengine.external.DriveType; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeMap; + +/* A flux source which reads an A2R version 2 flux file, ported from + * lib/fluxsource/a2rfluxsource.cc. */ +public class A2R2FluxSource extends FluxSource +{ + private final TreeMap v2data = new TreeMap<>(); + private final A2rFluxSourceProto config; + private final Bytes data; + protected ConfigProto extraConfig; + private int version; + + public A2R2FluxSource(A2rFluxSourceProto config) + { + this.config = config; + data = readFile(config.getFilename()); + ByteReader br = new ByteReader(data); + + switch (br.readBe32()) + { + case 0x41325232: + { + version = 2; + Bytes info = findChunk(new Bytes("INFO")); + int disktype = info.getByte(33) & 0xff; + DriveType driveType; + if (disktype == 1) + { + /* 5.25" with quarter stepping. */ + driveType = DriveType.DRIVETYPE_APPLE2; + } else + { + /* 3.5". */ + driveType = DriveType.DRIVETYPE_80TRACK; + } + + Bytes stream = findChunk(new Bytes("STRM")); + ByteReader bsr = new ByteReader(stream); + for (; ; ) + { + int location = bsr.read8(); + if (location == 0xff) + break; + CylinderHead key = (disktype == 1) ? + new CylinderHead(location, 0) : + new CylinderHead(location >> 1, location & 1); + + bsr.skip(1); + int len = bsr.readLe32(); + double index = (double) bsr.readLe32() * 125; + A2Rv2Flux entry = v2data.get(key); + if (entry == null) + { + entry = new A2Rv2Flux(); + entry.index = index; + v2data.put(key, entry); + } + + entry.flux.add(bsr.read(len)); + } + + List chs = new ArrayList<>(v2data.keySet()); + + ConfigProto.Builder builder = ConfigProto.newBuilder(); + builder.getDriveBuilder().setDriveType(driveType); + builder.getDriveBuilder().setTracks(Locations.convertCylinderHeadsToString(chs)); + extraConfig = builder.build(); + break; + } + + default: + error("unsupported A2R version"); + } + } + + private static Bytes readFile(String filename) + { + try + { + return new Bytes(Files.readAllBytes(Path.of(filename))); + } catch (IOException e) + { + throw new FluxEngineException( + "cannot open input file '" + filename + "': " + e.getMessage()); + } + } + + private static void error(String message) + { + throw new FluxEngineException(message); + } + + @Override + public ConfigProto getExtraConfig() + { + return extraConfig; + } + + @Override + public FluxSourceIterator readFlux(FluxReadParameters parameters) + { + switch (version) + { + case 2: + { + A2Rv2Flux entry = + v2data.get(new CylinderHead(parameters.cylinder(), parameters.head())); + if (entry != null) + return new A2RFluxSourceIterator(entry.flux, entry.index); + else + return new EmptyFluxSourceIterator(); + } + + default: + error("unsupported A2R version"); + return null; + } + } + + private Bytes findChunk(Bytes id) + { + long offset = 8; + while (offset < data.size()) + { + ByteReader br = new ByteReader(data); + br.seek((int) offset); + if (br.read(4).equals(id)) + { + int size = br.readLe32(); + return br.read(size); + } + + offset += (long) br.readLe32() + 8; + } + + error("A2R file missing chunk"); + return null; + } + + static class A2Rv2Flux + { + List flux = new ArrayList<>(); + double index; + } +} diff --git a/java/com/cowlark/fluxengine/fluxsource/A2RFluxSourceIterator.java b/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSourceIterator.java similarity index 100% rename from java/com/cowlark/fluxengine/fluxsource/A2RFluxSourceIterator.java rename to java/com/cowlark/fluxengine/fluxsource/A2R2FluxSourceIterator.java diff --git a/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java b/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java index 09385db95..3ed944031 100644 --- a/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java +++ b/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java @@ -1,161 +1,34 @@ package com.cowlark.fluxengine.fluxsource; -import com.cowlark.fluxengine.config.ConfigProto; -import com.cowlark.fluxengine.core.ByteReader; -import com.cowlark.fluxengine.core.Bytes; import com.cowlark.fluxengine.core.FluxEngineException; -import com.cowlark.fluxengine.data.CylinderHead; -import com.cowlark.fluxengine.data.Locations; -import com.cowlark.fluxengine.external.DriveType; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.TreeMap; +import java.util.Arrays; -/** - * A flux source which reads an A2R flux file, ported from - * lib/fluxsource/a2rfluxsource.cc. - */ -public class A2RFluxSource extends FluxSource +/* Selects the A2R reader using the file's four-byte magic number. */ +public final class A2RFluxSource { - private final TreeMap v2data = new TreeMap<>(); - private final A2rFluxSourceProto config; - private final Bytes data; - protected ConfigProto extraConfig; - private int version; - - public A2RFluxSource(A2rFluxSourceProto config) + private A2RFluxSource() { - this.config = config; - data = readFile(config.getFilename()); - ByteReader br = new ByteReader(data); - - switch (br.readBe32()) - { - case 0x41325232: - { - version = 2; - Bytes info = findChunk(new Bytes("INFO")); - int disktype = info.getByte(33) & 0xff; - DriveType driveType; - if (disktype == 1) - { - /* 5.25" with quarter stepping. */ - driveType = DriveType.DRIVETYPE_APPLE2; - } else - { - /* 3.5". */ - driveType = DriveType.DRIVETYPE_80TRACK; - } - - Bytes stream = findChunk(new Bytes("STRM")); - ByteReader bsr = new ByteReader(stream); - for (; ; ) - { - int location = bsr.read8(); - if (location == 0xff) - break; - CylinderHead key = (disktype == 1) ? - new CylinderHead(location, 0) : - new CylinderHead(location >> 1, location & 1); - - bsr.skip(1); - int len = bsr.readLe32(); - double index = (double) bsr.readLe32() * 125; - A2Rv2Flux entry = v2data.get(key); - if (entry == null) - { - entry = new A2Rv2Flux(); - entry.index = index; - v2data.put(key, entry); - } - - entry.flux.add(bsr.read(len)); - } - - List chs = new ArrayList<>(v2data.keySet()); - - ConfigProto.Builder builder = ConfigProto.newBuilder(); - builder.getDriveBuilder().setDriveType(driveType); - builder.getDriveBuilder().setTracks(Locations.convertCylinderHeadsToString(chs)); - extraConfig = builder.build(); - break; - } - - default: - error("unsupported A2R version"); - } } - private static Bytes readFile(String filename) + public static FluxSource create(A2rFluxSourceProto config) { - try + byte[] magic; + try (InputStream stream = Files.newInputStream(Path.of(config.getFilename()))) { - return new Bytes(Files.readAllBytes(Path.of(filename))); + magic = stream.readNBytes(4); } catch (IOException e) { throw new FluxEngineException( - "cannot open input file '" + filename + "': " + e.getMessage()); - } - } - - private static void error(String message) - { - throw new FluxEngineException(message); - } - - @Override - public ConfigProto getExtraConfig() - { - return extraConfig; - } - - @Override - public FluxSourceIterator readFlux(FluxReadParameters parameters) - { - switch (version) - { - case 2: - { - A2Rv2Flux entry = - v2data.get(new CylinderHead(parameters.cylinder(), parameters.head())); - if (entry != null) - return new A2RFluxSourceIterator(entry.flux, entry.index); - else - return new EmptyFluxSourceIterator(); - } - - default: - error("unsupported A2R version"); - return null; + "cannot open input file '" + config.getFilename() + "': " + e.getMessage()); } - } - private Bytes findChunk(Bytes id) - { - long offset = 8; - while (offset < data.size()) - { - ByteReader br = new ByteReader(data); - br.seek((int) offset); - if (br.read(4).equals(id)) - { - int size = br.readLe32(); - return br.read(size); - } - - offset += (long) br.readLe32() + 8; - } + if (Arrays.equals(magic, new byte[] {'A', '2', 'R', '2'})) + return new A2R2FluxSource(config); - error("A2R file missing chunk"); - return null; - } - - static class A2Rv2Flux - { - List flux = new ArrayList<>(); - double index; + throw new FluxEngineException("unsupported A2R version"); } -} \ No newline at end of file +} diff --git a/java/com/cowlark/fluxengine/fluxsource/FluxSource.java b/java/com/cowlark/fluxengine/fluxsource/FluxSource.java index 0982f8562..e1bb815f9 100644 --- a/java/com/cowlark/fluxengine/fluxsource/FluxSource.java +++ b/java/com/cowlark/fluxengine/fluxsource/FluxSource.java @@ -21,7 +21,7 @@ public static FluxSource create(ConfigProto config, Supplier usbFact case FLUXTYPE_TEST_PATTERN -> new TestPatternFluxSource(fluxSourceProto.getTestPattern()); case FLUXTYPE_SCP -> new ScpFluxSource(fluxSourceProto.getScp()); - case FLUXTYPE_A2R -> new A2RFluxSource(fluxSourceProto.getA2R()); + case FLUXTYPE_A2R -> A2RFluxSource.create(fluxSourceProto.getA2R()); case FLUXTYPE_CWF -> new CwfFluxSource(fluxSourceProto.getCwf()); case FLUXTYPE_DMK -> new DmkFluxSource(fluxSourceProto.getDmk()); case FLUXTYPE_FLUX -> new Fl2FluxSource(fluxSourceProto.getFl2()); diff --git a/javatests/com/cowlark/fluxengine/external/BUILD.bazel b/javatests/com/cowlark/fluxengine/external/BUILD.bazel index f53d94ac4..ee53a50ab 100644 --- a/javatests/com/cowlark/fluxengine/external/BUILD.bazel +++ b/javatests/com/cowlark/fluxengine/external/BUILD.bazel @@ -83,5 +83,6 @@ java_test( "//java/com/cowlark/fluxengine/external", "@maven//:junit_junit", "@maven//:org_indunet_fastproto", + "@maven//:org_indunet_fastproto_core", ], ) diff --git a/javatests/com/cowlark/fluxengine/fluxsource/A2RFluxSourceTest.java b/javatests/com/cowlark/fluxengine/fluxsource/A2RFluxSourceTest.java index d05c2c37f..edecb5acd 100644 --- a/javatests/com/cowlark/fluxengine/fluxsource/A2RFluxSourceTest.java +++ b/javatests/com/cowlark/fluxengine/fluxsource/A2RFluxSourceTest.java @@ -3,6 +3,7 @@ import static com.cowlark.fluxengine.external.DriveType.DRIVETYPE_40TRACK; import static com.cowlark.fluxengine.external.DriveType.DRIVETYPE_80TRACK; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat; import com.cowlark.fluxengine.config.ConfigBuilder; @@ -10,6 +11,7 @@ import com.cowlark.fluxengine.config.DriveProto; import com.cowlark.fluxengine.core.ByteWriter; import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; import com.cowlark.fluxengine.external.DriveType; import com.google.common.truth.extensions.proto.ProtoTruth; import org.junit.Test; @@ -35,7 +37,7 @@ private static Path writeTempFile() throws IOException for (int b : new int[]{'A', '2', 'R', '2', 0xff, 0x0a, 0x0d, 0x0a}) bw.write8(b); - // INFO chunk: version, 32-char padding, disktype (=2, 3.5"), ... + /* INFO chunk: version, 32-char padding, disktype (=2, 3.5"), ... */ writeChunk(bw, "INFO"); int sizePos = bw.pos(); bw.writeLe32(0); @@ -50,20 +52,18 @@ private static Path writeTempFile() throws IOException bw.writeLe32(infoEnd - sizePos - 4); bw.seek(infoEnd); - // STRM chunk: one record for track 0 head 0 with flux data 30,30 (pulses - // at 30 a2r ticks). The headed iterating sums bytes until non-0xff, - // so this encodes three intervals: 30, 30 and a trailing 255-less end. + /* STRM chunk: one record for track 0 head 0 with three intervals of 30 ticks. */ writeChunk(bw, "STRM"); int sizePos2 = bw.pos(); bw.writeLe32(0); - bw.write8(0); // location: cylinder 0, head 0 - bw.write8(0); // unused byte - bw.writeLe32(3); // data length - bw.writeLe32(0); // index + bw.write8(0); /* location: cylinder 0, head 0 */ + bw.write8(0); /* unused byte */ + bw.writeLe32(3); /* data length */ + bw.writeLe32(0); /* index */ bw.write8(30); bw.write8(30); bw.write8(30); - bw.write8(0xff); // stream terminator + bw.write8(0xff); /* stream terminator */ int strmSize = bw.pos(); bw.seek(sizePos2); bw.writeLe32(strmSize - sizePos2 - 4); @@ -86,11 +86,13 @@ public void readsTracks() throws IOException { Path path = writeTempFile(); - A2RFluxSource source = new A2RFluxSource(A2rFluxSourceProto + FluxSource source = A2RFluxSource.create(A2rFluxSourceProto .newBuilder() .setFilename(path.toString()) .build()); + assertThat(source).isInstanceOf(A2R2FluxSource.class); + FluxSourceIterator iterator = source.readFlux(FluxReadParameters.builder().setCylinder(0).setHead(0).build()); assertThat(iterator.hasNext()).isTrue(); @@ -114,4 +116,22 @@ public void readsTracks() throws IOException .build()) .build()); } + @Test + public void rejectsUnsupportedMagic() throws IOException + { + Path path = Files.createTempFile("flux", ".a2r"); + try + { + for (byte[] magic : new byte[][] { + {'A', '2', 'R', '3'}, {'N', 'O', 'P', 'E'}, {'A', '2'}, {}}) + { + Files.write(path, magic); + assertThrows(FluxEngineException.class, () -> A2RFluxSource.create( + A2rFluxSourceProto.newBuilder().setFilename(path.toString()).build())); + } + } finally + { + Files.delete(path); + } + } } \ No newline at end of file From 003352cf6d5598e90ee9856e5665fa54ab4daec9 Mon Sep 17 00:00:00 2001 From: David Given Date: Mon, 19 Oct 2026 00:14:02 +0200 Subject: [PATCH 3/5] Add the generated, untests A2R3FluxSource. --- AGENTS.md | 2 + .../fluxengine/fluxsource/A2R2FluxSource.java | 2 +- .../fluxsource/A2R2FluxSourceIterator.java | 4 +- .../fluxengine/fluxsource/A2R3FluxSource.java | 275 ++++++++++++++++++ .../fluxengine/fluxsource/A2RFluxSource.java | 3 + .../cowlark/fluxengine/fluxsource/BUILD.bazel | 4 + .../fluxsource/A2R3FluxSourceTest.java | 116 ++++++++ .../cowlark/fluxengine/fluxsource/BUILD.bazel | 17 ++ 8 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 java/com/cowlark/fluxengine/fluxsource/A2R3FluxSource.java create mode 100644 javatests/com/cowlark/fluxengine/fluxsource/A2R3FluxSourceTest.java diff --git a/AGENTS.md b/AGENTS.md index 463099799..dd1527f9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,8 @@ Useful commands: ## Code style - Explicit types, no `var`. +- Divide code into logical paragraphs separated by blank lines, with a block comment + introducing each paragraph to explain its purpose. - Prefer Guava utilities over hand-rolled checks: `Strings.nullToEmpty(...)` instead of explicit null checks; use `ImmutableList` for returned collections. - Prefer `System.out.printf(...)` over `System.out.println(String.format(...))`. diff --git a/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSource.java b/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSource.java index 2fe3f2a30..2e2dcb96f 100644 --- a/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSource.java +++ b/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSource.java @@ -120,7 +120,7 @@ public FluxSourceIterator readFlux(FluxReadParameters parameters) A2Rv2Flux entry = v2data.get(new CylinderHead(parameters.cylinder(), parameters.head())); if (entry != null) - return new A2RFluxSourceIterator(entry.flux, entry.index); + return new A2R2FluxSourceIterator(entry.flux, entry.index); else return new EmptyFluxSourceIterator(); } diff --git a/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSourceIterator.java b/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSourceIterator.java index 8fd010e45..3b4bd41a4 100644 --- a/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSourceIterator.java +++ b/java/com/cowlark/fluxengine/fluxsource/A2R2FluxSourceIterator.java @@ -11,13 +11,13 @@ * Iterator over the flux revolutions of one track in an A2R file, ported from * lib/fluxsource/a2rfluxsource.cc. */ -class A2RFluxSourceIterator implements FluxSourceIterator +class A2R2FluxSourceIterator implements FluxSourceIterator { private final List flux; private final double index; private int count; - A2RFluxSourceIterator(List flux, double index) + A2R2FluxSourceIterator(List flux, double index) { this.flux = flux; this.index = index; diff --git a/java/com/cowlark/fluxengine/fluxsource/A2R3FluxSource.java b/java/com/cowlark/fluxengine/fluxsource/A2R3FluxSource.java new file mode 100644 index 000000000..cdba4faa1 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/A2R3FluxSource.java @@ -0,0 +1,275 @@ +package com.cowlark.fluxengine.fluxsource; + +import static com.cowlark.fluxengine.wiring.FluxEngine.NS_PER_TICK; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.config.DriveProto; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Locations; +import com.cowlark.fluxengine.external.A2R3; +import com.cowlark.fluxengine.external.DriveType; +import com.google.common.collect.ImmutableList; +import org.indunet.fastproto.FastProto; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.TreeMap; + +/* Reads raw A2R3 captures. SLVD, META and unknown chunks are skipped. */ +public class A2R3FluxSource extends FluxSource +{ + private final TreeMap> tracks = new TreeMap<>(); + private final ConfigProto extraConfig; + + public A2R3FluxSource(A2rFluxSourceProto config) + { + /* Load the file and validate its format signature before reading chunks. */ + ByteBuffer file = Bytes + .readFromFile(Path.of(config.getFilename())) + .toByteBuffer() + .order(ByteOrder.LITTLE_ENDIAN); + FastProto.decode(take(file, 8), A2R3.FileHeader.class); + + /* Read bounded chunk bodies, ignoring chunks without raw capture data. */ + A2R3.Info info = null; + while (file.hasRemaining()) + { + A2R3.ChunkHeader chunk = FastProto.decode(take(file, 8), A2R3.ChunkHeader.class); + require(chunk.size <= file.remaining(), "truncated chunk"); + + /* Limit each decoder to this chunk and advance past unhandled chunks. */ + ByteBuffer body = file.slice().order(ByteOrder.LITTLE_ENDIAN); + body.limit((int) chunk.size); + file.position(file.position() + (int) chunk.size); + + /* INFO supplies the drive geometry needed to interpret capture locations. */ + require(info != null || chunk.id == A2R3.CHUNK_INFO, "INFO must be first"); + if (chunk.id == A2R3.CHUNK_INFO) + { + require(info == null, "duplicate INFO chunk"); + info = FastProto.decode(take(body, 37), A2R3.Info.class); + } else if (chunk.id == A2R3.CHUNK_RWCP) + readCaptures(body, info); + } + + /* Translate the recorded drive geometry into FluxEngine configuration. */ + require(info != null, "missing INFO chunk"); + DriveType driveType = switch (info.driveType) + { + case A2R3.DRIVE_525_SS_40_QUARTER_STEP -> DriveType.DRIVETYPE_APPLE2; + case A2R3.DRIVE_525_DS_40, A2R3.DRIVE_3_DS_40 -> DriveType.DRIVETYPE_40TRACK; + case A2R3.DRIVE_35_DS_80_APPLE_CLV, A2R3.DRIVE_525_DS_80, A2R3.DRIVE_35_DS_80, + A2R3.DRIVE_3_DS_80 -> DriveType.DRIVETYPE_80TRACK; + case A2R3.DRIVE_8_DS -> DriveType.DRIVETYPE_UNKNOWN; + default -> throw new FluxEngineException("unsupported A2R3 drive type"); + }; + + /* Restrict subsequent reads to the locations present in the raw captures. */ + extraConfig = ConfigProto + .newBuilder() + .setDrive(DriveProto + .newBuilder() + .setDriveType(driveType) + .setHardSectorCount(info.hardSectorCount) + .setTracks(Locations.convertCylinderHeadsToString(ImmutableList.copyOf( + tracks.keySet())))) + .build(); + } + + private void readCaptures(ByteBuffer body, A2R3.Info info) + { + /* Resolution belongs to the RWCP chunk and can differ between chunks. */ + A2R3.RawCaptureHeader header = + FastProto.decode(take(body, 16), A2R3.RawCaptureHeader.class); + require(header.resolution > 0, "zero resolution"); + + /* Capture entries are packed together and end with a single marker byte. */ + while (body.hasRemaining()) + { + if ((body.get(body.position()) & 0xff) == A2R3.END_MARK) + { + FastProto.decode(take(body, 1), A2R3.EndMarker.class); + require(!body.hasRemaining(), "data after capture terminator"); + return; + } + + /* Decode the capture header together with its variable-length index array. */ + require(body.remaining() >= 5, "truncated capture header"); + int indexCount = body.get(body.position() + 4) & 0xff; + A2R3.Capture capture = + FastProto.decode(take(body, 5 + 4 * indexCount), A2R3.Capture.class); + + /* Bound the payload before handing its recorded length to FastProto. */ + require(body.remaining() >= 4, "missing capture size"); + long size = Integer.toUnsignedLong(body.getInt(body.position())); + require(size <= body.remaining() - 4, "truncated capture data"); + A2R3.FluxData flux = FastProto.decode(take(body, 4 + (int) size), A2R3.FluxData.class); + + /* Validate the capture type and chronological order of its index signals. */ + require( + capture.captureType == A2R3.TIMING || capture.captureType == A2R3.XTIMING || + capture.captureType == A2R3.BITS, "unknown capture type"); + long previous = 0; + for (long index : capture.indexSignals) + { + require(index >= previous, "index signals out of order"); + previous = index; + } + + /* Retain each capture separately at its physical cylinder and head. */ + CylinderHead location = info.driveType == A2R3.DRIVE_525_SS_40_QUARTER_STEP ? + new CylinderHead(capture.location, 0) : + new CylinderHead(capture.location >> 1, capture.location & 1); + tracks + .computeIfAbsent(location, key -> new ArrayList<>()) + .add(new Capture(capture, flux.data, header.resolution)); + } + + /* Reaching the chunk boundary without an end marker indicates incomplete data. */ + throw new FluxEngineException("invalid A2R3: missing capture terminator"); + } + + private static byte[] take(ByteBuffer buffer, int size) + { + require(size >= 0 && size <= buffer.remaining(), "truncated structure"); + byte[] bytes = new byte[size]; + buffer.get(bytes); + return bytes; + } + + private static void require(boolean condition, String message) + { + if (!condition) + throw new FluxEngineException("invalid A2R3: " + message); + } + + @Override + public ConfigProto getExtraConfig() + { + return extraConfig; + } + + @Override + public FluxSourceIterator readFlux(FluxReadParameters parameters) + { + List captures = + tracks.get(new CylinderHead(parameters.cylinder(), parameters.head())); + if (captures == null) + return new EmptyFluxSourceIterator(); + + /* Decode captures lazily, with independent iteration for each read request. */ + Iterator iterator = captures.iterator(); + return new FluxSourceIterator() + { + @Override + public boolean hasNext() + { + return iterator.hasNext(); + } + + @Override + public Fluxmap next() + { + return iterator.next().decode(); + } + }; + } + + private record Capture(A2R3.Capture header, byte[] data, long resolution) + { + Fluxmap decode() + { + /* Merge flux transitions and recorded index signals onto one timeline. */ + Fluxmap map = new Fluxmap(); + Timeline timeline = new Timeline(map, header.indexSignals, resolution); + long timePs = 0; + + /* Bits have a fixed clock; timing captures use the chunk resolution. */ + switch (header.captureType) + { + case A2R3.BITS -> + { + /* Legacy bits are MSB first, normalized to 4 microseconds per bit. */ + for (byte value : data) + { + for (int mask = 0x80; mask != 0; mask >>= 1) + { + timePs += 4_000_000; + timeline.advance(timePs); + if ((value & mask) != 0) + map.appendPulse(); + } + } + } + + case A2R3.TIMING, A2R3.XTIMING -> + { + /* Both timing types use the recorded length, regardless of revolution count. */ + long pending = 0; + for (byte value : data) + { + int ticks = value & 0xff; + pending += ticks; + if (ticks != 255) + { + timePs += pending * resolution; + timeline.advance(timePs); + map.appendPulse(); + pending = 0; + } + } + + /* Preserve elapsed time when the capture ends with extension bytes. */ + if (pending != 0) + timeline.advance(timePs + pending * resolution); + } + default -> throw new FluxEngineException("invalid A2R3: unknown capture type"); + } + return map; + } + } + + /* Round absolute event times so fractional FluxEngine ticks do not accumulate drift. */ + private static class Timeline + { + private final Fluxmap map; + private final long[] indexes; + private final long resolution; + private int nextIndex; + + Timeline(Fluxmap map, long[] indexes, long resolution) + { + this.map = map; + this.indexes = indexes; + this.resolution = resolution; + } + + void advance(long timePs) + { + /* Emit index events before the flux event at the requested time. */ + while (nextIndex < indexes.length && indexes[nextIndex] * resolution <= timePs) + { + appendTo(indexes[nextIndex++] * resolution); + map.appendIndex(); + } + + /* Finish the interval at the flux event or the end of the capture. */ + appendTo(timePs); + } + + private void appendTo(long timePs) + { + double ticks = timePs / (NS_PER_TICK * 1000.0); + require(ticks <= Integer.MAX_VALUE, "capture is too long"); + int delta = (int) ticks - map.ticks(); + if (delta != 0) + map.appendInterval(delta); + } + } +} diff --git a/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java b/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java index 3ed944031..e42710482 100644 --- a/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java +++ b/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java @@ -29,6 +29,9 @@ public static FluxSource create(A2rFluxSourceProto config) if (Arrays.equals(magic, new byte[] {'A', '2', 'R', '2'})) return new A2R2FluxSource(config); + if (Arrays.equals(magic, new byte[] {'A', '2', 'R', '3'})) + return new A2R3FluxSource(config); + throw new FluxEngineException("unsupported A2R version"); } } diff --git a/java/com/cowlark/fluxengine/fluxsource/BUILD.bazel b/java/com/cowlark/fluxengine/fluxsource/BUILD.bazel index cc6229ee1..ceadc146c 100644 --- a/java/com/cowlark/fluxengine/fluxsource/BUILD.bazel +++ b/java/com/cowlark/fluxengine/fluxsource/BUILD.bazel @@ -24,6 +24,7 @@ java_library( ":fluxsource_java_proto", "//java/com/cowlark/fluxengine/config:common_java_proto", "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/config:drive_java_proto", "//java/com/cowlark/fluxengine/core", "//java/com/cowlark/fluxengine/data", "//java/com/cowlark/fluxengine/external", @@ -32,5 +33,8 @@ java_library( "//java/com/cowlark/fluxengine/wiring", "@com_google_protobuf//java/core", "@maven//:org_projectlombok_lombok", + "@maven//:com_google_guava_guava", + "@maven//:org_indunet_fastproto", + "@maven//:org_indunet_fastproto_core", ], ) diff --git a/javatests/com/cowlark/fluxengine/fluxsource/A2R3FluxSourceTest.java b/javatests/com/cowlark/fluxengine/fluxsource/A2R3FluxSourceTest.java new file mode 100644 index 000000000..4e56aa9a5 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsource/A2R3FluxSourceTest.java @@ -0,0 +1,116 @@ +package com.cowlark.fluxengine.fluxsource; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class A2R3FluxSourceTest +{ + private static byte[] chunk(String id, byte[] data) + { + return ByteBuffer.allocate(data.length + 8).order(ByteOrder.LITTLE_ENDIAN) + .put(id.getBytes(java.nio.charset.StandardCharsets.US_ASCII)) + .putInt(data.length).put(data).array(); + } + + private static byte[] captures(int resolution, int type, byte[] data) + { + return ByteBuffer.allocate(16 + 13 + data.length + 1).order(ByteOrder.LITTLE_ENDIAN) + .put((byte) 1).putInt(resolution).put(new byte[11]) + .put((byte) 'C').put((byte) type).putShort((short) 3).put((byte) 1) + .putInt(32).putInt(data.length).put(data).put((byte) 'X').array(); + } + + private static FluxSource source(byte[]... chunks) throws Exception + { + ByteArrayOutputStream file = new ByteArrayOutputStream(); + file.write(new byte[] {'A', '2', 'R', '3', (byte) 255, 10, 13, 10}); + byte[] info = new byte[37]; + info[0] = 1; + java.util.Arrays.fill(info, 1, 33, (byte) ' '); + info[33] = 5; + file.write(chunk("INFO", info)); + for (byte[] bytes : chunks) + file.write(bytes); + Path path = Files.createTempFile("a2r3", ".a2r"); + try + { + Files.write(path, file.toByteArray()); + FluxSource source = A2RFluxSource.create( + A2rFluxSourceProto.newBuilder().setFilename(path.toString()).build()); + assertTrue(source instanceof A2R3FluxSource); + return source; + } finally + { + Files.delete(path); + } + } + + private static FluxSourceIterator track(FluxSource source) + { + return source.readFlux(FluxReadParameters.builder().setCylinder(1).setHead(1).build()); + } + + @Test + public void readsArbitraryTimingLengthsAcrossChunks() throws Exception + { + FluxSource source = source(chunk("RWCP", captures(62500, 1, new byte[] {64, 64})), + chunk("SLVD", new byte[] {0}), chunk("META", new byte[] {0}), + chunk("RWCP", captures(125000, 3, new byte[] {(byte) 255, 10, 1}))); + FluxSourceIterator iterator = track(source); + Fluxmap first = iterator.next(); + assertEquals(8000, first.durationNs(), 50); + assertEquals(1, first.getIndexMarks().size()); + assertEquals(2000, first.getIndexMarks().first(), 50); + Fluxmap second = iterator.next(); + assertEquals(33250, second.durationNs(), 50); + assertFalse(iterator.hasNext()); + assertTrue(track(source).hasNext()); + assertFalse(source.readFlux(FluxReadParameters.builder().setCylinder(0).setHead(0).build()) + .hasNext()); + assertEquals("c1h1", source.getExtraConfig().getDrive().getTracks()); + } + + @Test + public void readsBitsMsbFirstAndPreservesTrailingZeros() throws Exception + { + Fluxmap map = track(source(chunk("RWCP", captures(62500, 2, new byte[] {(byte) 0x80})))) + .next(); + assertEquals(32000, map.durationNs(), 50); + int pulseCount = 0; + long ticks = 0; + for (byte value : map.rawBytes().toByteArray()) + { + ticks += value & 0x3f; + if ((value & 0x80) != 0) + { + pulseCount++; + assertEquals(4000, ticks * com.cowlark.fluxengine.wiring.FluxEngine.NS_PER_TICK, 50); + } + } + assertEquals(1, pulseCount); + } + + @Test + public void rejectsTruncatedChunksAndMissingTerminator() + { + assertThrows(FluxEngineException.class, + () -> source(new byte[] {'R', 'W', 'C', 'P', -1, -1, -1, 127})); + byte[] body = captures(62500, 1, new byte[] {64}); + assertThrows(FluxEngineException.class, + () -> source(chunk("RWCP", java.util.Arrays.copyOf(body, body.length - 1)))); + } +} diff --git a/javatests/com/cowlark/fluxengine/fluxsource/BUILD.bazel b/javatests/com/cowlark/fluxengine/fluxsource/BUILD.bazel index a9a950c2a..4c19b4613 100644 --- a/javatests/com/cowlark/fluxengine/fluxsource/BUILD.bazel +++ b/javatests/com/cowlark/fluxengine/fluxsource/BUILD.bazel @@ -101,3 +101,20 @@ java_test( "@maven//:junit_junit", ], ) + +java_test( + name = "A2R3FluxSourceTest", + srcs = ["A2R3FluxSourceTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/config:drive_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/fluxsource", + "//java/com/cowlark/fluxengine/fluxsource:fluxsource_java_proto", + "//java/com/cowlark/fluxengine/wiring", + "@com_google_protobuf//java/core", + "@maven//:com_google_guava_guava", + "@maven//:junit_junit", + ], +) From 4005acfd3e430a4b8b08912f70dbc5790e6340a6 Mon Sep 17 00:00:00 2001 From: David Given Date: Mon, 19 Oct 2026 00:36:23 +0200 Subject: [PATCH 4/5] Change A2RFluxSink to write A2R3 files. --- .../fluxengine/fluxsink/A2R3FluxSink.java | 175 +++++++++++++ ...kFactory.java => A2R3FluxSinkFactory.java} | 10 +- .../fluxengine/fluxsink/A2RFluxSink.java | 233 ------------------ .../cowlark/fluxengine/fluxsink/BUILD.bazel | 3 + .../fluxengine/fluxsink/FluxSinkFactory.java | 2 +- .../cowlark/fluxengine/fluxsink/BUILD.bazel | 4 + .../fluxengine/fluxsink/FluxSinkTest.java | 82 +++++- 7 files changed, 265 insertions(+), 244 deletions(-) create mode 100644 java/com/cowlark/fluxengine/fluxsink/A2R3FluxSink.java rename java/com/cowlark/fluxengine/fluxsink/{A2RFluxSinkFactory.java => A2R3FluxSinkFactory.java} (65%) delete mode 100644 java/com/cowlark/fluxengine/fluxsink/A2RFluxSink.java diff --git a/java/com/cowlark/fluxengine/fluxsink/A2R3FluxSink.java b/java/com/cowlark/fluxengine/fluxsink/A2R3FluxSink.java new file mode 100644 index 000000000..5b8960664 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/A2R3FluxSink.java @@ -0,0 +1,175 @@ +package com.cowlark.fluxengine.fluxsink; + +import static com.cowlark.fluxengine.wiring.FluxEngine.F_BIT_INDEX; +import static com.cowlark.fluxengine.wiring.FluxEngine.F_BIT_PULSE; +import static com.cowlark.fluxengine.wiring.FluxEngine.NS_PER_TICK; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.external.A2R3; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.indunet.fastproto.FastProto; + +/* Writes raw captures as A2R3, using the default 62.5 ns timing resolution. */ +public class A2R3FluxSink extends FluxSink +{ + private final String filename; + private final ConfigProto config; + private final Bytes captures = new Bytes(); + private final ByteWriter captureWriter = captures.writer(); + + public A2R3FluxSink(String filename, ConfigProto config) + { + this.filename = filename; + this.config = config; + + /* Every RWCP chunk begins with its version, resolution and reserved bytes. */ + captureWriter.write(FastProto.encode(new A2R3.RawCaptureHeader())); + } + + private static long toA2rTicks(long ticks) + { + return Math.round(ticks * NS_PER_TICK * 1000.0 / A2R3.DEFAULT_PS_PER_TICK); + } + + @Override + public void addFlux(int cylinder, int head, Fluxmap fluxmap) + { + if (fluxmap.bytes() == 0) + return; + + /* Locations use quarter tracks for Apple II drives and cylinder/side pairs otherwise. */ + boolean apple2 = config.getDrive().getDriveType() == + com.cowlark.fluxengine.external.DriveType.DRIVETYPE_APPLE2; + long location = apple2 ? cylinder : (long) cylinder * 2 + head; + require(cylinder >= 0 && head >= 0 && head <= 1 && (!apple2 || head == 0) + && location <= 0xffff, "capture location is out of range"); + + /* Quantize absolute event times to avoid accumulating rounding errors between pulses. */ + Bytes data = new Bytes(); + ByteWriter dataWriter = data.writer(); + List indexes = new ArrayList<>(); + long totalTicks = 0; + long lastPulse = 0; + for (byte value : fluxmap.rawBytes().toByteArray()) + { + totalTicks += value & 0x3f; + long now = toA2rTicks(totalTicks); + require(now <= 0xffffffffL, "capture duration exceeds the A2R3 timing range"); + if ((value & F_BIT_INDEX) != 0 && now != 0) + indexes.add(now); + if ((value & F_BIT_PULSE) != 0) + { + long interval = now - lastPulse; + while (interval >= 255) + { + dataWriter.write8(255); + interval -= 255; + } + dataWriter.write8((int) interval); + lastPulse = now; + } + } + + /* Trailing extension bytes preserve silence without inventing a flux transition. + * A2R cannot encode a final partial extension without implying another pulse. */ + long trailing = toA2rTicks(totalTicks) - lastPulse; + while (trailing >= 255) + { + dataWriter.write8(255); + trailing -= 255; + } + require(indexes.size() <= 255, "too many index signals in one capture"); + + /* Classify by actual duration, using measured revolutions when index signals exist. */ + double periodNs = revolutionPeriodNs(indexes, fluxmap.durationNs()); + A2R3.Capture capture = new A2R3.Capture(); + capture.captureType = fluxmap.durationNs() < 2.5 * periodNs ? A2R3.TIMING : A2R3.XTIMING; + capture.location = (int) location; + capture.indexSignalCount = indexes.size(); + capture.indexSignals = indexes.stream().mapToLong(Long::longValue).toArray(); + captureWriter.write(FastProto.encode(capture)); + + A2R3.FluxData payload = new A2R3.FluxData(); + payload.data = data.toByteArray(); + payload.dataSize = payload.data.length; + captureWriter.write(FastProto.encode(payload)); + } + + private double revolutionPeriodNs(List indexes, double durationNs) + { + /* A full revolution spans one index interval, or a full set of hard sector intervals. */ + int sectors = Math.max(1, config.getDrive().getHardSectorCount()); + if (indexes.size() > sectors) + return (indexes.get(sectors) - indexes.get(0)) * + (A2R3.DEFAULT_PS_PER_TICK / 1000.0); + + /* An explicitly synchronized capture begins at an omitted index signal. */ + if (config.getDrive().getSyncWithIndex() && indexes.size() >= sectors) + return indexes.get(sectors - 1) * (A2R3.DEFAULT_PS_PER_TICK / 1000.0); + + /* Without measured rotation, use the configured period. Indexless image tracks + * represent one revolution; otherwise use the conventional 300 RPM fallback. */ + double configuredNs = config.getDrive().getRotationalPeriodMs() * 1_000_000; + if (configuredNs > 0) + return configuredNs; + return indexes.isEmpty() ? durationNs : 200_000_000; + } + + private static void require(boolean condition, String message) + { + if (!condition) + throw new FluxEngineException("cannot write A2R3: " + message); + } + + private static void writeChunk(ByteWriter writer, int id, byte[] data) + { + A2R3.ChunkHeader header = new A2R3.ChunkHeader(); + header.id = Integer.toUnsignedLong(id); + header.size = data.length; + writer.write(FastProto.encode(header)); + writer.write(data); + } + + @Override + public void close() + { + /* INFO records the closest A2R drive geometry available from the configuration. */ + A2R3.Info info = new A2R3.Info(); + info.creator = String.format("%-32s", "FluxEngine"); + info.driveType = switch (config.getDrive().getDriveType()) + { + case DRIVETYPE_APPLE2 -> A2R3.DRIVE_525_SS_40_QUARTER_STEP; + case DRIVETYPE_40TRACK -> A2R3.DRIVE_525_DS_40; + default -> A2R3.DRIVE_35_DS_80; + }; + info.writeProtected = 1; + info.synchronizedCapture = config.getDrive().getSyncWithIndex() ? 1 : 0; + info.hardSectorCount = config.getDrive().getHardSectorCount(); + require(info.hardSectorCount >= 0 && info.hardSectorCount <= 255, + "hard sector count is out of range"); + + /* Terminate a copy of the captures so closing does not modify the buffered entries. */ + Bytes raw = new Bytes(captures.toByteArray()); + ByteWriter rawWriter = raw.writer(); + rawWriter.seekToEnd(); + rawWriter.write(FastProto.encode(new A2R3.EndMarker())); + Bytes file = new Bytes(); + ByteWriter writer = file.writer(); + writer.write(FastProto.encode(new A2R3.FileHeader())); + writeChunk(writer, A2R3.CHUNK_INFO, FastProto.encode(info)); + writeChunk(writer, A2R3.CHUNK_RWCP, raw.toByteArray()); + + /* Keep the imaging timestamp as UTF-8 metadata following the raw captures. */ + writeChunk(writer, A2R3.CHUNK_META, + ("image_date\t" + Instant.now() + "\n").getBytes(StandardCharsets.UTF_8)); + file.writeToFile(Path.of(filename)); + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/A2RFluxSinkFactory.java b/java/com/cowlark/fluxengine/fluxsink/A2R3FluxSinkFactory.java similarity index 65% rename from java/com/cowlark/fluxengine/fluxsink/A2RFluxSinkFactory.java rename to java/com/cowlark/fluxengine/fluxsink/A2R3FluxSinkFactory.java index 8e4075c6e..021a0f16d 100644 --- a/java/com/cowlark/fluxengine/fluxsink/A2RFluxSinkFactory.java +++ b/java/com/cowlark/fluxengine/fluxsink/A2R3FluxSinkFactory.java @@ -2,15 +2,13 @@ import com.cowlark.fluxengine.config.ConfigProto; -/** - * A factory for A2R flux sinks, ported from lib/fluxsink/a2rfluxsink.cc. - */ -public class A2RFluxSinkFactory extends FluxSinkFactory +/* A factory for A2R3 flux sinks. */ +public class A2R3FluxSinkFactory extends FluxSinkFactory { private final String filename; private final ConfigProto config; - public A2RFluxSinkFactory(String filename, ConfigProto config) + public A2R3FluxSinkFactory(String filename, ConfigProto config) { this.filename = filename; this.config = config; @@ -19,7 +17,7 @@ public A2RFluxSinkFactory(String filename, ConfigProto config) @Override public FluxSink create() { - return new A2RFluxSink(filename, config); + return new A2R3FluxSink(filename, config); } @Override diff --git a/java/com/cowlark/fluxengine/fluxsink/A2RFluxSink.java b/java/com/cowlark/fluxengine/fluxsink/A2RFluxSink.java deleted file mode 100644 index de9ef6370..000000000 --- a/java/com/cowlark/fluxengine/fluxsink/A2RFluxSink.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.cowlark.fluxengine.fluxsink; - -import static com.cowlark.fluxengine.wiring.FluxEngine.F_BIT_INDEX; -import static com.cowlark.fluxengine.wiring.FluxEngine.F_BIT_PULSE; -import static com.cowlark.fluxengine.wiring.FluxEngine.NS_PER_TICK; - -import com.cowlark.fluxengine.config.ConfigProto; -import com.cowlark.fluxengine.core.ByteWriter; -import com.cowlark.fluxengine.core.Bytes; -import com.cowlark.fluxengine.core.FluxEngineException; -import com.cowlark.fluxengine.core.Logger; -import com.cowlark.fluxengine.data.DiskLayout; -import com.cowlark.fluxengine.data.Fluxmap; -import com.cowlark.fluxengine.data.FluxmapReader; -import com.cowlark.fluxengine.decoders.DecoderProto; -import com.cowlark.fluxengine.external.A2R; -import com.cowlark.fluxengine.external.DriveType; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * A flux sink which writes an A2R flux file, ported from - * lib/fluxsink/a2rfluxsink.cc. - */ -public class A2RFluxSink extends FluxSink -{ - private static final String VERSION_STRING = String.format("%-32s", "FluxEngine"); - private final String filename; - private final ConfigProto config; - private final Bytes bytes = new Bytes(0); - private final ByteWriter writer = bytes.writer(); - private final Bytes strmBytes = new Bytes(0); - private final ByteWriter strmWriter = strmBytes.writer(); - private final Map metadata = new LinkedHashMap<>(); - private int minHead; - private int maxHead; - private int minCylinder; - private int maxCylinder; - - public A2RFluxSink(String filename, ConfigProto config) - { - this.filename = filename; - this.config = config; - metadata.put( - "image_date", - DateTimeFormatter.ISO_INSTANT.format(ZonedDateTime.now(ZoneOffset.UTC))); - } - - private static long ticksToA2r(long ticks) - { - return (long) (ticks * NS_PER_TICK / A2R.NS_PER_TICK); - } - - private void writeChunkAndData(int chunkId, Bytes data) - { - writer.writeLe32(chunkId); - writer.writeLe32(data.size()); - writer.write(data); - } - - private void writeHeader() - { - writer.write(Bytes.of( - A2R.FILEHEADER[0] & 0xff, - A2R.FILEHEADER[1] & 0xff, - A2R.FILEHEADER[2] & 0xff, - A2R.FILEHEADER[3] & 0xff, - A2R.FILEHEADER[4] & 0xff, - A2R.FILEHEADER[5] & 0xff, - A2R.FILEHEADER[6] & 0xff, - A2R.FILEHEADER[7] & 0xff)); - } - - private void writeInfo() - { - Bytes info = new Bytes(0); - ByteWriter infoWriter = info.writer(); - infoWriter.write8(A2R.INFO_CHUNK_VERSION); - infoWriter.write(VERSION_STRING.getBytes()); - - infoWriter.write8((config.getDrive().getDriveType() == DriveType.DRIVETYPE_APPLE2) ? - A2R.DISK_525 : - A2R.DISK_35); - - infoWriter.write8(1); /* write protected */ - infoWriter.write8(1); /* synchronized */ - writeChunkAndData(A2R.CHUNK_INFO, info); - } - - private void writeMeta() - { - Bytes meta = new Bytes(0); - ByteWriter metaWriter = meta.writer(); - for (Map.Entry i : metadata.entrySet()) - { - metaWriter.write(i.getKey().getBytes()); - metaWriter.write8('\t'); - metaWriter.write(i.getValue().getBytes()); - metaWriter.write8('\n'); - } - writeChunkAndData(A2R.CHUNK_META, meta); - } - - private void writeStream() - { - /* A STRM always ends with a 255, even though this could ALSO - * indicate the first byte of a multi-byte sequence */ - strmWriter.write8(255); - - writeChunkAndData(A2R.CHUNK_STRM, strmBytes); - } - - @Override - public void addFlux(int cylinder, int head, Fluxmap fluxmap) - { - if (fluxmap.bytes() == 0) - { - return; - } - - // Writing from an image (as opposed to from a floppy) will - // contain exactly one revolution and no index events. - FluxmapReader fmrCheck = new FluxmapReader(fluxmap, DecoderProto.getDefaultInstance()); - fmrCheck.skipToEvent(F_BIT_INDEX); - boolean isImage = fmrCheck.eof(); - - // Write the flux data into its own Bytes - Bytes trackBytes = new Bytes(0); - ByteWriter trackWriter = trackBytes.writer(); - - int[] revolutionHolder = {0}; - long[] loopPointHolder = {0}; - long[] totalTicksHolder = {0}; - - FluxmapReader fmr = new FluxmapReader(fluxmap, DecoderProto.getDefaultInstance()); - java.util.function.IntConsumer writeOneFlux = (ticks) -> { - long value = ticksToA2r(ticks); - while (value > 254) - { - trackWriter.write8(255); - value -= 255; - } - trackWriter.write8((int) value); - }; - - java.util.function.IntConsumer writeFlux = (maxTicks) -> { - long ticksSinceLastPulse = 0; - - while (!fmr.eof() && totalTicksHolder[0] < maxTicks) - { - FluxmapReader.Event event = fmr.getNextEvent(); - long ticks = event.ticks(); - - ticksSinceLastPulse += ticks; - totalTicksHolder[0] += ticks; - - if ((event.event() & F_BIT_PULSE) != 0) - { - writeOneFlux.accept((int) ticksSinceLastPulse); - ticksSinceLastPulse = 0; - } - - if ((event.event() & F_BIT_INDEX) != 0 && revolutionHolder[0] == 0) - { - loopPointHolder[0] = totalTicksHolder[0]; - revolutionHolder[0] += 1; - } - } - }; - - if (isImage) - { - // A timing stream with no index represents exactly one - // revolution with no index. However, a2r nominally contains - // 450 degress of rotation, 250ms at 300rpm. - writeFlux.accept(Integer.MAX_VALUE); - loopPointHolder[0] = totalTicksHolder[0]; - fmr.rewind(); - revolutionHolder[0] += 1; - writeFlux.accept((int) (totalTicksHolder[0] * 5 / 4)); - } else - { - // We have an index, so this is a real read from a floppy - // and should be "one revolution plus a bit" - fmr.skipToEvent(F_BIT_INDEX); - writeFlux.accept(Integer.MAX_VALUE); - } - - if (config.getDrive().getDriveType() == DriveType.DRIVETYPE_APPLE2) - strmWriter.write8(cylinder); - else - strmWriter.write8((cylinder << 1) | head); - - strmWriter.write8(A2R.TIMING); - strmWriter.writeLe32(trackBytes.size()); - strmWriter.writeLe32((int) ticksToA2r(loopPointHolder[0])); - strmWriter.write(trackBytes); - } - - @Override - public void close() - { - // FIXME: should use a passed-in DiskLayout object. - DiskLayout diskLayout = DiskLayout.createDiskLayout(config); - - minCylinder = diskLayout.minPhysicalCylinder; - maxCylinder = diskLayout.maxPhysicalCylinder; - minHead = diskLayout.minPhysicalHead; - maxHead = diskLayout.maxPhysicalHead; - - Logger.logf("A2R: writing A2R " + ((minHead == maxHead) ? "single sided" : "double sided") + - " file containing " + (maxCylinder - minCylinder + 1) + " tracks..."); - - writeHeader(); - writeInfo(); - writeStream(); - writeMeta(); - - try - { - Files.write(Path.of(filename), bytes.toByteArray()); - } catch (IOException e) - { - throw new FluxEngineException("cannot open output file"); - } - } -} diff --git a/java/com/cowlark/fluxengine/fluxsink/BUILD.bazel b/java/com/cowlark/fluxengine/fluxsink/BUILD.bazel index 29bd5d5d3..119ef8197 100644 --- a/java/com/cowlark/fluxengine/fluxsink/BUILD.bazel +++ b/java/com/cowlark/fluxengine/fluxsink/BUILD.bazel @@ -23,6 +23,7 @@ java_library( ":fluxsink_java_proto", "//java/com/cowlark/fluxengine/config:common_java_proto", "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/config:drive_java_proto", "//java/com/cowlark/fluxengine/core", "//java/com/cowlark/fluxengine/data", "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", @@ -32,5 +33,7 @@ java_library( "//java/com/cowlark/fluxengine/wiring", "@com_google_protobuf//java/core", "@maven//:org_apache_commons_commons_lang3", + "@maven//:org_indunet_fastproto", + "@maven//:org_indunet_fastproto_core", ], ) diff --git a/java/com/cowlark/fluxengine/fluxsink/FluxSinkFactory.java b/java/com/cowlark/fluxengine/fluxsink/FluxSinkFactory.java index 4cb612583..097d6cdda 100644 --- a/java/com/cowlark/fluxengine/fluxsink/FluxSinkFactory.java +++ b/java/com/cowlark/fluxengine/fluxsink/FluxSinkFactory.java @@ -19,7 +19,7 @@ public static FluxSinkFactory create( { case FLUXTYPE_DRIVE -> new HardwareFluxSinkFactory(config, usbFactorySupplier.get()); case FLUXTYPE_A2R -> - new A2RFluxSinkFactory(fluxSinkProto.getA2R().getFilename(), config); + new A2R3FluxSinkFactory(fluxSinkProto.getA2R().getFilename(), config); case FLUXTYPE_AU -> new AuFluxSinkFactory( fluxSinkProto.getAu().getDirectory(), fluxSinkProto.getAu().getIndexMarkers()); diff --git a/javatests/com/cowlark/fluxengine/fluxsink/BUILD.bazel b/javatests/com/cowlark/fluxengine/fluxsink/BUILD.bazel index dbe8ed916..89389b5d3 100644 --- a/javatests/com/cowlark/fluxengine/fluxsink/BUILD.bazel +++ b/javatests/com/cowlark/fluxengine/fluxsink/BUILD.bazel @@ -30,6 +30,10 @@ java_test( "//java/com/cowlark/fluxengine/data", "//java/com/cowlark/fluxengine/external", "//java/com/cowlark/fluxengine/fluxsink", + "//java/com/cowlark/fluxengine/fluxsource", + "//java/com/cowlark/fluxengine/fluxsource:fluxsource_java_proto", + "@com_google_protobuf//java/core", + "@maven//:com_google_guava_guava", "//java/com/cowlark/fluxengine/fluxsink:fluxsink_java_proto", "//javatests/com/cowlark/fluxengine/testing", "@maven//:com_google_truth_truth", diff --git a/javatests/com/cowlark/fluxengine/fluxsink/FluxSinkTest.java b/javatests/com/cowlark/fluxengine/fluxsink/FluxSinkTest.java index b631cafad..d410207a7 100644 --- a/javatests/com/cowlark/fluxengine/fluxsink/FluxSinkTest.java +++ b/javatests/com/cowlark/fluxengine/fluxsink/FluxSinkTest.java @@ -10,6 +10,12 @@ import com.cowlark.fluxengine.core.FluxEngineException; import com.cowlark.fluxengine.data.Fluxmap; import com.cowlark.fluxengine.external.Scp; +import com.cowlark.fluxengine.fluxsource.A2RFluxSource; +import com.cowlark.fluxengine.fluxsource.A2R3FluxSource; +import com.cowlark.fluxengine.fluxsource.A2rFluxSourceProto; +import com.cowlark.fluxengine.fluxsource.FluxSource; +import com.cowlark.fluxengine.fluxsource.FluxSourceIterator; +import com.cowlark.fluxengine.fluxsource.FluxReadParameters; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -83,14 +89,14 @@ public void a2rWritesFile() throws IOException Path path = Files.createTempFile("flux", ".a2r"); Files.delete(path); - A2RFluxSink sink = new A2RFluxSink(path.toString(), makeConfig()); + A2R3FluxSink sink = new A2R3FluxSink(path.toString(), makeConfig()); sink.addFlux(0, 0, makeFluxmap()); sink.close(); Bytes data = new Bytes(Files.readAllBytes(path)); assertThat(data.size()).isGreaterThan(0); - /* File header: A2R2 then 0xff 0x0a 0x0d 0x0a. */ - assertThat(new String(data.slice(0, 4).toByteArray())).isEqualTo("A2R2"); + /* File header: A2R3 then 0xff 0x0a 0x0d 0x0a. */ + assertThat(new String(data.slice(0, 4).toByteArray())).isEqualTo("A2R3"); assertThat(data.getByte(4) & 0xff).isEqualTo(0xff); assertThat(data.getByte(5) & 0xff).isEqualTo(0x0a); } @@ -138,8 +144,76 @@ public void factoryWiring() .build(); FluxSinkFactory factory = FluxSinkFactory.create(config, null); - assertThat(factory).isInstanceOf(A2RFluxSinkFactory.class); + assertThat(factory).isInstanceOf(A2R3FluxSinkFactory.class); assertThat(factory.getPath()).isEqualTo("test.a2r"); assertThat(factory.isHardware()).isFalse(); } + @Test + public void a2r3CaptureTypeBoundary() throws Exception + { + /* At 12 MHz, 6,000,000 ticks are exactly 2.5 revolutions at 300 RPM. */ + Path path = Files.createTempFile("flux", ".a2r"); + try + { + for (int duration : new int[] {5_999_999, 6_000_000, 6_000_001}) + { + Fluxmap map = new Fluxmap(); + map.appendInterval(duration).appendPulse(); + try (A2R3FluxSink sink = new A2R3FluxSink(path.toString(), makeConfig())) + { + sink.addFlux(0, 0, map); + } + + /* Inspect the on-disk type independently of the reader. */ + ByteReader reader = Bytes.readFromFile(path).reader(); + reader.skip(8); + assertThat(reader.readLe32()).isEqualTo(0x4f464e49); + reader.skip(reader.readLe32()); + assertThat(reader.readLe32()).isEqualTo(0x50435752); + reader.readLe32(); + reader.skip(16); + assertThat(reader.read8()).isEqualTo('C'); + assertThat(reader.read8()).isEqualTo(duration < 6_000_000 ? 1 : 3); + } + } finally + { + Files.delete(path); + } + } + + @Test + public void a2r3RoundTripsCapturesAndIndexes() throws Exception + { + /* Record two captures at the same location, with two index signals per capture. */ + Path path = Files.createTempFile("flux", ".a2r"); + Fluxmap map = new Fluxmap(); + map.appendInterval(100).appendPulse(); + map.appendInterval(2_399_900).appendIndex(); + map.appendInterval(2_400_000).appendIndex().appendPulse(); + try + { + try (A2R3FluxSink sink = new A2R3FluxSink(path.toString(), makeConfig())) + { + sink.addFlux(0, 0, map); + sink.addFlux(0, 0, map); + } + + /* Read the completed file through version dispatch and compare both captures. */ + FluxSource source = A2RFluxSource.create(A2rFluxSourceProto.newBuilder() + .setFilename(path.toString()).build()); + assertThat(source).isInstanceOf(A2R3FluxSource.class); + FluxSourceIterator iterator = source.readFlux(FluxReadParameters.builder() + .setCylinder(0).setHead(0).build()); + for (int i = 0; i < 2; i++) + { + Fluxmap decoded = iterator.next(); + assertThat(decoded.getIndexMarks()).containsExactly(200_000_000.0, 400_000_000.0); + assertThat(decoded.durationNs()).isWithin(100).of(map.durationNs()); + } + assertThat(iterator.hasNext()).isFalse(); + } finally + { + Files.delete(path); + } + } } From 031ea47f4f2199d0a3e8f9e9f04edbecc21b899f Mon Sep 17 00:00:00 2001 From: David Given Date: Mon, 19 Oct 2026 00:37:58 +0200 Subject: [PATCH 5/5] Round-trip the corpus tests through A2R files. --- scripts/corpus.bzl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/corpus.bzl b/scripts/corpus.bzl index b6f1f168b..f25139383 100644 --- a/scripts/corpus.bzl +++ b/scripts/corpus.bzl @@ -74,7 +74,7 @@ def define_corpus_tests(): script = entry[1] flags = entry[2] name = _sanitize(format + script + flags) - for ext in ["scp", "flux"]: + for ext in ["scp", "flux", "a2r"]: test_name = "corpustest_%s_%s" % (name, ext) args = [format, ext] if flags: