Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(...))`.
Expand Down
16 changes: 8 additions & 8 deletions MODULE.bazel.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

135 changes: 135 additions & 0 deletions java/com/cowlark/fluxengine/external/A2R3.java
Original file line number Diff line number Diff line change
@@ -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 = "";
}
}
1 change: 1 addition & 0 deletions java/com/cowlark/fluxengine/external/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)
175 changes: 175 additions & 0 deletions java/com/cowlark/fluxengine/fluxsink/A2R3FluxSink.java
Original file line number Diff line number Diff line change
@@ -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<Long> 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<Long> 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));
}
}
Loading
Loading