diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 000000000..abdeacb42 --- /dev/null +++ b/.bazelrc @@ -0,0 +1,17 @@ +common --java_language_version=21 +common --java_runtime_version=remotejdk_21 + +# Tools built in the exec configuration also need Java 21 (for records etc.). +common --tool_java_language_version=21 +common --tool_java_runtime_version=remotejdk_21 + +# Lombok generates builder classes that are part of the public API; the +# annotation-processor output doesn't reach the interface jar used by header +# compilation (and Turbine can't run Lombok), so disable header compilation. +build --java_header_compilation=false + +# Dev machine toolchain workarounds: the system gcc is wrapped by ccache and +# the system linker (lld) lives in /opt/bin, outside Bazel's default action PATH. +build --repo_env=CC=/usr/bin/gcc +build --repo_env=CXX=/usr/bin/g++ +build --host_linkopt=-B/opt/bin diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 000000000..599d14aae --- /dev/null +++ b/.bazelversion @@ -0,0 +1,2 @@ +8.7.0 + diff --git a/.github/workflows/ccpp.yml b/.github/workflows/ccpp.yml index 9fe715080..57874ca80 100644 --- a/.github/workflows/ccpp.yml +++ b/.github/workflows/ccpp.yml @@ -1,8 +1,8 @@ name: C/C++ CI -on: [push] +on: [ push ] -concurrency: +concurrency: group: environment-${{ github.head_ref }} cancel-in-progress: true @@ -12,107 +12,102 @@ jobs: strategy: matrix: variant: - - debian12 - - debian13 - - fedora42 - - fedora43 - - fedora43.nooptionaldeps + - debian12 + - debian13 + - fedora42 + - fedora43 + - fedora43.nooptionaldeps steps: - - uses: actions/checkout@v4 - with: - repository: 'davidgiven/fluxengine' - path: 'fluxengine' - - uses: actions/checkout@v4 - with: - repository: 'davidgiven/fluxengine-testdata' - path: 'fluxengine-testdata' - - name: make - run: | - cd fluxengine && docker build -t ${{ matrix.variant }} -f tests/docker/Dockerfile.${{ matrix.variant }} . + - uses: actions/checkout@v4 + with: + repository: 'davidgiven/fluxengine' + path: 'fluxengine' + - uses: actions/checkout@v4 + with: + repository: 'davidgiven/fluxengine-testdata' + path: 'fluxengine-testdata' + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + - name: Build with Bazel + run: | + cd fluxengine + bazel test //... --test_output=errors + bazel build //:fluxengine //:fluxengine_deb //:fluxengine_rpm //:fluxengine_app_image + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ github.event.repository.name }}.${{ github.sha }}.bazel.artifacts + path: | + fluxengine/bazel-bin/** build-macos-current: strategy: matrix: - runs-on: [macos-15, macos-15-intel] + runs-on: [ macos-15, macos-15-intel ] runs-on: ${{ matrix.runs-on }} steps: - - uses: actions/checkout@v4 - with: - repository: 'davidgiven/fluxengine' - path: 'fluxengine' - - uses: actions/checkout@v4 - with: - repository: 'davidgiven/fluxengine-testdata' - path: 'fluxengine-testdata' - - name: brew - run: | - brew install sqlite pkg-config libusb protobuf wxwidgets fmt make coreutils dylibbundler libjpeg libmagic nlohmann-json cli11 boost glfw3 md4c ninja python freetype2 mbedtls@3 lunasvg - brew link mbedtls@3 - brew upgrade - - name: make - run: | - g++ -v - gmake -C fluxengine - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: ${{ github.event.repository.name }}.${{ github.sha }}.fluxengine.${{ runner.arch }}.pkg - path: | - fluxengine/FluxEngine.pkg - fluxengine/FluxEngine.app.zip + - uses: actions/checkout@v4 + with: + repository: 'davidgiven/fluxengine' + path: 'fluxengine' + - uses: actions/checkout@v4 + with: + repository: 'davidgiven/fluxengine-testdata' + path: 'fluxengine-testdata' + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 + - name: Build with Bazel + run: | + cd fluxengine + bazel test //... --test_output=errors + bazel build //:fluxengine //:fluxengine_dmg + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ github.event.repository.name }}.${{ github.sha }}.bazel.artifacts.${{ runner.arch }} + path: | + fluxengine/bazel-bin/** build-windows: runs-on: windows-latest - defaults: - run: - shell: msys2 {0} steps: - - uses: msys2/setup-msys2@v2 - with: - msystem: mingw64 - update: true - install: | - python diffutils ninja make zip git - pacboy: | - protobuf:p pkgconf:p curl-winssl:p file:p glfw:p mbedtls:p - sqlite:p freetype:p boost:p gcc:p binutils:p nsis:p abseil-cpp:p + - uses: actions/checkout@v4 + with: + repository: 'davidgiven/fluxengine' + path: 'fluxengine' - - name: debug - run: | - pacboy -Q --info protobuf:p - cat /mingw64/lib/pkgconfig/protobuf.pc - /mingw64/bin/pkg-config.exe protobuf --cflags - /mingw64/bin/pkg-config.exe protobuf --cflags --static + - uses: actions/checkout@v4 + with: + repository: 'davidgiven/fluxengine-testdata' + path: 'fluxengine-testdata' - - uses: actions/checkout@v4 - with: - repository: 'davidgiven/fluxengine' - path: 'fluxengine' + - name: Setup Bazel + uses: bazel-contrib/setup-bazel@0.19.0 - - uses: actions/checkout@v4 - with: - repository: 'davidgiven/fluxengine-testdata' - path: 'fluxengine-testdata' + - name: Set up MSVC Developer Environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 - - name: run - run: | - g++ -v - make -C fluxengine BUILDTYPE=windows AB_SANDBOX=no + - name: Build with Bazel + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + cd fluxengine + bazel test //... --test_output=errors ` + --action_env=PATH ` + --action_env=INCLUDE ` + --action_env=LIB ` + --action_env=LIBPATH + bazel build //:fluxengine //:fluxengine_msi ` + --action_env=PATH ` + --action_env=INCLUDE ` + --action_env=LIB ` + --action_env=LIBPATH - - name: nsis - run: | - cd fluxengine - strip fluxengine.exe -o fluxengine-stripped.exe - strip fluxengine-gui.exe -o fluxengine-gui-stripped.exe - makensis -v2 -nocd -dOUTFILE=fluxengine-installer.exe extras/windows-installer.nsi - - - name: zip - run: | - cd fluxengine && zip -9 fluxengine-windows.zip fluxengine.exe fluxengine-gui.exe upgrade-flux-file.exe brother120tool.exe brother240tool.exe FluxEngine.cydsn/CortexM3/ARM_GCC_541/Release/FluxEngine.hex fluxengine-installer.exe - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: ${{ github.event.repository.name }}.${{ github.sha }}.windows.zip - path: fluxengine/fluxengine-windows.zip + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ github.event.repository.name }}.${{ github.sha }}.bazel.artifacts.windows + path: fluxengine/bazel-bin/** diff --git a/.gitignore b/.gitignore index 468689295..c6effad0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,15 @@ +bazel-fluxengine +bazel-testlogs +bazel-out +bazel-bin .obj .project +.idea/ +.ijwb/ +nbproject/ +build.xml +.classpath +manifest.mf /.ninja* /brother120tool /brother120tool-* @@ -11,3 +21,4 @@ /compile_commands.json /doc/disk-_global_options.md +/nbproject/private/ diff --git a/.opencode/skill/swingtree/SKILL.md b/.opencode/skill/swingtree/SKILL.md new file mode 100644 index 000000000..256a7c873 --- /dev/null +++ b/.opencode/skill/swingtree/SKILL.md @@ -0,0 +1,1633 @@ +--- +name: swingtree +description: > + Write Swing desktop UIs with the SwingTree library and its companion property + library Sprouts. Use this skill whenever you are building, editing, reviewing, + or debugging Java Swing GUI code that imports `swingtree.UI` or `sprouts.*` — + declarative component trees, convergent/responsive/reactive layouts, the + functional style API, SVG icons, animations, and MVI/MVL or MVVM view models. +--- + +# Writing SwingTree Applications + +SwingTree is a Java library for building **Swing** desktop GUIs **declaratively**, +the way Flutter / SwiftUI / Jetpack Compose / JetBrains' Kotlin UI DSL build +theirs. You describe the component tree with **method chaining + nesting**, bind +it to state with the **Sprouts** property library (`Var`/`Val`/`Vars`/`Tuple`), +and paint it with a **functional, immutable style API**. There is no XML, no FXML, +no separate template language — it is all plain Java, fully type-safe and +debuggable. + +This document gives you the intuition to write *any* SwingTree app: the builder, +layout, properties & lenses, the two architecture patterns (MVI/MVL and classic +MVVM), events, styling, animation, tables, icons & SVG, dialogs, and the +non-obvious gotchas that bite people. Read it top to bottom once; thereafter use the cheat sheet at +the end. + +> **One library-wide preference up front: SwingTree views are expected to be +> *convergent*** — usable whether the window is maximised on an ultrawide or +> tiled into a tall, 500-pixel strip. Users run tiling window managers, snap +> windows to halves, and rotate monitors into portrait; a view that only works +> at the size you developed it in is considered broken. §2c is the short +> version and the checklist; apply it to every view you write or review. + +--- + +## 0. The one import and the mental model + +```java +import swingtree.UI; +import static swingtree.UI.*; // brings panel(), button(), FILL, WRAP, GROW, ... +``` + +A UI is a **tree of components**. Every node is built by a `UI.xxx(..)` **factory** +that returns a **builder** (`UIForPanel`, `UIForButton`, `UIForLabel`, … all +subtypes of `UIForAnySwing`). On a builder you: + +- **configure** it with chained `withXyz(..)` / `isXyzIf(..)` calls, +- **nest children** with `.add(..)`, +- **bind** it to `Var`/`Val` properties for reactivity, +- **style** it with `.withStyle(it -> ...)`, +- **wire events** with `.onXyz(..)`, +- and finally **unwrap** it with `.get(JPanel.class)` or hand it to `UI.show(..)`. + +Crucial idea: **a builder is a recipe, not the component.** It produces a real +`JComponent` underneath. You can escape to the raw component with `.peek(c -> ...)` +or unwrap with `.get(Type.class)` — but treat `peek` as a last resort (§12): reach +for a SwingTree `with*`/`is*If`/`on*` method first. + +The smallest complete program: + +```java +import static swingtree.UI.*; + +public static void main(String[] args) { + UI.show( + panel("wrap 1") + .add(label("Welcome to SwingTree!")) + .add(button("Click me").onClick(it -> System.out.println("clicked"))) + ); +} +``` + +--- + +## 1. Growing the tree — factories, nesting, `add` + +`UI.show(component | builder | title, builder | Function)` +opens a window. Inside it you compose nodes: + +```java +UI.show( + panel("wrap 2") // a JPanel, MigLayout "wrap 2" + .add(label("Name:")) + .add("grow", textField("John")) // first String arg = per-child layout constraint + .add(label("Age:")) + .add("grow", textField("42")) + .add("span", separator()) // span all columns, then wrap + .add(button("Save")) +); +``` + +Rules of `.add(..)`: + +- `.add(childBuilder)` — add with no constraint. +- `.add("growx, span 2", childBuilder)` — first arg is a **MigLayout add-constraint string**. +- `.add(GROW.and(SPAN), childBuilder)` — or a **type-safe constraint** (see §2). +- `.add(a, b, c)` — add several children at once (same constraint applies to each). + +### Common factories (each returns a builder) + +| Factory | Component | +|---|---| +| `panel(...)`, `box(...)` | `JPanel` / `JBox` (a transparent, insets-free panel — perfect for grouping) | +| `label(text)`, `html("

..

")` | `JLabel` (html(..) renders HTML) | +| `button(text)`, `toggleButton(text)`, `checkBox(text)`, `radioButton(text)` | buttons | +| `textField(text)`, `textArea(text)`, `passwordField()`, `numericTextField(var)` | text inputs | +| `comboBox(...)`, `slider(Align, min, max)`, `spinner(...)`, `progressBar(...)` | value pickers | +| `separator()`, `scrollPane()`, `scrollPanels()`, `splitPane(Align)`, `tabbedPane()` | structure | +| `table(Var)`, `table()`, `list(...)`, `menu(...)`, `menuItem(...)`, `splitButton(text)` | data / menus (bind a `TableData` value — see §10) | +| `icon(path)`, `icon(w,h,path)` | `JIcon` (supports SVG, see §10) | + +`box(...)` vs `panel(...)`: a `JBox` is non-opaque with zero default insets — use it +for invisible structural grouping; use `panel` when you want a real surface to +style. **Never call `setOpaque(..)` yourself on a styled component — the style +engine owns opacity and will fight you.** + +### Wrapping a custom / third-party component + +```java +.add( UI.of(new MyCustomJComponent()).onMouseClick(it -> ...) ) +``` + +`UI.of(jcomponent)` wraps any `JComponent` so the declaration keeps flowing. +`UI.of(this)` is the standard way to start a `View extends JPanel` (see §5). + +--- + +## 2. Layout — MigLayout, type-safe constants, and convergence + +SwingTree's default layout manager is **MigLayout**; §2a and §2b are the two ways +to drive it. §2c onwards is about making the result work at **any** window size, +which SwingTree treats as the default expectation rather than a nice-to-have. + +### 2a. String constraints (most common, terse) + +The **container** constraint goes in the factory; **per-child** constraints go as +the first `add(..)` arg: + +```java +panel("fill, wrap 3, insets 12, gap 8") // container: fill space, 3 cols, 12px insets +.add("growx", a) +.add("span 2, growx", b) // this child spans 2 columns +.add("wrap", c) // force a new row after c +``` + +Memorize these MigLayout keywords: +- Container: `fill`, `fillx`, `filly`, `wrap N` (N columns), `insets T L B R` / `ins N`, `gap`, `debug` (draws guide borders — great for diagnosing layout). +- Per-child: `grow`, `growx`, `growy`, `push`, `pushx`, `pushy`, `span` / `span N`, `wrap`, `align center/left/right`, `top/bottom`, `width 60px::`, `w 180!`, `h 90!`. +- `60px::` means "min 60, no max"; `180!` means "exactly 180". + +`withLayout("fill, wrap 2")` sets the container constraint after the fact, and +`withLayout(layout, colConstraints, rowConstraints)` gives full control, e.g. +`.withLayout("fill, wrap 2", "[grow 60][grow 40]")`. + +Full keyword reference: http://www.miglayout.com/ + +### 2b. Type-safe constants (refactor-safe, composable) + +`import static swingtree.UI.*` exposes constants that compose with `.and(..)`: + +```java +of(this).withLayout(FILL.and(WRAP(1)).and(INS(16))) +.add(GROW.and(PUSH), child) +.add(CENTER.and(SPAN), html("

Title

")) +.add(RIGHT, button("OK")); +``` + +Container constants: `FILL`, `FILL_X`, `FILL_Y`, `WRAP(n)`, `INS(n)` / `INS(t,l,b,r)`, `GAP_REL(n)`, `FLOW_X`, `DEBUG`. +Per-child constants: `GROW`, `GROW_X`, `GROW_Y`, `PUSH`, `PUSH_X`, `PUSH_Y`, `SPAN`, `SPAN(n)`, `WRAP`, `SHRINK`, `CENTER`, `LEFT`, `RIGHT`, `TOP`, `BOTTOM`, `ALIGN_CENTER`, `ALIGN_LEFT`, `ALIGN_X_CENTER`, `ALIGN_Y_TOP`, `GAP_LEFT(n)`, … + +String constraints and constants are interchangeable — pick whichever reads +clearer locally. (Examples in this codebase mix both freely.) + +### 2c. Convergence — a SwingTree view is expected to survive any window shape + +**This is a strong preference of the library, not an optional polish step. Write +convergent views by default; treat "it only works maximised on a landscape +monitor" as a bug.** + +Desktop windows are not a fixed size any more. Users run tiling window managers +(i3, sway, Hyprland, yabai, AeroSpace, FancyZones), snap windows to halves and +thirds, put four windows across an ultrawide, rotate a monitor into portrait, +and drag your app onto a smaller second screen. A view that assumes ~1400×900 +is broken for a large fraction of real users. + +**Convergent** ≠ merely responsive. Responsive means nothing *overlaps*; +convergent means nothing is *lost* — the layout rearranges, the content +re-prioritises, and the primary action stays reachable at every size. + +#### The default recipe (start every page like this) + +```java +UI.scrollPane( conf -> conf.fitWidth(true) ) // the page may outgrow the window +.withHorizontalScrollBarPolicy(UI.Active.NEVER) // never scroll sideways +.withVerticalScrollIncrement(24) +.add( + UI.panel().withFlowLayout(UI.HorizontalAlignment.LEFT, 18, 18) + .withMinSize(0, 0) // may shrink to whatever it is given + .withPrefSize(PAGE_REFERENCE_WIDTH, 0) // the reference width (see 2d) + .add(SIDEBAR_SPAN, sidebar(vm)) + .add(CONTENT_SPAN, content(vm)) +); +``` + +#### Four mechanisms, in the order you should reach for them + +| Gear | Mechanism | Use when | Costs | +|---|---|---|---| +| **0** | `"wmin 0"` + `withMinSize(0,0)` | **always** — a prerequisite for all of the others | nothing | +| **1** | `withFlowLayout()` + `AUTO_SPAN` (§2d) | the same regions want a different number of columns | **no state at all** | +| **2** | `Var` reflow (§2e) | the same widgets want a genuinely different arrangement (a toolbar folding into rows) | one property; **nothing is rebuilt** | +| **3** | form-factor state + view swap (§2f) | the two shapes want different component trees (split pane ⇄ scrolling column) | rebuilds — loses focus/caret/scroll | +| **4** | `isVisibleIf` + shorter bound labels (below) | content, not layout, must re-prioritise | nothing | + +Most views need **gear 0 + gear 1**. Escalate only when the shape of the problem +demands it — gear 3 is the only one that destroys component state. + +#### Gear 0 — minimum sizes are a hard floor (the #1 cause of "it won't narrow") + +A `JLabel`'s minimum width is its full text; containers propagate child minimums +upward; a flow grid reports the **sum** of its children's minimums. One +forgotten label deep in the tree gives the whole *window* a minimum width, and +the responsive bands are then unreachable — the layout never even gets to try. + +```java +.add("growx, wmin 0", label("A long descriptive caption")) // ellipsizes instead of strutting +.withMinSize(0, 0) // on every flow-grid panel +``` + +Prefer ranges over hard sizes: `width 90::200`, not `width 200!`. +**Rule:** drag the window as narrow as it goes. If it stops at an arbitrary +width, that is a minimum-size bug — fix it before touching anything else. + +#### Gear 4 — the content converges too + +```java +label("Live timetable · click any train to see its route").isVisibleIf(isWide) // + "hidemode 3" +Val btn = Viewable.of(String.class, theme, formfactor, + (t, f) -> f.isTall() ? "☾" : "☾ Dark mode"); +``` + +`hidemode 3` on the container constraint makes a hidden child stop reserving its +cell. Drop what is **redundant** (already shown elsewhere) before what is unique. + +#### The convergence checklist (apply this in every review) + +- [ ] Window narrows freely — no arbitrary floor (`wmin 0`, `withMinSize(0,0)`). +- [ ] Multi-column regions collapse to one column rather than becoming slivers. +- [ ] A stacked page sits in `scrollPane(conf -> conf.fitWidth(true))`. +- [ ] Everything with no natural preferred size (`scrollPane`, `scrollPanels`, + empty `textField`) has been given one. +- [ ] A nested grid lives inside another **grid**, never in a MigLayout cell (§2d). +- [ ] The primary action is reachable at every size. +- [ ] What vanishes when narrow is redundant, not unique. +- [ ] A view swap (gear 3) has hysteresis. +- [ ] Every `Var` variant spells out a constraint for **every** child. + +Full prose: [Convergent-Design.md](https://github.com/globaltcad/swing-tree/blob/main/docs/markdown/Convergent-Design.md). + +### 2d. Gear 1 — the responsive flow grid (`ResponsiveGridFlowLayout`, Bootstrap-style 12 columns) + +The workhorse, and **stateless**: no breakpoint field, no resize listener, no +view-model change. Each child declares how many of 12 virtual columns it occupies +per size category; the lambda re-runs on every resize. + +```java +private static final FlowCell ROSTER_SPAN = AUTO_SPAN( it -> it.fill(true) + .verySmall(12).small(12).medium(12).large(5).veryLarge(4).oversize(4) ); +private static final FlowCell EDITOR_SPAN = AUTO_SPAN( it -> it.fill(true) + .verySmall(12).small(12).medium(12).large(7).veryLarge(8).oversize(8) ); + +panel().withFlowLayout(UI.HorizontalAlignment.LEFT, 18, 18) +.withMinSize(0, 0).withPrefSize(900, 0) +.add(ROSTER_SPAN, roster(vm)) +.add(EDITOR_SPAN, editor(vm)); +``` + +That span table **is** the responsive design — read it out loud: *side by side +from LARGE up, one stacked column below.* + +**Size categories are exact fifths of the grid's reference width** (not pixels): + +| `VERY_SMALL` | `SMALL` | `MEDIUM` | `LARGE` | `VERY_LARGE` | `OVERSIZE` | +|---|---|---|---|---|---| +| 0…⅕ | ⅕…⅖ | ⅖…⅗ | ⅗…⅘ | ⅘…1 | ≥1 | + +An undeclared category falls back to the **nearest declared** one, so +`AUTO_SPAN(it -> it.large(12))` means "always full width". Spell all six out in +shared code anyway — the table then documents the design. + +**Reference width** = the explicitly set preferred width if there is one, else +the ideal single-row sum of all children. Declaring it (`withPrefSize(w, 0)`) is +how you *move* the breakpoints, and it is mandatory for a nested grid — otherwise +the nested grid reports "all children in one row" upward and silently rewrites +the parent's bands. + +**Other cell options:** `.fill(true)` stretches the cell to the row height (how a +short sidebar card ends up flush with a tall content card; a MigLayout child with +a `fill`/`filly` container constraint gets this automatically), and +`.align(UI.VerticalAlignment.TOP|CENTER|BOTTOM)` positions a non-filling cell. + +**Heights: a row is as tall as its tallest child's *preferred* height** — a flow +grid never stretches a row to fill a tall window. Hence two rules: +1. Give a preferred height to anything that has none: + `scrollPanels().withPrefSize(340, 470)`, or it collapses to one line. +2. Put a page-level grid in a `scrollPane(conf -> conf.fitWidth(true))`, or the + stacked layout is clipped at the bottom. + +> ⚠️ **THE NESTING TRAP — a grid nests in a grid, NOT in a MigLayout cell.** +> A wrapping grid is a *width-for-height* layout, so `ResponsiveGridFlowLayout` +> asks each child "how tall at the width you are about to get?" — and only +> another `ResponsiveGridFlowLayout` can answer. Meanwhile +> `JComponent.getPreferredSize()` short-circuits the layout manager once a +> preferred size is set, so a **MigLayout** parent reads the literal `0` from +> `withPrefSize(w, 0)` and **the nested grid collapses to zero height**, silently +> clipping everything in it. +> ```java +> // ❌ form laid out at height 0 +> panel("fill, wrap 1").add("grow, push", panel().withFlowLayout(..).withPrefSize(620,0)...) +> // ✅ make the card a grid too +> panel().withFlowLayout(UI.HorizontalAlignment.LEFT, 0, 0).withMinSize(0,0).withPrefSize(620,0) +> .add(FULL_ROW, titleStrip()).add(FULL_ROW, form()) +> ``` +> A grid declaring a reference width must live inside another grid or directly +> inside a `scrollPane(fitWidth(true))`. Anywhere else, drop the explicit +> preferred size. + +### 2e. Gear 2 — reactive layout: bind the layout itself to a `Var` + +To swap the *entire layout manager* at runtime (one toolbar row ↔ three rows, +compact ↔ wide, edit ↔ read mode) **without destroying or rebuilding any +child** — so focus, caret, selection and scroll offsets all survive: + +```java +import swingtree.api.Layout; +import swingtree.layout.MigAddConstraint; + +Var layout = Var.of(Layout.class, Layout.mig("fill, wrap 1")); + +panel(layout) // == panel().withLayout(layout) +.add("growx", a).add("growx", b); + +// later, anywhere — atomic reflow, no rebuild: +layout.set(Layout.mig("fill, wrap 2").withChildConstraints( + MigAddConstraint.of("growx"), + MigAddConstraint.of("growx, span 2") // positional: index 0, 1, ... +)); +``` + +`Layout` factories: `Layout.mig(constraints)`, `Layout.flow(FlowCell...)`, +`Layout.border()`, `Layout.grid(rows,cols)`, `Layout.box(UI.Axis.X)`, +`Layout.none()` (absolute positioning — `setLayout(null)`), `Layout.unspecific()` +(no-op, leaves current manager alone). `withChildConstraints(...)` maps +positionally to children. This is how `SalesDashboard`, `AlmanackView` and +`CelestialScribe` work — see §5.4 for deriving a layout from data. + +Two rules that cost debug time: +- **Every variant must supply a constraint for *every* child.** They apply + positionally and are only overwritten where a new layout supplies one, so a gap + leaves the previous variant's constraint (a stray `"wrap"`) in place after + switching back. +- **Add `nogrid`** to a wrapped MigLayout variant, or every row's columns line up + with every other row's and the second row inherits the first column's width. + +### 2f. Gear 3 — form-factor state and view swapping + +When the two shapes want genuinely different component trees (a split pane is a +good landscape design and a bad portrait one), classify the shape into a small +enum, keep it in the **view model** like any other state, and swap the body with +the property-bound `add(Val, ViewSupplier)`: + +```java +public enum Formfactor { + WIDE, TALL; + public boolean isTall(){ return this == TALL; } + /** 10% dead band — without hysteresis, dragging along the diagonal strobes. */ + public static Formfactor of( int width, int height, Formfactor current ) { + double slack = 1.1; + return current == TALL ? (width > height * slack ? WIDE : TALL) + : (height > width * slack ? TALL : WIDE); + } +} +``` +```java +of(this).withLayout(FILL.and(WRAP(1))) +.onResize( it -> formfactor.update(From.VIEW, f -> Formfactor.of(it.getWidth(), it.getHeight(), f)) ) +.add(GROW.and(PUSH), formfactor, this::body); // rebuilds only on an actual shape change +``` + +- **Always add hysteresis** (gears 1 and 2 don't need it — reflowing never + changes the width it was measured against; a view *swap* can). +- `onResize` fires per pixel of a drag, but `Var.update(..)` is a no-op when the + value is unchanged, so the rebuild happens once per shape change. +- The form factor is ordinary, Swing-free state ⇒ unit-testable without a GUI. +- If the swapped sub-view is built under a `StyleSheet`, re-enter the scope: + `UI.of(UI.use(sheet, () -> tallBody().get(JScrollPane.class)))` — `UI.use` + consumes the builder and returns the component (§7). + +--- + +## 3. State — Sprouts properties (`Var`, `Val`) and binding + +Reactivity comes from the **Sprouts** library. The whole point: **the view never +holds Swing state; it binds to properties, and the property system keeps the two +in sync bidirectionally.** Your business logic never imports a Swing class. + +- `Var` — a **mutable** property. `get()`, `set(value)`, `update(fn)`, `onChange(..)`. +- `Val` — a **read-only** view of a property. `Var extends Val`, so you can + expose `Val` from a view model to prevent the view from writing. +- `Vars` / `Vals` — observable **lists** of properties (classic MVVM). +- `Tuple` — an **immutable** ordered collection (functional MVI/MVL). + +```java +Var name = Var.of("Joseph"); +Var ok = Var.of(true); +Var count = Var.of(0); +Var lay = Var.of(Layout.class, Layout.mig("fill")); // explicit type when value could be null/ambiguous +``` + +### Binding properties to components + +Pass the property to the factory and the binding is automatic and bidirectional: + +```java +textField(name) // user typing -> name.set(..); name.set(..) -> field text +checkBox("Agree", ok) // toggling <-> ok +slider(Align.HORIZONTAL, 0.0, 1.0, ratio) // generic over Number: int OR double +comboBox(selectedEnum, e -> prettyLabel(e)) // selection <-> Var +label(name) // one-way: label text follows name +progressBar(Align.HORIZONTAL, ratioVal) // one-way Val 0..1 +``` + +Flags bind through `isXyzIf(Val)`: + +```java +textField(name).isEnabledIf(ok).isVisibleIf(showAdvanced) +button("Go").isEnabledIf(canSubmit) +checkBox("edit").isSelectedIf(...) // and isEditableIf on text components +``` + +### Derived (computed) read-only views + +`view*` methods produce a `Val` that recomputes when the source changes — perfect +for labels and computed flags: + +```java +Val caption = count.viewAsString(n -> "Items: " + n); +Val isEmpty = name.viewAs(Boolean.class, s -> s.isBlank()); +Val asD = count.viewAsDouble(n -> n / 100.0); +label(caption); +``` + +`viewAsString/Int/Double()` with **no mapper** just stringify/convert the value; +the `nullObject`-first overloads (`viewAsString("", fn)`) define what to show when +the source is null — null-safe by construction. To derive from **two** sources at +once, combine them — the result recomputes when *either* input changes: + +```java +Viewable total = Viewable.of(price, taxRate, (p, tr) -> p * (1 + tr)); // Val, updates live +``` + +To merge **any number** of sources (not just two) into one value without nesting, +use the **composite view builder** (Sprouts ≥ 2.7.0): a seed plus one +`join(property, wither)` per input, each folding that property's item into the seed. +It recomputes as a whole on any input change — ideal for feeding a *single* +`withStyle` from a whole cluster of view-model properties (§8): + +```java +Viewable weather = Viewable.of(Weather.blank(), it -> it + .join(city, Weather::withCity) + .join(temperature, Weather::withTemperature) + .join(humidity, Weather::withHumidity)); // Val, recomputed on any change +``` + +> All `view*`/`viewAs*` results are `Viewable` (a `Val` you may listen on). They +> are held **weakly** by their source — see the GC gotcha in §9c: if you only +> register an `onChange` on one, keep it in a field or it is collected. + +### The two change channels (`From.VIEW` vs `From.VIEW_MODEL`) + +Every `Var` distinguishes who caused a change: + +- `set(From.VIEW, v)` — the **user/view** changed it (SwingTree calls this for you when the user types/clicks). +- `set(From.VIEW_MODEL, v)` / plain `set(v)` — your **application logic** changed it. + +Register listeners per channel via `Viewable.cast(prop).onChange(From.VIEW_MODEL, it -> ...)` +(or `From.VIEW`, or `From.ALL`). This split prevents infinite feedback loops and +lets you react only to user input or only to logic. Inside a listener, +`it.currentValue()` is the new value. + +> **`prop.view()` vs `Viewable.cast(prop)`.** You cannot listen on a raw +> `Var`/`Val` directly — you need a `Viewable`. Two ways to get one, and the +> difference is lifecycle: `prop.view()` returns a **new, weakly-held** view +> (the sprouts-preferred default) — store it in a field so it isn't GC'd. +> `Viewable.cast(prop)` reinterprets the property *itself* as `Viewable`, so the +> listener lives exactly as long as that property object. Both are safe **only +> when the thing you listen on is reachable**: for a lens (which its parent holds +> *weakly*) you must keep the lens — or its `view()` — in a field either way (§9c). + +```java +Viewable.cast(firstName).onChange(From.ALL, it -> + fullName.set(it.currentValue().orElseThrowUnchecked() + " " + lastName.get()) +); +``` +**Warning:** The approach above can lead to memory leaks due to change listeners +never being garbage collected and still holding strong references to captured variables. + +→ So the prefer custom change listener registration on views instead of directly! + +--- + +## 4. Lenses — `zoomTo` and immutable view models + +This is the heart of the **recommended** SwingTree architecture (MVI/MVL). A +**lens** focuses a root `Var` down onto one field, giving you +a `Var` that reads via a getter and writes via a **wither** (a method that +returns a *new* record with that field changed). + +```java +record Person(String forename, String surname, Address address) { + Person withForename(String f){ return new Person(f, surname, address); } + Person withSurname(String s){ return new Person(forename, s, address); } + Person withAddress(Address a){ return new Person(forename, surname, a); } +} + +Var person = Var.of(new Person("Tom","Schultz", addr)); +Var forename = person.zoomTo(Person::forename, Person::withForename); +Var
address = person.zoomTo(Person::address, Person::withAddress); +Var street = address.zoomTo(Address::street, Address::withStreet); // lenses nest! +``` + +Now `textField(forename)` edits the forename, and a keystroke produces a brand-new +`Person` (and `Team`, etc., all the way up) inside `person`. Lenses are **smart**: +they fire change events only when *their own slice* actually changes, even if the +whole root record was replaced. + +Other lens flavors: +- `viewAs(Type.class, getter)` / `viewAsString/Double/Int(getter)` — **read-only** derived `Val`. +- `zoomToNullable(Type.class, getter, wither)` — when the focused value may be null. +- `zoomTo(defaultValue, getter, wither)` — supply a fallback for null parents. +- `zoomTo(Lens)` — a hand-written lens (implement `Lens.getter`/`wither`, or + `Lens.of(getter, wither)`) when the focus needs **logic** — clamping, derived + fields, or zooming into a collection entry (see below). + +**Tip:** Generate withers with Lombok `@With` on records to avoid boilerplate +(this is also how you stay on **Java 8** — records need 16+, but `@With @Getter` +on a `final class` gives the same value semantics): +```java +@With record Person(String forename, String surname, Address address) {} +// person.zoomTo(Person::forename, Person::withForename) // withForename generated by @With +``` + +### Sprouts immutable collections — `Tuple`, `Association`, `ValueSet`, `Pair` + +Records model *fixed* shape; for *variable-size* state inside a view model, use +Sprouts' **persistent** (structural-sharing) collections instead of +`java.util` — they are immutable value objects, so they fit record fields and +withers, and SwingTree binds to several of them directly. Every "mutation" +returns a **new** instance. + +| Type | `java.util` analogue | Make it | Key ops (all return a new instance) | +|---|---|---|---| +| `Tuple` | `List` | `Tuple.of(a,b,c)`, `Tuple.of(T.class)` (empty), `Tuple.of(T.class, iterable)` | `add`, `remove`, `removeAt`, `setAt(i,x)`, `map`, `retainIf`/`removeIf`, `slice`, `sort`, `first`/`last` | +| `Association` | `Map` | `Association.between(K.class, V.class)` (empty!), `.ofLinked(..)` (insertion-ordered) | `put`, `putAll(Pair...)`, `get(k) → Optional`, `remove`, `removeIf(pair->..)` | +| `ValueSet` | `Set` | `ValueSet.of(E.class)`, `ValueSet.of(a,b,..)`, `.ofLinked(..)` | `add`, `addAll`, `remove`, `retainAll`, `retainIf`, `any(pred)` | +| `Pair` | `Map.Entry` | `Pair.of(a, b)` | `.first()`, `.second()` | + +> ⚠️ The empty-map factory is **`Association.between(K.class, V.class)`**, *not* +> `Association.of(..)` — `of(key, value)` builds a one-entry map (and +> `of(String.class, Integer.class)` would silently make an `Association`). + +A field of one of these *is* part of the immutable value, so it composes with +lenses and withers like any other field: + +```java +@With record PartyPlan( + Tuple guests, // ordered, may repeat + Association drinkStock, // name -> quantity + ValueSet decorations // unique, unordered +) {} + +Var plan = Var.of(initialPlan); +Var> guests = plan.zoomTo(PartyPlan::guests, PartyPlan::withGuests); +Var> stock = plan.zoomTo(PartyPlan::drinkStock, PartyPlan::withDrinkStock); + +guests.update(g -> g.add(new Guest("Gimli"))); // immutable add, fires change +stock.update(s -> s.put("Ale", 12)); // immutable put +``` + +You can even **lens into a single entry** of a collection with logic lenses — +the write rebuilds the whole collection immutably, but the property behaves like +a plain `Var` (great for binding one map value to one field): + +```java +Var aleStock = stock.zoomTo( + s -> s.get("Ale").orElse(0), // getter: read the entry + (s, qty) -> s.put("Ale", qty) // wither: return a new map +); +aleStock.set(20); // updates the entire association inside `plan` +``` + +`Tuple` is the one most wired into SwingTree: `addAll(..)` renders one sub-view +per element (§5.2), and `Var>` is the canonical MVI list. + +--- + +## 5. Architecture — how to structure a real app + +A SwingTree **view** is conventionally a `class extends JPanel` whose constructor +takes the view model (or a `Var` of it) and builds itself with `UI.of(this)`: + +```java +public final class MyView extends JPanel { + public MyView(Var vm) { + UI.of(this).withLayout("fill, wrap 1") + .add(...) + .add(...); + } + public static void main(String[] args) { + Var vm = Var.of(new MyViewModel()); + UI.show(f -> new MyView(vm)); + EventProcessor.DECOUPLED.join(); // keep the app thread alive (see §11), processes events forever (blocks) + } +} +``` + +Pull repeated fragments into `private static UIForAnySwing someSection(...)` +methods that return builders — this is the standard way large views (TeamView, +BreathingView, CelestialScribe) stay readable. + +### 5.1 MVI / MVL — the recommended pattern (immutable records + lenses) + +The whole UI state lives in **one immutable record** (the view model). The view is +a pure function of it; every change produces a new record via withers; the view +reaches fields through `zoomTo`. There are no Swing references and no mutable +fields in the view model — it is unit-testable in isolation. + +**View model** (note: `static empty()` / no-arg constructor for the initial state, +withers for every field, and *business methods* that return new instances): + +```java +public record CalculatorViewModel(CalculatorInputs inputs, CalculatorOutput output) { + public static CalculatorViewModel empty(){ return new CalculatorViewModel(CalculatorInputs.empty(), CalculatorOutput.empty()); } + public CalculatorViewModel withInputs(CalculatorInputs i){ return new CalculatorViewModel(i, output); } + public CalculatorViewModel withOutput(CalculatorOutput o){ return new CalculatorViewModel(inputs, o); } + public CalculatorViewModel runCalculation(){ // business logic = pure function returning new VM + try { + double l = Double.parseDouble(inputs.left()), r = Double.parseDouble(inputs.right()); + double res = switch (inputs.operator()) { + case ADD -> l+r; case SUBTRACT -> l-r; case MULTIPLY -> l*r; case DIVIDE -> l/r; + }; + return withOutput(output.withResult(res).withValid(true)); + } catch (NumberFormatException e) { return withOutput(output.withError("Invalid number").withValid(false)); } + } +} +``` + +For business logic that can **fail** (parsing, validation, IO), Sprouts' +`Result` is a cleaner alternative to ad-hoc error fields: it is a `Maybe` +(present-or-empty, like `Optional`) that *also* carries a `Tuple` +describing what went wrong. `Result.ofTry(T.class, () -> risky())` runs a +throwing supplier and captures any exception as a `Problem` instead of +propagating it — ideal inside a pure view-model method. The view then renders +`result.problems()` (e.g. an error label) and `result.orElse(fallback)` for the +value. (SwingTree itself returns `Result` from table-cell conversions.) + +**View** zooms in and triggers business methods with `vm.set(vm.get().runCalculation())` +or, more idiomatically, `vm.update(CalculatorViewModel::runCalculation)`: + +```java +public final class CalculatorView extends JPanel { + public CalculatorView(Var vm) { + Var inputs = vm.zoomTo(CalculatorViewModel::inputs, CalculatorViewModel::withInputs); + Var output = vm.zoomTo(CalculatorViewModel::output, CalculatorViewModel::withOutput); + UI.of(this).withLayout("fill") + .add("growx", textField(inputs.zoomTo(CalculatorInputs::left, CalculatorInputs::withLeft))) + .add(comboBox(inputs.zoomTo(CalculatorInputs::operator, CalculatorInputs::withOperator), Operator::symbol)) + .add("growx", textField(inputs.zoomTo(CalculatorInputs::right, CalculatorInputs::withRight))) + .add("wrap", button("Run!").onClick(e -> vm.update(CalculatorViewModel::runCalculation))) + .add("span", label(output.viewAsString(o -> o.valid() ? "= " + o.result() : o.error()))); + } +} +``` + +`vm.update(fn)` is shorthand for `vm.set(fn.apply(vm.get()))` — prefer it for +applying a business method. + +### 5.2 Lists in MVI/MVL — `Tuple` + `addAll` + `HasId` + +Model a collection as a `Tuple` field; zoom to it; render with `addAll`: + +```java +record ChatVM(Tuple allMessages, String draft) { + record Message(UUID id, String text, LocalDateTime sentAt, boolean editing) implements HasId { + Message(){ this(UUID.randomUUID(), "", LocalDateTime.now(), false); } + } +} + +Var> messages = vm.zoomTo(ChatVM::allMessages, ChatVM::withAllMessages); + +scrollPanels() +.addAll(messages, (Var entry) -> { // one sub-view per item; entry is a per-item lens + Var text = entry.zoomTo(Message::text, Message::withText); + return panel(FILL) + .add(GROW_X.and(WRAP), textArea(text)) + .add(RIGHT, button("✕").onClick(it -> messages.update(t -> t.remove(entry)))); +}); + +// add an item: +messages.update(t -> t.add(new Message().withText(draft.get()))); +``` + +> **CRITICAL: when you bind a *mutable* `Var>` and want a per-item lens, +> the item type MUST implement `sprouts.HasId`** (carry a `UUID`/stable +> id). That overload — `addAll(Var>, entry -> ...)`, where `entry` is a +> `Var` lens — is the one above, and it is `>`. Value +> records define identity by *content*, so two equal records would confuse the +> component binding; `HasId.id()` gives each item a stable identity so SwingTree +> knows which sub-view maps to which item, which item-lens to hand it, and which +> rows to reuse vs. rebuild on change. Add a `UUID id` field and `implements +> HasId`. +> +> The **read-only** overloads do *not* require `HasId`: `addAll(Val>, +> m -> view)` and `addAll(Tuple, m -> view)` (and the `Vals` MVVM overload) +> hand the supplier the **value** `M`, not a lens — use these when items aren't +> individually editable. `HasId` is the price of admission for per-item editing. + +> **A bound `addAll` OWNS its container — give it a panel of its own.** The +> binding manages every child, so a component that already had children added by +> hand is **cleared** when `addAll` binds to it (SwingTree logs "Trying to bind +> multiple sub-views to component … Clearing component now"). A heading plus a +> bound list is therefore two components, not one: +> ```java +> // ❌ the heading is silently deleted when the binding attaches +> panel().add(FULL_ROW, label("ROOMS")).addAll(CHIP_SPAN, rooms, this::roomChip) +> // ✅ the list gets a container to itself +> panel().add(FULL_ROW, label("ROOMS")).add(FULL_ROW, roomRail()) +> // where roomRail() == panel().withFlowLayout(..).addAll(CHIP_SPAN, rooms, this::roomChip) +> ``` + +> **A row supplier runs *later*, so under a `StyleSheet` it must re-enter the +> scope.** `UI.use(sheet, ..)` only binds what is built inside its lambda, and +> `addAll` rebuilds rows whenever the tuple changes — long after the constructor +> returned. Initial rows then look right and every row built after the first +> model change comes out unstyled (§7): +> ```java +> private UIForAnySwing row( Var entry ) { // the supplier passed to addAll +> return UI.of(UI.use(sheet, () -> rowBody(entry).get(JPanel.class))); +> } +> ``` + +`Tuple` is functional: `.add(x)`, `.remove(x)`, `.map(fn)`, `.setAt(i, x)`, +`.get(i)`, `.size()`, `.isEmpty()` — all return new tuples (or values). +`Tuple.of(Message.class)` makes an empty typed tuple; `Tuple.of(a, b, c)` a +populated one. + +### 5.3 Classic MVVM — mutable view models (the alternative) + +If you prefer mutable view models: the view model holds `Var` *fields* directly +(no root record, no lenses), exposes them through getters, and uses `Vars` for +observable lists. The view binds straight to those fields. + +```java +public class PersonVM { + private final Var firstName = Var.of("Joseph"); + private final Var lastName = Var.of("Armstrong"); + private final Var fullName = Var.of(""); + public PersonVM() { + Viewable.cast(firstName).onChange(From.ALL, it -> recompute()); + Viewable.cast(lastName ).onChange(From.ALL, it -> recompute()); + recompute(); + } + private void recompute(){ fullName.set(firstName.get() + " " + lastName.get()); } + public Var firstName(){ return firstName; } // mutable out + public Var lastName(){ return lastName; } + public Val fullName(){ return fullName; } // read-only out +} +``` + +**Polymorphic / dynamic sub-views** work in both patterns via the property-bound +`add` overload — when the property changes, SwingTree swaps the sub-view: + +```java +// MVVM: Var subVM, view supplier dispatches on type +.add(vm.subViewModel(), subVM -> + subVM instanceof SubVM1 s ? new SubView1(s) : new SubView2((SubVM2) subVM)) + +// MVI: Val + supplier picks which fragment to (re)build +.add("grow, push", hasSelection, has -> has ? editorBody(vm) : emptyState()) +``` + +A `Vars` (MVVM) and a `Var>` (MVI) are both rendered with +`addAll(list, viewSupplier)`. **TeamView exists in the SwingTree repo in both flavors** +(`examples.team.mvi` and `examples.team.mvvm`) — the clearest side-by-side +contrast. Choose **MVI/MVL for new code**; reach for MVVM only when integrating +with existing mutable models. + +### 5.4 Deriving a layout from data (advanced reactive) + +`CelestialScribe` derives the entire child layout from a tuple of model objects — +positions are a pure function of state, so dragging a star just updates the model: + +```java +Val layout = stars.viewAs(Layout.class, tuple -> { + Layout.None none = Layout.none(); + for (int i = 0; i < tuple.size(); i++) + none = none.withChildBound(i, tuple.get(i).bounds()); + return none; +}); +box().withLayout(layout).withRepaintOn(stars).addAll(stars, this::starPanel); +``` + +--- + +## 6. Events + +Every component supports the same base events; the handler receives a delegate +(conventionally `it`) that wraps **both the component and the event state** and +offers query/animation helpers. + +```java +button("Go") +.onClick(it -> doThing()) // also: onClick(Runnable) for no-arg +.onMouseClick(it -> ...).onMousePress(it -> ...).onMouseRelease(it -> ...) +.onMouseEnter(it -> ...).onMouseExit(it -> ...).onMouseMove(it -> ...).onMouseDrag(it -> ...) +.onFocusGain(it -> ...).onFocusLoss(it -> ...) +.onKeyPress(it -> ...).onKeyRelease(it -> ...).onKeyTyped(it -> ...) +.onResize(it -> ...).onShown(it -> ...).onHidden(it -> ...); +``` + +Useful delegate methods: `it.get()` / `it.getComponent()` (the component), +`it.getParent()`, `it.mouseX()` / `it.mouseY()`, `it.animateFor(..)` (§9), +`it.paint(status, g -> ...)` (custom rendering), drag deltas +(`it.deltaXSinceStart()`, `it.initialComponentPosition()`). **All geometry these +return is in DPI-agnostic "developer pixels"** (except `mouse*OnScreen()`, which +is raw screen pixels) — see §13. + +### Custom / model-driven events: `on(..)` vs `onView(..)` + +Both attach an `Action` to any `sprouts.Observable` (e.g. an `Event` from +`Event.create()`, or a property). The difference is **which thread runs the +handler**: + +| Method | Handler runs on | Use for | +|---|---|---| +| `onView(observable, it -> ...)` | **EDT** (Swing thread) | reacting to model changes that **touch the view** — resize a label, animate a colour | +| `on(observable, it -> ...)` | **application thread** | reacting to external/business events that **update your model** — network, custom input | + +Rule: if your handler sets Swing properties → `onView`; if it mutates the view +model or does non-UI work → `on`. + +--- + +## 7. Styling — the functional `withStyle` API + +`.withStyle(it -> it. ... )` receives a `ComponentStyleDelegate` (`it`) and returns +a configured one. It is **immutable and re-run on every paint**, so styles can +depend on live state (selection, animation progress, model fields). This is how +SwingTree paints shadows, gradients, rounded borders, etc. *on top of* the current +Look-and-Feel — things plain Swing cannot do. + +```java +panel("fill") +.withStyle(it -> it + .margin(8).padding(24) + .backgroundColor(new Color(57,221,255)) + .foregroundColor(Color.WHITE) + .borderRadius(32) + .border(2, Color.DARK_GRAY) // width + color + .borderAt(Edge.LEFT, 5, accent) // one edge only (great for accent bars) + .shadowColor(new Color(0,0,0,128)).shadowBlurRadius(5).shadowSpreadRadius(1).shadowOffset(0,2) + .shadowIsInset(false) +); +``` + +Frequently used delegate methods (all chainable, all DPI/HiDPI aware): + +- Box: `margin`, `padding`, `borderRadius`, `borderRadiusAt(Corner, w, h)`, `border`, `borderAt(Edge, w, color)`, `prefSize`, `size`. +- Fill: `backgroundColor` / `foundationColor`, `foregroundColor`, `gradient(...)`, `noise(...)`, `image(img -> ...)`. +- Shadow: `shadowColor`, `shadowBlurRadius`, `shadowSpreadRadius`, `shadowOffset`, `shadowIsInset`. Named shadows: `.shadow("name", s -> s.color(..).offset(..))`. +- Layered painting: `.painter(Layer.CONTENT, g -> ...)` for raw `Graphics2D`. +- `component()` returns the live component, so you can branch on its state (e.g. `it.component().isSelected()`). **Deprecated for reading geometry** — its sizes are in *component pixels* and double-scale if fed back in; use `componentWidth/Height()` / `componentPrefWidth/Height()` instead (§13). + +Gradients and named layers: + +```java +.gradient(Layer.BACKGROUND, "glow", g -> g + .type(GradientType.RADIAL) // or LINEAR + .boundary(ComponentBoundary.BORDER_TO_INTERIOR) + .span(Span.TOP_LEFT_TO_BOTTOM_RIGHT) + .offset(cx, cy).size(radius) + .colors(color(0.75,1,0.5,0.5), color(0.5,1,1,0)) // UI.color(r,g,b[,a]) -> UI.Color + .clipTo(ComponentArea.BODY) +) +``` + +`UI.Color` (via `color(...)`, `Color.ofRgb(...)`, `Color.ofHsb(...)`) adds +`.blend(other, t)`, `.shade(amount)`, `.brighter()`, alpha helpers — handy for +deriving palettes. + +### Font styling (`componentFont`) + +```java +.withStyle(it -> it.componentFont(f -> f + .size(32).family("Arial").weight(2f).color(Color.WHITE).posture(0.1f).spacing(0.12f) + .gradient(grad -> grad.colors(Color.GREEN, Color.BLUE).span(UI.Span.LEFT_TO_RIGHT)) + .noise(n -> n.colors(Color.DARK_GRAY, Color.CYAN).function(UI.NoiseType.CELLS).scale(1.25)) +)) +``` + +There are also `.withFontSize(n)`, `.withForeground(color)`, `.withBackground(color)` +shortcuts directly on the builder for simple cases. + +### Background filtering (frosted glass) + +A non-opaque child can blur/scale the parent's pixels behind it: + +```java +.withStyle(it -> it + .backgroundColor(Color.TRANSPARENT) // must be non-opaque for the filter to show + .parentFilter(f -> f.area(ComponentArea.BODY).blur(16).scale(1.25, 1.25)) +) +``` + +### Central style sheets + semantic groups (CSS-like, hot-swappable themes) + +For app-wide styling, pull rules into a `StyleSheet` and tag components with +`.group(EnumTag)` / `.id("name")`. This is how the **Theme Garden** swaps five +complete themes at runtime with zero changes to the view skeleton. + +```java +enum Skin { PRIMARY, SECONDARY } + +final class MySheet extends StyleSheet { + @Override protected void configure() { + add(type(JButton.class), it -> it.borderRadius(8).padding(6,14,6,14)); + add(type(JButton.class).group(Skin.PRIMARY), it -> it.backgroundColor(BLUE).foregroundColor(WHITE)); + add(id("ok-button"), it -> it.shadowBlurRadius(8)); + } +} +``` + +Traits: `id("x")` (most specific), `group(tag)` (prefer **enum** tags over +strings — type-safe), `type(Class)`. They compose: +`type(JButton.class).group(Skin.PRIMARY)`. Specificity: `id` > `type+group` > +`group` > `type`; later `add(..)` wins ties. + +Install a sheet either globally — +`SwingTree.initializeUsing(cfg -> cfg.styleSheet(new MySheet()))` — or for a scope: + +```java +UI.use(new MySheet(), () -> UI.show(f -> new MyView())); // only components built INSIDE the lambda bind +``` + +> `UI.use(sheet, supplier)` **consumes** the builder it is handed and returns the +> finished component. And it only binds what is built *inside* the lambda — so a +> sub-view built later (a property-bound `add(Val, ViewSupplier)`, a lazy tab) +> must re-enter the scope itself, or it comes out unstyled: +> `UI.of(UI.use(sheet, () -> tallBody().get(JScrollPane.class)))`. + +**Hot-swap themes**: keep mutable state in the sheet and call `reconfigure()` to +re-run `configure()` and instantly repaint every component in the `UI.use` scope: + +```java +final class ThemedSheet extends StyleSheet { + private Theme theme = Theme.LIGHT; + public void setTheme(Theme t){ if (t != theme){ theme = t; reconfigure(); } } + @Override protected void configure(){ switch (theme){ case LIGHT -> light(); case DARK -> dark(); } } +} +// in the view: bind a Var to the sheet +Viewable.cast(theme).onChange(From.ALL, it -> sheet.setTheme(theme.get())); +UI.use(sheet, () -> of(this).group(Skin.FRAME). ... .add(comboBox(theme))); +``` + +--- + +## 8. Property-driven styles — `withStyle(prop, styler)` (and `withRepaintOn`) + +Style lambdas are evaluated by the **UI thread**, as part of the paint cycle. So when +a style depends on property state, don't read the property inside a plain `withStyle` +lambda — hand the property to the style and receive its item as an argument: + +```java +box() +.withStyle(orbScale, (scale, it) -> it.shadowBlurRadius((int)(16 + 78 * scale)). ...) +``` + +The item is captured on the property's owning thread and passed to the lambda +explicitly, and the component re-styles and repaints **automatically** on every +change. This is the thread-safe and preferred way to use property state in styles: +a plain `withStyle(it -> ... someVal.get() ...)` reads application-thread state from +the UI thread (unsafe under `EventProcessor.DECOUPLED`) and doesn't refresh by +itself either. Styles driven by several properties compose by chaining: +`.withStyle(a, ..).withStyle(b, ..)`. + +### Merging *many* properties into **one** `withStyle` (Sprouts ≥ 2.7.0) + +When one style rule genuinely depends on **several** properties at once, you don't +have to chain a `withStyle` per property. Declare a small **record in the view** that +holds everything the style needs, and merge all the source properties into a single +`Viewable` with the Sprouts **composite view builder** +`Viewable.of(seed, it -> it.join(p, combiner)...)` — a seed record plus one +`join(property, wither)` per input, each folding that property's item into the record. +A *single* `withStyle` then drives the whole style from the merged item, for **any** +number of inputs: + +```java +record Avatar(Color accent, int diameter, boolean online) { + Avatar withAccent(Color c) { return new Avatar(c, diameter, online); } + Avatar withDiameter(int d) { return new Avatar(accent, d, online); } + Avatar withOnline(boolean o) { return new Avatar(accent, diameter, o); } +} + +label(initials) +.withStyle( + Viewable.of(new Avatar(Color.GRAY, 38, false), it -> it + .join(accentColor, Avatar::withAccent) // Val + .join(diameter, Avatar::withDiameter) // Val + .join(isOnline, Avatar::withOnline)), // Val + (a, it) -> it + .prefSize(a.diameter(), a.diameter()) + .backgroundColor(a.accent()) + .borderRadius(1000) + .border(a.online() ? 2 : 0, Color.GREEN) +); +``` + +The composite item is recomputed **as a whole** whenever *any* joined property +changes (fold starts at the seed, applies each combiner in join order, reads the +*current* item of every input), so one `withStyle` stays in sync with all of its +inputs. It scales to any number of properties without nesting, and a property may be +joined more than once. **This is the idiomatic way to capture multiple reactive +view-model properties in a single thread-safe styler.** Requires **Sprouts 2.7.0+** +(`Viewable.of(seed, configurator)` — the composite builder — was added there). Use the +`Viewable.of(Type.class, seed, ..)` overload when the record type is polymorphic. + +> **No field needed — build it inline.** A composite is a *view* +> (`isView() == true`), and SwingTree's property bindings hold **views (and lenses) +> strongly** internally (§9c), so the inline `Viewable.of(..)` above is safe from GC +> even though views are otherwise only weakly held by their sources. (Chaining +> separate `withStyle(a,..).withStyle(b,..)` calls is still fine and reads clearer when +> the rules are independent; reach for the composite when one rule needs several +> inputs together, or when you want a single styler for a whole cluster of state.) + +An animated flavor transitions towards each new item over a `LifeTime` +(`anim.progress()` runs 0→1 on every item change): + +```java +label("status") +.withStyle(status, LifeTime.of(0.5, TimeUnit.SECONDS), (s, anim, it) -> it + .backgroundColor(mix(s.color(), anim.progress()))) +``` + +The same **composite merge** works here: hand a merged `Viewable` (built with +`Viewable.of(seed, it -> it.join(...)...)`, Sprouts ≥ 2.7) as the property, and every +change of *any* joined input restarts the transition towards the newly merged item. + +The full family of property/animation styling entry points (all cross-linked in their +Javadocs): + +| Method | Driven by | Use for | +|---|---|---| +| `withStyle(it -> ..)` | nothing (plain) | static style, or live state you read *safely* (no app-thread props) | +| `withStyle(prop, (item, it) -> ..)` | a property **item** | thread-safe property-driven style, auto-repaint | +| `withStyle(prop, LifeTime, (item, anim, it) -> ..)` | a property **item** + transition | *animate towards* each new item | +| `withTransitionalStyle(boolVar, LifeTime, (state, it) -> ..)` | a **boolean** property | bidirectional 0↔1 transition as the flag flips (§9b) | +| `withTransitoryStyle(observable, LifeTime, (state, it) -> ..)` | an `Observable`/`Event` | a one-shot temporary style animation on each fire | + +The two item-driven rows (`ItemStyler`/`AnimatedItemStyler`) are the ones that benefit +from the composite merge — collapse *N* properties into one record and feed a single call. + +`withRepaintOn(observableOrEvent, ...)` remains the right tool for repaint triggers +that are *not* property-item-driven styles — e.g. repainting a custom painter when +an `Event` fires, or a bound custom layout (§5) whose inputs changed. + +--- + +## 9. Animation + +Animations are timer-driven lambdas invoked ~60×/s on the EDT. Two levels: + +### 9a. View-side, fire-and-forget (`it.animateFor` / `UI.animateFor`) + +```java +button("hover me") +.onMouseEnter(it -> it.animateFor(0.5, TimeUnit.SECONDS, status -> { + double h = 1 - status.progress() * 0.5; + it.setBackgroundColor(h, 1, h); +})); +``` + +The `AnimationStatus status` gives you `progress()` (0→1), `fadeIn()`, `fadeOut()`, +`pulse()`, `cycle()`. Drive *anything* from it: colors, bounds (`setBounds`), +text, or custom rendering via `it.paint(status, g -> ...)`: + +```java +.onMouseClick(it -> it.animateFor(1.2, TimeUnit.SECONDS, s -> it.paint(s, g -> { + g.setColor(new Color(120,176,238,(int)(200*s.fadeOut()))); + for (int i=0;i<5;i++){ double r=280*s.fadeIn()*(1-i*0.18); + g.drawOval((int)(it.mouseX()-r/2),(int)(it.mouseY()-r/2),(int)r,(int)r); } +}))); +``` + +`UI.animateFor(dur, unit).go(s -> someVar.set(s.progress()))` runs an animation not +tied to an event; `.asLongAs(s -> true).go(...)` loops forever (ambient effects). +A common idiom: animate a `Var` and let a property-bound +`withStyle(progress, (p, it) -> ..)` (§8) render the frames. + +### 9b. View-side transition between two states (`withTransitionalStyle`) + +Given a `Var` and a duration, SwingTree interpolates `progress` 0↔1 every +time the flag flips. Multiply style props by `state.progress()`: + +```java +label("toggle me") +.withTransitionalStyle(isOn, LifeTime.of(2, TimeUnit.SECONDS), (state, it) -> it + .borderRadius(38 * state.progress()) + .backgroundColor(200/255d, 210/255d, 220/255d, state.progress()) + .shadowBlurRadius(10 * state.progress()) +); +// elsewhere: toggleButton("toggle").onClick(it -> isOn.set(it.get().isSelected())); +``` + +### 9c. Modelled animation (MVI-friendly — state lives in the view model) + +For testable, multi-phase animation, the view model exposes an `Animatable` (a pure +function of `AnimationStatus` → new model). The view hands it to `UI.animate(vm, vm::xxx)` +and **re-arms** the next phase by listening for the model's phase change. + +```java +// view model +public Animatable breathAnimation() { + BreathPhase ph = this.phase; double secs = settings.secondsFor(ph); + return Animatable.of(LifeTime.of(secs, TimeUnit.SECONDS), this, + new AnimationTransformation<>() { + public BreathingViewModel run(AnimationStatus s, BreathingViewModel m){ // pure, every frame + return m.withPhase(ph).withPhaseProgress(s.progress()).withOrbScale(ph.scaleAt(s)); + } + public BreathingViewModel finish(AnimationStatus s, BreathingViewModel m){ // once, at end + return m.advancePhase(); + } + }); +} + +// view: chain phases by re-arming on phase change +Viewable.cast(phase).onChange(From.VIEW_MODEL, it -> { + if (vm.get().running()) UI.animate(vm, BreathingViewModel::breathAnimation); +}); +// start it: +button.onClick(it -> { vm.update(BreathingViewModel::begin); UI.animate(vm, BreathingViewModel::breathAnimation); }); +``` + +> **GC GOTCHA (this WILL bite you):** Sprouts lenses/views observe their parent +> **weakly**. SwingTree's own bindings (`label`, `slider`, `withRepaintOn`, …) +> hold a strong ref internally, so lenses you pass *to them* are safe as locals. +> But a lens consumed **only** by a raw `Viewable.cast(lens).onChange(..)` +> subscription (like the `phase` re-arming lens above) is **not** retained — it +> gets garbage-collected and the animation silently freezes after one phase. +> **Fix: keep that lens as a `private final` field of the view.** (See the +> `BreathingView.phase` field and its Javadoc.) + +--- + +## 10. Tables, lists, icons, dialogs + +### Tables — model them as **data** (`TableData`), never as a `TableModel` + +`TableData` (`swingtree.api.model`) is an **immutable value describing a whole +table**: cells + column names + column classes + a `UI.ListData` layout. Put it in a +`Var`, bind it, done — no model subclass, no `updateTableOn(..)`, no event to fire, +and thread-safe by construction (§11). **This is the preferred way to build tables.** + +```java +Var data = Var.of( + TableData.of(UI.ListData.ROW_MAJOR, "Name", "Age") // columns first, no rows yet + .addRow("Alice", 30) + .addRow("Bob", 42) +); + +UI.table(data); // that's the whole binding +data.update(it -> it.addRow("Carol", 55)); // ...and the table follows +``` + +Every method returns a **new** `TableData` (verbs mirror `Tuple`, §4): + +| | | +|---|---| +| read | `getValueAt(r,c)`, `getRow(r)`, `getColumn(c)`, `getRowCount()`, `getColumnCount()`, `isEmpty()`, `indexOfColumn(name)`, `getColumnName(i)`, `getColumnClass(i)`, `isEditable()`, `layout()`, `cells()`, `columnNames()`, `columnClasses()` | +| cell | `setCellAt(r, c, value)` — **not** `setValueAt` (that is `TableModel`'s *mutator*) | +| rows | `addRow(vals…)`, `addRowAt(i, vals…)`, `addRows(t)`, `addRowsAt(i, t)`, `setRowAt(i, vals…)`, `setRowsAt(i, t)`, `removeRowAt(i)`, `removeRowsAt(i, n)`, `removeAllRows()` | +| columns | `addColumn(name, cls, vals)`, `addColumnAt(i, ..)`, `setColumnAt(i, vals)`, `removeColumnAt(i)`, `removeColumnsAt(i, n)`, `setColumnNameAt(i, name)`, `setColumnClassAt(i, cls)`, `setColumnNames(..)`, `setColumnClasses(..)` | +| whole | `setCells(t)`, `withLayout(listData)`, `TableData.empty()`, `TableData.row(vals…)` | + +Rows, columns, names, classes and both counts may **all change at any time** — +reshaping a table is just another value, not a special case. Indices rot when columns +move, so address columns by meaning: `it.setCellAt(0, it.indexOfColumn("Age"), 31)`. + +**Performance — do not hand-roll around it.** `Tuple`s are persistent (structural +sharing: adding a row to a 1000-row table copies no rows), and a `ROW_MAJOR` table +forwards the tuple's change-diff to the `JTable` as **targeted** row events — a row +add repaints that row, not the table. **Prefer range ops**: `addRows(..)` / +`removeRowsAt(..)` / `setRowsAt(..)` emit **one** event instead of N. +(`COLUMN_MAJOR` stores columns, so a change never maps onto a row range and it must +rebuild — use `ROW_MAJOR` for big/lively tables. All methods still speak +`(row, column)` in either layout.) + +**Editable needs BOTH** a `*_EDITABLE` layout **and** a mutable `Var` — a `Val`, or a +`Var` with a non-editable layout, yields a read-only table. Edits flow back into the +property as a new value. Flip it live with `it.withLayout(ROW_MAJOR_EDITABLE)`. + +`getColumnClass` drives the `JTable`'s renderer/editor, so +`setColumnClassAt(i, Boolean.class)` buys you check boxes for free. + +Custom cell rendering: `.withCell(cell -> cell.view(c -> c.orGetUi(() -> textField()).updateIf(JTextField.class, tf -> { tf.setText(cell.entryAsString()); return tf; })))`. + +#### Legacy table sources — still supported, but prefer `TableData` + +All of these are **pull-based**: they need `updateTableOn(..)`/`updateOn(..)`, they +cannot say *what* changed (so every refresh rebuilds the whole table), and they are +read live — which forces SwingTree to copy the whole table on every refresh under +`DECOUPLED`. + +```java +UI.table().withModel(m -> m.colName(i -> headers[i]).colCount(() -> headers.length) + .rowCount(() -> data.length).getsEntryAt((r,c) -> data[r][c]) + .setsEntryAt((r,c,val) -> data[r][c] = (int) val) + .isEditableIf(() -> true).updateOn(dataChangedEvent)); // must .fire() by hand +UI.table(UI.ListData.ROW_MAJOR_EDITABLE, () -> listOfRows).updateTableOn(evt); +UI.table(UI.MapData.EDITABLE, () -> mapOfColumns).updateTableOn(evt); +UI.table(UI.ListData.ROW_MAJOR, tupleVar); // Var>>: TableData minus the + // column metadata; keeps the diff fast-path +``` +`BasicTableModel` is only a *description of where the data lives* — SwingTree wraps it +in a thread-safe model of its own, so `JTable.getModel()` does **not** return the +object you passed to `withModel(..)`. + +Full prose: [Writing-Tables.md](https://github.com/globaltcad/swing-tree/blob/main/docs/markdown/Writing-Tables.md). +Executable catalogue of the whole `TableData` API: +[Table_Data_Spec.groovy](https://github.com/globaltcad/swing-tree/blob/main/src/test/groovy/swingtree/Table_Data_Spec.groovy). + +### Icons & SVG (first-class, HiDPI-crisp) + +SVG works **everywhere an icon can appear** (icons, buttons, labels, tabs, +menus, dialogs, style-API images), rendered via JSVG + Java2D, re-rendered at +the current UI scale — never blurry. All icon sizes are developer px (§13). + +**`IconDeclaration` — the right type for view models.** A lightweight immutable +value (path or SVG text + preferred size); loading is lazy + cached, and a +missing resource logs instead of throwing. It is a functional interface over +`source()`: + +```java +IconDeclaration funnel = () -> "img/funnel.svg"; // simplest: lambda +enum Icons implements IconDeclaration { // idiomatic: constants + FUNNEL("img/funnel.svg"), SEED("img/seed.png"); + private final String path; + Icons(String p){ this.path = p; } + @Override public String source(){ return path; } +} +Icons.FUNNEL.withSize(24, 24) / .withWidth(24) // sizing withers +IconDeclaration.ofSvg(svgText) // SVG string; reports the size declared in the SVG +IconDeclaration.ofAutoScaledSvg(svgText) // SVG string; size -1 -> stretches to its component +``` + +**Using them:** `icon(decl)`, `icon(48, 48, decl)`, `button(decl)`, +`label("x").withIcon(decl)`, `tab("t").withIcon(decl)`. **Dynamic:** bind a +`Var` — `icon(iconProp)`, `labelWithIcon(iconProp)`, +`buttonWithIcon(iconProp)`, `menuItem("Connect", iconProp)`; set the property +and the icon swaps. View models hold `IconDeclaration`s, never `ImageIcon`s. + +**Loading by hand:** `UI.findIcon("path")` → `Optional` (classpath → +file system → cache; returns an `SvgIcon` for `.svg`); `UI.findSvgIcon(..)` → +`Optional`. Cache lives in `SwingTree.get().getIconCache()`, keyed by +declaration — prefer declarations over hand-built `SvgIcon`s so equal +declarations share one instance. + +**`SvgIcon`** (`swingtree.style`) — immutable `ImageIcon` subclass; construct +directly only when the SVG text is dynamic (editors, server-sent graphics): +`SvgIcon.of(svgString | stream | document)` / `SvgIcon.at(path | url)`, then +`.withIconSize(w,h)`, `.withIconSizeFromWidth(w)` (height from aspect ratio), +`.withOpacity(f)`, `.withFitComponent(..)`, `.withPreferredPlacement(..)`. +Reported size (`getIconWidth()/getIconHeight()`, DPI-scaled): an explicit size +wins; else a **directly constructed** `SvgIcon.at/of(..)` adopts the px +`width`/`height` declared in the SVG text; **-1** (= unknown → icon adapts to +its component) when those are missing, `%`-based, or non-px units — **and for +every declaration-pipeline load** (`findIcon`, `icon(path)`, +`IconDeclaration.of(path)`): the declaration's default `Size.unknown()` +deliberately resets the icon to flexible. While a dimension is unknown, two +policies control rendering: `UI.FitComponent` — `NO`, `WIDTH`, `HEIGHT`, +`WIDTH_AND_HEIGHT` (these three may distort), `MIN_DIM`/`MAX_DIM` (fit +smaller/larger dimension, keep aspect ratio — usually what you want) — and +`UI.Placement` (`CENTER`, `TOP_LEFT`, … 9 positions). `.getImage()` rasterizes +to a `BufferedImage` (loses scalability — visibly blurry when stretched). + +**Style API images:** `.image(img -> img.svg(svgText).fitMode(..).placement(..))` +or `img.image(iconDeclOrImageIcon)`; plus `opacity`, `size`, `offset`, `repeat`, +`primer(color)`, `clipTo(ComponentArea.BODY|BORDER|INTERIOR|..)`. Layer via the +outer overload `image(Layer.BACKGROUND, img -> ..)`. If the SVG text/config comes +from a property, use the property-bound `withStyle(prop, (svg, it) -> ..)` (§8). +Playground example covering all of this: +[SvgViewer.java](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/stylish/SvgViewer.java). + +### Dialogs (`JOptionPane` wrappers) + +```java +ConfirmAnswer a = UI.confirmation("Continue?").titled("Confirm").show(); // YES/NO/CANCEL/CLOSE +UI.confirmation("Heads up!").showAsWarning(); // .showAsError() .showAsInfo() +UI.message("Saved.").showAsInfo(); // no return value +// customize buttons: .yesOption("OK").noOption("").cancelOption("") (empty hides a button) +``` + +--- + +## 11. Threading & lifecycle + +- SwingTree binding/animation callbacks run on the **EDT**. Business logic that you + trigger via `on(..)` runs on the **application thread**. +- `UI.run(r)` runs on EDT now; `UI.runLater(r)` / `runLater(delay, r)` defer to EDT. +- In a `main`, after `UI.show(...)`, call `EventProcessor.DECOUPLED.join()` to keep + the (decoupled) application thread alive so the program doesn't exit. +- **Under `DECOUPLED`, never let the EDT read mutable application state.** Bind + *values* (immutable records, `Tuple`s, `TableData` — §10) rather than live data + sources: an immutable value cannot be seen half-updated, so no locking, no torn + reads. Pull-based sources (lambda/collection table models, §10) force SwingTree to + copy the whole thing on every refresh to get the same guarantee. +- Set a Look-and-Feel before showing if desired (examples use FlatLaf: + `FlatDarkLaf.setup();` / `FlatLightLaf.setup();`). + +--- + +## 12. Escape hatches & error containment + +SwingTree wraps **every lambda it invokes for you** in try/catch + SLF4J logging, +so a thrown exception in one fragment doesn't tear down the whole UI ("the show +must go on"). Caught: `peek`, `apply`, `applyIf`, `applyIfPresent`, `withStyle`, +all `onXyz` handlers, and `zoomTo` map/wither lambdas. **NOT** caught: code at the +top level of your declaration (your own `for`/`if`/arithmetic *outside* a captured +lambda) — push risky top-level code into `apply(ui -> ...)` or `peek(c -> ...)`. + +| Hatch | Use | +|---|---| +| `.peek(c -> ...)` | **last resort** — reach into the raw Swing component only when SwingTree wraps no equivalent (see the caution below) | +| `.apply(ui -> ...)` | imperative loop that `add(..)`s many children (the lambda gets the builder) | +| `.applyIf(boolean, ui -> ...)` | inline conditional sub-tree (static shape decisions) | +| `.applyIfPresent(Optional>)` | inline `Optional`-driven sub-tree | +| `.get(JPanel.class)` | unwrap the builder to the real component | +| `UI.of(jcomponent)` | wrap a hand-rolled/3rd-party component into the tree | + +> **Prefer reactivity over hatches.** If a condition depends on app state, bind it +> (`isVisibleIf`, `isEnabledIf`, property-bound `add`) instead of `applyIf`, so the +> UI updates automatically. The hatches are for *construction-time* decisions. + +> **`peek(..)` is a code smell — always look for a SwingTree method first.** It hands +> you the raw component and steps *outside* SwingTree's control, forfeiting what the +> library gives you for free: HiDPI "developer-pixel" scaling (§13), the style +> engine's ownership of colours/opacity/borders (§7), decoupled-thread safety (§11), +> and any usability fixes SwingTree layers over raw Swing. So before writing `peek`, +> look for the SwingTree variant — a `with*` setter (e.g. `withPrefSize`, +> `withBackground`, `withTooltip`), an `is*If(Val)` binding, an `on*(..)` +> event handler, `withStyle(..)`, or `withProperty(key, value)` for a client +> property. `peek` is legitimate **only** when no such method exists — a niche Swing +> setter SwingTree genuinely does not wrap (say `JTable#setRowHeight`), or capturing +> a third-party component — and then keep it to that one imperative line. + +--- + +## 13. HiDPI scaling — "developer pixels" vs "component pixels" + +SwingTree maintains one **UI scale factor** (`UI.scale()`, a `float`, derived +from the system font) and applies it everywhere, because vanilla Swing + the +JDK's bundled Look-and-Feels do **not** scale for HiDPI. This creates two +coordinate spaces: + +- **Developer pixels** — the DPI-agnostic numbers *you* write (`withPrefSize(100,50)`). +- **Component pixels** — the real scaled numbers Swing lays out/paints (at scale `2.0` → `200×100`). + +**The symmetry you can rely on:** everything you pass *into* the SwingTree API is +in developer pixels and gets scaled **up** for you; everything SwingTree reads +*back* for you is scaled **down** into developer pixels. So values round-trip +cleanly — you almost never call `UI.scale(..)` yourself. + +- **Inputs scaled up:** all builder dims (`withPrefSize/withMinSize/withWidth/withSizeExactly/...`) + and all style dims (`prefSize`, `minHeight`, `margin`, `padding`, `borderWidth`, + `borderRadius`, gradient/shadow offsets & sizes, …). +- **Outputs scaled down (already in developer px):** + - Style delegate: `it.componentWidth()`, `it.componentHeight()`, + `it.componentPrefWidth()`, `it.componentPrefHeight()`. + - Event delegates (`onClick`, `onResize`, `onMouseMove`, `onDrag`, …): + `it.getX/getY/getPosition`, `it.getWidth/getHeight/getSize`, `it.getPrefSize`, + `it.getBounds`; setters like `it.setBounds/setPrefSize/setMinSize` take + developer px. Mouse: `it.mouseX()/mouseY()/mousePosition()`. Drag: + `it.initialComponentPosition()`, `it.dragPositions()`, `it.deltaXSinceStart()`. + +> **THE DOUBLE-SCALING TRAP (this is why `component()` is deprecated):** the raw +> Swing component returns **component pixels**. If you read +> `it.component().getPreferredSize().height` (already scaled) and pass it back +> into a scaling method like `minHeight(..)`, it is scaled **twice** — min height +> becomes `200` when you meant `100`, and the error grows with the scale factor. +> **Fix:** use the developer-pixel accessor instead: +> ```java +> .withStyle( it -> it.minHeight(it.componentPrefHeight()) ) // ✅ round-trips; NOT it.component().getPreferredSize().height ❌ +> ``` + +> **THE ONE EXCEPTION:** absolute on-screen coords are **raw**, not unscaled — +> `it.mouseXOnScreen()`, `it.mouseYOnScreen()`, `it.mousePositionOnScreen()` are +> in real screen pixels (they're desktop-absolute, possibly multi-monitor). + +Only call the raw helpers when working **against raw Swing** (custom `Graphics2D` +painting, a peeked component, a third-party widget): `UI.scale(int|float|double)` +(developer→component), `UI.unscale(int|float|Dimension)` +(component→developer), `UI.scale(Graphics2D)` (scales a context in place), +`UI.scale()` (the raw factor). Override the factor with +`SwingTree.get().setUiScaleFactor(2.0f)` or +`SwingTree.initializeUsing(cfg -> cfg.uiScaleFactor(2.0f))`. Full prose: +[HiDPI-Scaling.md](https://github.com/globaltcad/swing-tree/blob/main/docs/markdown/HiDPI-Scaling.md). + +## 14. Hard-won gotchas (check these in any review) + +1. **A view that only works at one window size is a bug.** Build convergent by + default (§2c): `wmin 0` / `withMinSize(0,0)` everywhere, a 12-column + `AUTO_SPAN` grid for the page, a `scrollPane(conf -> conf.fitWidth(true))` + around it so the stacked arrangement can outgrow the window. +2. **Minimum sizes are a hard floor and propagate upward.** A label's minimum + width is its full text and a flow grid's minimum is the **sum** of its + children's — one forgotten row gives the whole *window* a minimum width and + the responsive bands become unreachable. `"wmin 0"` on rows, `withMinSize(0,0)` + on grids, `width 90::200` instead of `width 200!`. +3. **A responsive grid nests inside another grid — never inside a MigLayout + cell.** `withPrefSize(w, 0)` declares the reference width, but + `getPreferredSize()` short-circuits the layout manager, so a MigLayout parent + reads that literal `0` and the nested grid **collapses to zero height**, + silently clipping its content. Make the containing card a grid too (§2d). Also + give a preferred height to anything that has none (`scrollPane`, + `scrollPanels`, empty `textField`) — a grid row is only as tall as its + tallest child *prefers* to be. +4. **Never `setOpaque(..)`** on a styled component — the style engine controls + opacity; manual calls fight it. Use `backgroundColor(Color.TRANSPARENT)` / a real + color in `withStyle` instead. +5. **`Tuple` items bound for *per-item editing* (`addAll(Var>, entry -> ..)`, + where `entry` is a `Var` lens) must `implement HasId`** with a stable id — + otherwise equal value-records collide and bindings target the wrong sub-view. The + read-only `addAll(Val>/Tuple, m -> ..)` overloads pass the value and + need no `HasId` (§5.2). +5b. **A bound `addAll` owns its container and clears hand-added children, and its + row supplier runs *later*** — so give the list a panel of its own, and under a + `StyleSheet` wrap the supplier in `UI.use(sheet, ..)` or every row built after + the first model change comes out unstyled (§5.2, §7). +6. **Hold a strong reference (a view field) to any lens used only by a raw + `onChange` subscription** — weak observation will GC it and silently break (§9c). +7. **Tables: bind a `Var`; don't reach for a `TableModel` or a pull-based + data source** (§10). An editable table needs **both** a `*_EDITABLE` layout **and** + a mutable `Var` — either alone is silently read-only. Use **`ROW_MAJOR`** (the + diff-driven, incremental path) and **range ops** (`addRows`/`removeRowsAt`/ + `setRowsAt`) for bulk changes; per-row loops emit one event each. +8. **Never read property values inside a plain `withStyle` lambda** — use the + property-bound `withStyle(prop, (item, it) -> ..)` (§8), which captures the item + thread-safely and repaints automatically. (`withRepaintOn(props) + prop.get()` + is the legacy version of this pattern.) When one style depends on **several** + properties, merge them into one record with the Sprouts ≥2.7 composite view builder + `Viewable.of(seed, it -> it.join(p, wither)…)` and drive it from a single + `withStyle` — no need to chain one per property (§8). +9. **Pick the right thread:** `onView` for view-touching handlers, `on` for + model/business handlers; respect `From.VIEW` vs `From.VIEW_MODEL` to avoid + feedback loops. +10. **View models import zero Swing classes.** If you find a `JComponent` in a view + model, the architecture is wrong. +11. Expose **`Val`** (not `Var`) from a view model for fields the view must not write. +12. Use **enum** group tags and the type-safe layout constants for refactor safety. +13. Withers must be **pure** and return **new** instances (Lombok `@With` on records + is the cleanest path); never mutate `this`. +14. **Never feed a raw Swing size/position back into the SwingTree API** — values + from `it.component().getPreferredSize()`/`getBounds()`/`getWidth()` are in + *component pixels* (already scaled); passing them to `minHeight(..)`/`size(..)`/etc. + double-scales them. Read geometry through the delegate accessors + (`componentPrefHeight()`, `getWidth()`, `mouseX()`, …) which give developer pixels. (§13) +15. **`peek(..)` is a code smell — prefer a SwingTree method.** Raw-component tweaks + step outside HiDPI scaling, the style engine and decoupled-thread safety; reach + for a `with*`/`is*If`/`on*`/`withStyle`/`withProperty` method first. `peek` is + legitimate only when SwingTree wraps no equivalent (§12). + +--- + +## 15. Cheat sheet + +```java +import static swingtree.UI.*; +import sprouts.*; // Var, Val, Vars, Vals, Tuple, From, Viewable, HasId, Event + +// build + show +UI.show(panel("fill, wrap 2").add("growx", textField(name)).add(button("Go").onClick(it -> ...))); +UI.show(f -> new MyView(vm)); EventProcessor.DECOUPLED.join(); + +// view skeleton +UI.of(this).withLayout(FILL.and(WRAP(1)).and(INS(16))).add(GROW, child); + +// state +Var v = Var.of(value); v.get(); v.set(x); v.update(fn); Val d = v.viewAsString(fn); +v.isEnabledIf / isVisibleIf / isSelectedIf / isEditableIf (Val) +Viewable c = Viewable.of(a, b, (x,y) -> combine); // derived from 2 sources; result type = a's type +Viewable r = Viewable.of(R.class, a, b, (x,y) -> ..); // ...or with an explicitly different result type +Viewable m = Viewable.of(seed, it -> it.join(a,C::withA).join(b,C::withB).join(c,C::withC)); // N sources → 1 record (Sprouts ≥2.7) +Viewable w = v.view(); // weakly-held listenable view (store in a field!) + +// sprouts immutable collections (persistent; every op returns a new instance) +Tuple t = Tuple.of(a,b,c) / Tuple.of(T.class); // List-like: add/remove/setAt/map/retainIf/sort +Association m = Association.between(K.class,V.class);// Map-like: put / get(k)->Optional / remove (NOT .of!) +ValueSet s = ValueSet.of(E.class); // Set-like: add/addAll/retainAll/any +Result res = Result.ofTry(T.class, () -> risky()); // Maybe + Tuple; res.problems()/orElse(x) + +// lenses (MVI/MVL) +Var f = root.zoomTo(Root::f, Root::withF); // mutable lens +Val r = root.viewAs(F.class, Root::f); // read-only view +Var e = root.zoomTo(c -> c.get(k).orElse(d), (c,x) -> c.put(k,x)); // lens into a collection entry +Var> items = root.zoomTo(Root::items, Root::withItems); +panel.addAll(items, (Var it) -> itemView(it)); // per-item lens ⇒ Item implements HasId! +panel.addAll(roTuple, (Item it) -> itemView(it)); // read-only value ⇒ no HasId needed + +// tables (§10) — an immutable value describing the WHOLE table; bind it and it follows +Var d = Var.of(TableData.of(UI.ListData.ROW_MAJOR, "Name","Age").addRow("Alice",30)); +UI.table(d); d.update(it -> it.addRow("Bob", 42)); // no updateTableOn/Event needed +it.setCellAt(r,c,v) / .addRowAt(i,vals…) / .removeRowAt(i) / .setColumnClassAt(i,Boolean.class) +it.addRows(t) / .removeRowsAt(i,n) / .setRowsAt(i,t) // range ops ⇒ ONE table event, not N +// editable ⇔ *_EDITABLE layout AND a mutable Var; ROW_MAJOR ⇒ incremental (diff) updates + +// events +.onClick / .onMouseEnter / .onMouseClick / .onKeyPress / .onResize (it -> ...) +.on(observable, it -> appWork) .onView(observable, it -> viewWork) + +// style +.withStyle(it -> it.padding(8).borderRadius(12).backgroundColor(c).shadowBlurRadius(6) + .gradient(Layer.BACKGROUND,"g",g->g.type(GradientType.RADIAL).colors(a,b)) + .componentFont(fc -> fc.size(14).family("Serif"))) +.withStyle(prop, (item, it) -> it.backgroundColor(item.color())) // property-driven, auto-repaint (§8) +.withStyle(Viewable.of(seed, it -> it.join(a,Seed::withA).join(b,Seed::withB)), (m,it)->..) // N props → 1 styler (Sprouts ≥2.7; §8) +.withRepaintOn(eventA, eventB) +.withTransitionalStyle(boolVar, LifeTime.of(0.4, SECONDS), (state, it) -> it. ...progress()...) + +// animation +it.animateFor(0.5, TimeUnit.SECONDS, s -> ... s.progress() / s.fadeIn() / it.paint(s, g->...)); +UI.animateFor(2, SECONDS).go(s -> p.set(s.progress())); +UI.animate(vm, ViewModel::someAnimatable); + +// convergence — the default page skeleton (§2c). Categories are FIFTHS of the reference width. +scrollPane(conf -> conf.fitWidth(true)).withHorizontalScrollBarPolicy(UI.Active.NEVER).add( + panel().withFlowLayout(UI.HorizontalAlignment.LEFT, 18, 18) + .withMinSize(0,0) // a grid's minimum is the SUM of its children's — kill it + .withPrefSize(REFERENCE_WIDTH, 0) // declares where the bands sit; MANDATORY for a nested grid + .add(AUTO_SPAN(it->it.fill(true).verySmall(12).small(12).medium(12).large(5).veryLarge(4).oversize(4)), sidebar) + .add(AUTO_SPAN(it->it.fill(true).verySmall(12).small(12).medium(12).large(7).veryLarge(8).oversize(8)), content)); +.add("growx, wmin 0", label(..)) // or its text becomes the window's minimum width +scrollPanels().withPrefSize(340, 470) // a grid row is only as tall as its tallest child PREFERS +// ⚠ a grid with withPrefSize(w,0) must sit in a GRID or a fitWidth scrollPane — a MigLayout +// cell reads the literal 0 and the grid renders at zero height (§2d) +label(..).isVisibleIf(isWide) // + "hidemode 3" on the container ⇒ content converges too + +// reactive layout (gear 2 — reflow, nothing rebuilt: focus/caret/scroll survive) +Var L = Var.of(Layout.class, Layout.mig("fill, wrap 1")); +panel(L)...; L.set(Layout.mig("fill, wrap 2, nogrid").withChildConstraints(MigAddConstraint.of("growx, span 2"))); +// every variant must give EVERY child a constraint (positional, only overwritten where supplied) + +// form factor (gear 3 — swaps the tree; needs hysteresis, loses component state) +.onResize(it -> ff.update(From.VIEW, f -> Formfactor.of(it.getWidth(), it.getHeight(), f))) +.add(GROW.and(PUSH), ff, this::body); + +// icons & SVG (crisp at any DPI; sizes in developer px) +IconDeclaration ic = () -> "img/x.svg"; // value object -> belongs in view models +IconDeclaration.ofSvg(svgText) / .ofAutoScaledSvg(svgText) / ic.withSize(24,24) +icon(ic) / button(ic) / label("x").withIcon(ic) / tab("t").withIcon(ic) +icon(iconProp) / labelWithIcon(iconProp) / buttonWithIcon(iconProp) // Val -> swaps live +UI.findIcon("img/x.svg") / UI.findSvgIcon(..) // Optional<..>, classpath + cache +SvgIcon.of(svgText).withIconSizeFromWidth(64).withFitComponent(FitComponent.MIN_DIM) +.withStyle(it -> it.image(img -> img.svg(svgText).fitMode(..).placement(..))) + +// style sheet + theme +UI.use(sheet, () -> UI.show(f -> new View())); // sheet.reconfigure() hot-swaps + +// escape hatches (peek = last resort; prefer a with*/is*If/on*/withStyle method — §12) +.peek(c -> c.setX(..)).apply(ui -> {for(..) ui.add(..);}).applyIf(cond, ui -> ui.add(..)).get(JPanel.class) + +// HiDPI scaling — you write developer px (scaled up), delegates return developer px (scaled down) +.withStyle(it -> it.minHeight(it.componentPrefHeight())) // ✅ round-trips; NOT it.component().getPreferredSize().height ❌ +it.getWidth()/getHeight()/getBounds()/mouseX()/mouseY() // all developer px; mouse*OnScreen() = raw screen px +UI.scale(int|float|double) / UI.unscale(..) / UI.scale(g2d) // only when working against RAW Swing +``` + +### Runnable examples in the SwingTree repo (read these for full context) + +All example sources live under [`src/test/java/examples/`](https://github.com/globaltcad/swing-tree/tree/main/src/test/java/examples) +in the repo; the links below open each on GitHub. + +- [`calculator/mvi/CalculatorView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/calculator/mvi/CalculatorView.java) — canonical MVI/MVL. +- [`team/mvi/TeamView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/team/mvi/TeamView.java) **vs** [`team/mvvm/TeamView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/team/mvvm/TeamView.java) — same UI, both architectures. +- [`chat/mvi/ChatView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/chat/mvi/ChatView.java) (+ `ChatViewModel`, `Room`, `Message`, `ChatStyle`, `ChatArt`) — **the reference for `Tuple` + `addAll` + `HasId`**, inside a whole messenger: a room rail, a roster, message bubbles editable in place, and emoji reactions, all bound off one immutable root. Three less obvious ideas live here too: a **lens onto a *computed* projection** (`vm.zoomTo(ChatViewModel::visibleMessages, ChatViewModel::withVisibleMessages)` — the getter filters the selected room by the search box, the wither merges edits and deletions back by `id`, so one lens reacts to three inputs with zero listeners); **generated SVG as a value** (`ChatArt` builds the room sigils and a "conversation ribbon" as SVG *text*, fed to `withStyle(svgVal, (svg, it) -> it.image(img -> img.svg(svg)))`); and a hot-swapped `StyleSheet` whose row suppliers **re-enter the `UI.use(..)` scope** — the gotcha that otherwise leaves every dynamically added row unstyled (§5.2). +- [`trains/mvi/TrainsView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/trains/mvi/TrainsView.java) (+ `TrainsViewModel`, `TransitClient`) — real-world MVI: `Tuple`-valued state, a Swing-free data layer doing blocking IO off the EDT, and Lombok `@With`/`@Getter` value objects (records-free, **Java 8**-clean). +- [`budget/mvi/BudgetView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/budget/mvi/BudgetView.java) (+ `BudgetViewModel`, `Budget`, `BudgetHealth`) — **the reference for convergence (§2c/2d): four arrangements of three cards from one span table, with zero state.** It also showcases three other ideas at once: a **value-model table** bound with `UI.table(Var)` (editable, edits flow back as a new value; a `withCellForColumn` renderer/editor euro-formats the Amount column yet commits back a `Double`), a **value-capturing SVG style** `withStyle(svgText, (svg, it) -> it.image(img -> img.svg(svg)))` driving a donut chart generated from the data, and a **composite view** `Viewable.of(seed, it -> it.join(a, ..).join(b, ..)…)` (Sprouts ≥2.7) merging three properties into one item for a single `withStyle`. +- [`breathing/mvi/BreathingView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/breathing/mvi/BreathingView.java) (+ `BreathingViewModel`) — modelled animation, re-arming, the GC gotcha. +- [`animated/AnimatedView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/animated/AnimatedView.java) / [`TransitionalAnimation.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/animated/TransitionalAnimation.java) — the full animation primitive tour. +- [`zen/ThemeGardenView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/zen/ThemeGardenView.java) (+ `ThemedStyleSheet`) — style sheets, groups, runtime theme swap. +- [`scribe/CelestialScribe.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/scribe/CelestialScribe.java) — `Layout.none()` derived from data, styled text flowing around children. +- [`dashboard/SalesDashboard.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/dashboard/SalesDashboard.java) — reactive `Var` reflow. +- [`almanack/mvi/AlmanackView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/almanack/mvi/AlmanackView.java) (+ `AlmanackViewModel`) — every tab binding mechanism in one field-notebook app: a two-way `Var` selection index that may point at tabs which don't exist yet (deferred selection), `addAll(Val>, TabSupplier)` dynamic tabs, enum⇄index lenses, bound tab titles/tooltips/enabled flags. +- [`stylish/SoftUIView.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/stylish/SoftUIView.java) — soft-UI style sheet, custom paint. +- [`stylish/SvgViewer.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/stylish/SvgViewer.java) — SVG playground: one SVG rendered through four pipelines (`SvgIcon` in style API, `img.svg(..)` string, rasterized `getImage()`, component icon) with live `Placement`/`FitComponent` switching. +- [`simple/ResponsiveLayout.java`](https://github.com/globaltcad/swing-tree/blob/main/src/test/java/examples/simple/ResponsiveLayout.java) (+ `ResponsiveLayoutAlign`, `ResponsiveLayoutFill`) — the smallest `AUTO_SPAN` responsive flow demo. + +**Convergent examples, by which gears they use (§2c):** `budget/mvi/BudgetView` +and `zen/ThemeGardenView` (gears 0+1, pure span tables); `animated/AnimatedView` +(0+1 with a **nested** grid — the recipe list is a column as a sidebar, a chip +grid when stacked); `team/mvi/TeamView` + its `mvvm` twin (0+1, master–detail +with a nested responsive *form*, and the grid-in-a-grid card that makes it +measure correctly); `breathing/mvi/BreathingView` (0+1 plus size-relative +*painting* — the orb is sized from its box, not in pixels); +`almanack/mvi/AlmanackView` (0+2+4, four breakpoints feeding four `Val` +properties, nothing ever rebuilt); `trains/mvi/TrainsView` (0+2+3+4 — a +`Formfactor` in the view model swapping a split pane for a scrolling column, +plus a reactive toolbar and bound labels that shorten); +`chat/mvi/ChatView` (0+1+2+4 and **deliberately no gear 3** — a chat is full of +state you must not destroy, so every shape is reached by reflowing: nested grids +turn the room rail and the roster from sidebars into banners, a `Val` +composer measures *its own* width rather than the window's, and the conversation's +preferred height is derived from the window inside the view model, because a flow +grid gives a row the height its tallest child *prefers* and never stretches it). + +The wiki ([`docs/markdown/`](https://github.com/globaltcad/swing-tree/tree/main/docs/markdown)) is the prose +companion; start at [README.md](https://github.com/globaltcad/swing-tree/blob/main/docs/markdown/README.md) → +[Climbing-Swing-Tree.md](https://github.com/globaltcad/swing-tree/blob/main/docs/markdown/Climbing-Swing-Tree.md) → +[Functional-MVVM.md](https://github.com/globaltcad/swing-tree/blob/main/docs/markdown/Functional-MVVM.md). +For layout specifically: +[Convergent-Design.md](https://github.com/globaltcad/swing-tree/blob/main/docs/markdown/Convergent-Design.md) +(strategy + checklist) → +[Responsive-Layouts.md](https://github.com/globaltcad/swing-tree/blob/main/docs/markdown/Responsive-Layouts.md) +(grid mechanics, nesting rules, a debugging table) → +[Reactive-Layouts.md](https://github.com/globaltcad/swing-tree/blob/main/docs/markdown/Reactive-Layouts.md). \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..624341a19 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,142 @@ +# AGENTS.md + +This repo is FluxEngine, a USB floppy-disk drive tool. The existing codebase is C++, +and there is an active, incremental migration of components to Java. The Java side is +the current focus of development. This document describes the Java build structure and +the coding conventions used. Follow it when making changes. + +## Build system + +Bazel with bzlmod. There is **no WORKSPACE file** — all dependency declarations live in +`MODULE.bazel` (rules_java, rules_jvm_external for Maven deps, rules_proto). + +- Java sources: `java/` (standard Bazel layout, `com` is a direct child of `java`) +- Java tests: `javatests/` +- Packages (Java): `com.cowlark.fluxengine` (Main, FluxEngineComponent), + `com.cowlark.fluxengine.cli`, `com.cowlark.fluxengine.core`, `com.cowlark.fluxengine.core.flags`, + `com.cowlark.fluxengine.data`, `com.cowlark.fluxengine.usb`, `com.cowlark.fluxengine.wiring` +- Each package directory has its own `BUILD.bazel`. + +Useful commands: + +- `bazel build //java/...` +- `bazel test //javatests/...` +- `bazel run //java/com/cowlark/fluxengine:fluxengine -- ` (JVM binary) +- `bazel build //:fluxengine_deb //:fluxengine_rpm` (jpackage .deb/.rpm installers; root + aliases `//:fluxengine`, `//:fluxengine_deb`, and `//:fluxengine_rpm` exist) +- `bazel build //:fluxengine_app_image` (jpackage app-image, produced as a tar file) +- `bazel build //:fluxengine_msi //:fluxengine_dmg` (Windows MSI / macOS DMG installers, + only buildable on their native platforms) + +## Gotchas + +- Because there is no WORKSPACE, Java rules are **not autoloaded**. Every BUILD file must + explicitly load what it uses, e.g. + `load("@rules_java//java:defs.bzl", "java_library", "java_binary", "java_plugin", "java_test")`. +- `javax.usb.properties` must sit at the **classpath root** (the usb4java `Services` + constructor requires it via `UsbHostManager.getProperties()`). It lives at + `java/javax.usb.properties`, is exported from `java/BUILD.bazel`, and is pulled in as a + resource (`resources = ["//java:javax.usb.properties"]`) by the usb library. Bazel's + resource jarring strips the leading `java/`, so it lands at the jar root. Do not move it + into the package directory. +- The `.deb` and `.rpm` installers are built with jpackage via the `jpackage` rule in + `jpackage.bzl` (which uses the configured Java toolchain's `jpackage`). Because `rpmbuild` + writes to `/var/tmp` and read-only sandbox paths by default, the rule stages everything + under a writable `workdir/` and, for rpm, points rpmbuild's `_tmppath`/`_builddir` etc. at + it via a `~/.rpmmacros` file. The `jpackage_app_image` rule produces the raw app-image + directory as a tar file. +- The MSI (`//:fluxengine_msi`) and DMG (`//:fluxengine_dmg`) targets use `select()` to set + the jpackage `package_type` per platform (`@platforms//os:windows` → `msi`, + `@platforms//os:osx` → `dmg`); jpackage can't cross-compile, so on any other platform the + type is `unsupported`, which makes the rule produce an empty target (so `bazel build + //java/...` still works everywhere). + +## Lombok builders + +- The flag classes live in `com.cowlark.fluxengine.core.flags` (one class per file: `Flag`, + `FlagGroup`, `Flags`, `ActionFlag`, `SettableFlag`, `ValueFlag`, `StringFlag`, `IntFlag`, + `HexIntFlag`, `DoubleFlag`, `BoolFlag`). Construct flag instances with + `XxxFlag.builder().setGroup(g).setNames(names).setHelpText(h).build()` rather than + constructors. `@Builder(setterPrefix = "set")` on the private all-args constructor + generates the `setX` methods (the ctor param is named `helpText` for `setHelpText`). The + `core/flags` BUILD defines a `lombok_plugin` (`generates_api = True`, wired via + `plugins`); lombok is also a compile-time `dep` so the `import lombok.Builder;` resolves. +- Lombok doesn't run under Turbine, and generated classes don't reach the header jar, so + `.bazelrc` sets `--experimental_java_header_compilation=false`. +- Pattern: put `@Builder` on a private all-args constructor. `@Builder.Default` can't supply + custom defaults on parameters (illegal `= value` syntax, and defaults to 0/null/false), + so normalize defaults in the constructor body (e.g. `defaultValue != null ? defaultValue : + ""`). `FlagGroup.addFlag(this)` happens in the base `Flag` constructor, so `build()` + registers the flag. +- The `names` parameter is annotated `@Singular`, so builders offer `setName("--foo")` + (one name at a time), `setNames(collection)`, and `clearNames()` — Lombok can't generate a + varargs setter, and `@SuperBuilder` is unusable here because its auto-generated constructor + can't run the `addFlag` side-effect, so `@Singular` avoids hand-writing a builder per class. +- `HexIntFlag` extends `ValueFlag` directly (not `IntFlag`): two `@Builder`s would + both generate a static `builder()` and clash via hiding. + +## Flags parsing + +- Parsing is done by the static `Flags.parse(ImmutableList argv, FlagGroup... groups)` / + `Flags.parseWithFilenames(ImmutableList argv, Predicate callback, + FlagGroup... groups)` (both also accept `ImmutableList`). It first runs + `FlagGroup.initialise` over every root group (recursive duplicate-name check + into a shared `Set`, marking groups initialised), then walks argv and resolves each flag via + `FlagGroup.findFlag(key)`, which scans the group's own flags then recurses into its parents. + `Flags.parse` calls `flag.set(value)` and only consumes a space-separated value when + `useThat && flag.hasArgument()`. `findFlag` is public and overridable so a group can + intercept/absorb flags (e.g. a config group) before they fall through to its parents. +- `parseWithFilenames` returns `ImmutableList` (Guava). Duplicate flag names throw + `IllegalStateException`; unknown flags throw `FluxEngineException`. +- `ConfigFlagGroup` (config package) overrides `findFlag` to intercept dotted `--key.subkey=value` + arguments: it strips the leading `--` and routes them to `ConfigBuilder.set(path, value)`, + which delegates to `ProtoPath.set(builder, path, value)`. `ProtoPath` resolves the dotted + path (with optional `field[4]` indices) against the `ConfigProto` builder via + `com.google.protobuf` reflection, creating intermediate messages and coercing the string + value (int/uint/long/float/double/bool/enum) as needed. Unknown paths and bad values throw + `ConfigException`. + +## CLI + +- Commands live in `com.cowlark.fluxengine.cli` and implement the `Command` interface + (`String getHelp()`, `void run(ImmutableList args)`), receiving the tail of the argv + array after + the command name (modelled on `src/fluxengine.cc`'s `command_cb`). +- `Main.main` holds the command/subcommand tables as `ImmutableMap>`: `COMMANDS` (top level), `ANALYSABLES`, `FLUXFILEABLES`, + `TESTABLES`. The tables mirror `src/fluxengine.cc`; unported commands map to + `StubCommand(name, help)`, which prints "not implemented yet". +- Each command carries its own help text, returned by `getHelp()`; `Main.help` prints the + table by instantiating each command and calling `getHelp()`. +- `Main.dispatch(commands, args)` consumes arguments until it reaches a real command, + instantiates it via the supplier (`TestDevicesCommand::new`), and calls `run()` with the + tail. Group commands + (`analyse`, `fluxfile`, `test`) are `CommandGroup(subcommands, help)` instances, which + dispatch again on their sub-table and print extended help if nothing matches. Add new + commands by updating the relevant table. + +## USB + +- `UsbFinder` (`java/com/cowlark/fluxengine/usb/`) is the Java port of + `lib/usb/usbfinder.{cc,h}`. It uses usb4java-javax (javax.usb API). `UsbFinder` is + Dagger-injectable (`@Inject` constructor, instance methods) and `findUsbDevices()` + returns an `ImmutableList`. +- `DeviceType` is an enum carrying its display name as a property (`getDeviceName()`). +- jSerialComm is available for serial-port access (not yet used). + +## Code style + +- Allman brace style (opening brace on its own line), 4-space indent. +- Explicit types, no `var`. +- 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(...))`. +- Tests use JUnit 4 (`@RunWith(JUnit4.class)`, `org.junit.Test`). +- Follow existing patterns in the package you are editing; keep new functionality + localized to the relevant package. + +## Process + +- Verify changes with `bazel build //java/...` and `bazel test //javatests/...` (and + `bazel run` for CLI-visible behaviour) before finishing. +- Do not commit unless asked. diff --git a/BUILD.bazel b/BUILD.bazel new file mode 100644 index 000000000..88aa29d33 --- /dev/null +++ b/BUILD.bazel @@ -0,0 +1,47 @@ +load("//:corpus.bzl", "define_corpus_tests") + +package(default_visibility = ["//visibility:public"]) + +# Root aliases for running/building the application +alias( + name = "fluxengine", + actual = "//java/com/cowlark/fluxengine", +) + +alias( + name = "fluxengine_deb", + actual = "//java/com/cowlark/fluxengine:fluxengine_deb", + tags = ["manual"], +) + +alias( + name = "fluxengine_rpm", + actual = "//java/com/cowlark/fluxengine:fluxengine_rpm", + tags = ["manual"], +) + +alias( + name = "fluxengine_app_image", + actual = "//java/com/cowlark/fluxengine:fluxengine_app_image", + tags = ["manual"], +) + +alias( + name = "fluxengine_msi", + actual = "//java/com/cowlark/fluxengine:fluxengine_msi", + tags = ["manual"], +) + +alias( + name = "fluxengine_dmg", + actual = "//java/com/cowlark/fluxengine:fluxengine_dmg", + tags = ["manual"], +) + +# Encode/decode round-trip tests, ported from the corpus tests in build.py. +CORPUS_TESTS = define_corpus_tests() + +test_suite( + name = "corpus", + tests = CORPUS_TESTS, +) diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 000000000..56e2c8395 --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,35 @@ +bazel_dep(name = "platforms", version = "0.0.11") +bazel_dep(name = "rules_java", version = "9.1.0") +bazel_dep(name = "rules_jvm_external", version = "6.7") +bazel_dep(name = "rules_proto", version = "7.1.0") +bazel_dep(name = "protobuf", version = "33.4", repo_name = "com_google_protobuf") + +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") +maven.install( + artifacts = [ + "org.projectlombok:lombok:1.18.46", + "org.apache.commons:commons-lang3:3.17.0", + "com.fazecast:jSerialComm:2.11.4", + "com.google.guava:guava:33.6.0-jre", + "com.google.truth:truth:1.4.5", + "com.jayway.jsonpath:json-path:3.0.0", + "javax.usb:usb-api:1.0.2", + "junit:junit:4.13.2", + "org.usb4java:usb4java:1.3.0", + "org.usb4java:usb4java-javax:1.3.0", + "com.formdev:flatlaf:3.0", + "io.github.globaltcad:swing-tree:0.24.1", + "io.reactivex.rxjava3:rxjava:3.1.10", + "io.github.globaltcad:sprouts:2.7.0", + "org.mockito:mockito-core:5.23.0", + "net.bytebuddy:byte-buddy:1.17.7", + "net.bytebuddy:byte-buddy-agent:1.17.7", + "org.objenesis:objenesis:3.3", + ], + repositories = [ + "https://repo1.maven.org/maven2", + ], +) +use_repo(maven, "maven") diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock new file mode 100644 index 000000000..6c14c92b8 --- /dev/null +++ b/MODULE.bazel.lock @@ -0,0 +1,471 @@ +{ + "lockFileVersion": 28, + "registryFileHashes": { + "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", + "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", + "https://bcr.bazel.build/modules/abseil-cpp/20211102.0/MODULE.bazel": "70390338f7a5106231d20620712f7cccb659cd0e9d073d1991c038eb9fc57589", + "https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16", + "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", + "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c", + "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", + "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/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101", + "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", + "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", + "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", + "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", + "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", + "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", + "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a", + "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", + "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", + "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", + "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", + "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", + "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080", + "https://bcr.bazel.build/modules/bazel_features/1.42.1/source.json": "fcd4396b2df85f64f2b3bb436ad870793ecf39180f1d796f913cc9276d355309", + "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", + "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", + "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", + "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", + "https://bcr.bazel.build/modules/bazel_skylib/1.2.1/MODULE.bazel": "f35baf9da0efe45fa3da1696ae906eea3d615ad41e2e3def4aeb4e8bc0ef9a7a", + "https://bcr.bazel.build/modules/bazel_skylib/1.3.0/MODULE.bazel": "20228b92868bf5cfc41bda7afc8a8ba2a543201851de39d990ec957b513579c5", + "https://bcr.bazel.build/modules/bazel_skylib/1.4.1/MODULE.bazel": "a0dcb779424be33100dcae821e9e27e4f2901d9dfd5333efe5ac6a8d7ab75e1d", + "https://bcr.bazel.build/modules/bazel_skylib/1.4.2/MODULE.bazel": "3bd40978e7a1fac911d5989e6b09d8f64921865a45822d8b09e815eaa726a651", + "https://bcr.bazel.build/modules/bazel_skylib/1.5.0/MODULE.bazel": "32880f5e2945ce6a03d1fbd588e9198c0a959bb42297b2cfaf1685b7bc32e138", + "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb", + "https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f", + "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd", + "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", + "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", + "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", + "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", + "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108", + "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46", + "https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713", + "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", + "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", + "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", + "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", + "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee", + "https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37", + "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", + "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", + "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", + "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc", + "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", + "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96", + "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", + "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", + "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", + "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", + "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", + "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", + "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", + "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79", + "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680", + "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4", + "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa", + "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", + "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e", + "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68", + "https://bcr.bazel.build/modules/rules_apple/4.1.0/source.json": "8ee81e1708756f81b343a5eb2b2f0b953f1d25c4ab3d4a68dc02754872e80715", + "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", + "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", + "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", + "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", + "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", + "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", + "https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c", + "https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f", + "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", + "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", + "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", + "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.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_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", + "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", + "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", + "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", + "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", + "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", + "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", + "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", + "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", + "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", + "https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d", + "https://bcr.bazel.build/modules/rules_java/9.1.0/source.json": "da589573c1dee2c9ac4a568b301269a2e8191110ff0345c1a959fa7ea6c4dfd6", + "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", + "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", + "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", + "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5", + "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", + "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", + "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", + "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb", + "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc", + "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff", + "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a", + "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", + "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", + "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", + "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", + "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", + "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", + "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", + "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", + "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", + "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937", + "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", + "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13", + "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6", + "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32", + "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", + "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", + "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", + "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", + "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", + "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", + "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400", + "https://bcr.bazel.build/modules/rules_swift/3.1.2/source.json": "e85761f3098a6faf40b8187695e3de6d97944e98abd0d8ce579cb2daf6319a66", + "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", + "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", + "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", + "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", + "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b", + "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", + "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", + "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", + "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", + "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198" + }, + "selectedYankedVersions": {}, + "moduleExtensions": { + "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { + "general": { + "bzlTransitiveDigest": "NRXra7941UfmNUyIxnLt82V5hULluVGL2nBsijTl4j4=", + "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=", + "recordedInputs": [ + "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools", + "FILE:@@pybind11_bazel+//MODULE.bazel e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34" + ], + "generatedRepoSpecs": { + "pybind11": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel", + "strip_prefix": "pybind11-2.12.0", + "urls": [ + "https://github.com/pybind/pybind11/archive/v2.12.0.zip" + ] + } + } + } + } + }, + "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { + "general": { + "bzlTransitiveDigest": "+Kp6j204mBZ3mxlIDDR0gBoP45BZ4jYRhRAcB8sU0qc=", + "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", + "recordedInputs": [ + "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" + ], + "generatedRepoSpecs": { + "com_github_jetbrains_kotlin_git": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", + "attributes": { + "urls": [ + "https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip" + ], + "sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88" + } + }, + "com_github_jetbrains_kotlin": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", + "attributes": { + "git_repository_name": "com_github_jetbrains_kotlin_git", + "compiler_version": "1.9.23" + } + }, + "com_github_google_ksp": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", + "attributes": { + "urls": [ + "https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip" + ], + "sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d", + "strip_version": "1.9.23-1.0.20" + } + }, + "com_github_pinterest_ktlint": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985", + "urls": [ + "https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint" + ], + "executable": true + } + }, + "rules_android": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806", + "strip_prefix": "rules_android-0.1.1", + "urls": [ + "https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip" + ] + } + } + } + } + }, + "@@rules_python+//python/extensions:config.bzl%config": { + "general": { + "bzlTransitiveDigest": "dzD8Q2YmrP3fz8saWLHPmlwPLO91ImtTmP/c9JKTStM=", + "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,pypi__build rules_python++config+pypi__build", + "REPO_MAPPING:rules_python+,pypi__click rules_python++config+pypi__click", + "REPO_MAPPING:rules_python+,pypi__colorama rules_python++config+pypi__colorama", + "REPO_MAPPING:rules_python+,pypi__importlib_metadata rules_python++config+pypi__importlib_metadata", + "REPO_MAPPING:rules_python+,pypi__installer rules_python++config+pypi__installer", + "REPO_MAPPING:rules_python+,pypi__more_itertools rules_python++config+pypi__more_itertools", + "REPO_MAPPING:rules_python+,pypi__packaging rules_python++config+pypi__packaging", + "REPO_MAPPING:rules_python+,pypi__pep517 rules_python++config+pypi__pep517", + "REPO_MAPPING:rules_python+,pypi__pip rules_python++config+pypi__pip", + "REPO_MAPPING:rules_python+,pypi__pip_tools rules_python++config+pypi__pip_tools", + "REPO_MAPPING:rules_python+,pypi__pyproject_hooks rules_python++config+pypi__pyproject_hooks", + "REPO_MAPPING:rules_python+,pypi__setuptools rules_python++config+pypi__setuptools", + "REPO_MAPPING:rules_python+,pypi__tomli rules_python++config+pypi__tomli", + "REPO_MAPPING:rules_python+,pypi__wheel rules_python++config+pypi__wheel", + "REPO_MAPPING:rules_python+,pypi__zipp rules_python++config+pypi__zipp" + ], + "generatedRepoSpecs": { + "rules_python_internal": { + "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo", + "attributes": { + "transition_setting_generators": {}, + "transition_settings": [] + } + }, + "pypi__build": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/e2/03/f3c8ba0a6b6e30d7d18c40faab90807c9bb5e9a1e3b2fe2008af624a9c97/build-1.2.1-py3-none-any.whl", + "sha256": "75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__click": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", + "sha256": "ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__colorama": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", + "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__importlib_metadata": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl", + "sha256": "30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__installer": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl", + "sha256": "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__more_itertools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl", + "sha256": "686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__packaging": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", + "sha256": "2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pep517": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/25/6e/ca4a5434eb0e502210f591b97537d322546e4833dcb4d470a48c375c5540/pep517-0.13.1-py3-none-any.whl", + "sha256": "31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pip": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl", + "sha256": "ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pip_tools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl", + "sha256": "4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pyproject_hooks": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/ae/f3/431b9d5fe7d14af7a32340792ef43b8a714e7726f1d7b69cc4e8e7a3f1d7/pyproject_hooks-1.1.0-py3-none-any.whl", + "sha256": "7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__setuptools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", + "sha256": "c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__tomli": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", + "sha256": "939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__wheel": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl", + "sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__zipp": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/da/55/a03fd7240714916507e1fcf7ae355bd9d9ed2e6db492595f1a67f61681be/zipp-3.18.2-py3-none-any.whl", + "sha256": "dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + } + } + } + }, + "@@rules_python+//python/uv:uv.bzl%uv": { + "general": { + "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", + "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=", + "recordedInputs": [ + "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", + "REPO_MAPPING:rules_python+,platforms platforms" + ], + "generatedRepoSpecs": { + "uv": { + "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo", + "attributes": { + "toolchain_type": "'@@rules_python+//python/uv:uv_toolchain_type'", + "toolchain_names": [ + "none" + ], + "toolchain_implementations": { + "none": "'@@rules_python+//python:none'" + }, + "toolchain_compatible_with": { + "none": [ + "@platforms//:incompatible" + ] + }, + "toolchain_target_settings": {} + } + } + } + } + } + }, + "facts": {}, + "factsVersions": {} +} diff --git a/Makefile b/Makefile index 831495929..1a8c6ed2d 100644 --- a/Makefile +++ b/Makefile @@ -1,132 +1,8 @@ -ifeq ($(BUILDTYPE),) - # On MSYS2 uname -s produces something like: MINGW64_NT-10.0-19045 - # Strip the suffix off - OS := $(patsubst MINGW%,MINGW,$(shell uname -s)) - buildtype_Darwin = osx - buildtype_Haiku = haiku - buildtype_MINGW = windows - BUILDTYPE := $(buildtype_$(OS)) - ifeq ($(BUILDTYPE),) - BUILDTYPE := unix - endif -endif -export BUILDTYPE +.PHONY: all corpus +all: + bazel test //javatests/... + bazel build //:fluxengine -OPTFLAGS = -g -O3 +corpus: + bazel test //:corpus -ifeq ($(BUILDTYPE),windows) - MINGW = x86_64-w64-mingw32- - CC = $(MINGW)gcc - CXX = $(MINGW)g++ - CFLAGS += \ - $(OPTFLAGS) \ - -ffunction-sections \ - -fdata-sections \ - -Wno-attributes \ - -Wa,-mbig-obj \ - -static - CXXFLAGS += \ - $(OPTFLAGS) \ - -std=c++23 \ - -Wno-deprecated-enum-float-conversion \ - -Wno-deprecated-enum-enum-conversion \ - -Wno-attributes \ - -Wa,-mbig-obj \ - -static - LDFLAGS += -Wl,--gc-sections -static - AR = $(MINGW)gcc-ar - PKG_CONFIG = $(MINGW)pkg-config --static - WINDRES = $(MINGW)windres - WX_CONFIG = /usr/i686-w64-mingw32/sys-root/mingw/bin/wx-config-3.0 --static=yes - NINJA = /bin/ninja - PROTOC = /mingw64/bin/protoc - PROTOC_SEPARATOR = ; - EXT = .exe - - AB_SANDBOX = no -else - CFLAGS += \ - $(OPTFLAGS) \ - -I/opt/homebrew/include -I/usr/local/include \ - -Wno-unknown-warning-option - CXXFLAGS += \ - $(OPTFLAGS) \ - -std=c++23 \ - -I/opt/homebrew/include -I/usr/local/include \ - -Wformat \ - -Wformat-security \ - -Wno-deprecated-enum-float-conversion \ - -Wno-deprecated-enum-enum-conversion - LDFLAGS += - AR = ar - PKG_CONFIG = pkg-config - ifeq ($(BUILDTYPE),osx) - CXXFLAGS += -fexperimental-library - else - LDFLAGS += -pthread - endif -endif - -HOSTCC = gcc -HOSTCXX = g++ -std=c++20 -HOSTCFLAGS += -g -O3 -HOSTLDFLAGS = - -REALOBJ = .obj -OBJ = $(REALOBJ)/$(BUILDTYPE) -DESTDIR ?= -PREFIX ?= /usr/local -BINDIR ?= $(PREFIX)/bin - -# Special Windows settings. - -#ifeq ($(OS), Windows_NT) -# EXT ?= .exe -# MINGWBIN = /mingw32/bin -# CCPREFIX = $(MINGWBIN)/ -# PKG_CONFIG = $(MINGWBIN)/pkg-config -# WX_CONFIG = /usr/bin/sh $(MINGWBIN)/wx-config --static=yes -# PROTOC = $(MINGWBIN)/protoc -# WINDRES = windres -# LDFLAGS += \ -# -static -# CXXFLAGS += \ -# -fext-numeric-literals \ -# -Wno-deprecated-enum-float-conversion \ -# -Wno-deprecated-enum-enum-conversion -# -# # Required to get the gcc run - time libraries on the path. -# export PATH := $(PATH):$(MINGWBIN) -#endif - -# Special OSX settings. - -ifeq ($(shell uname),Darwin) - LDFLAGS += \ - -framework IOKit \ - -framework AppKit \ - -framework UniformTypeIdentifiers \ - -framework UserNotifications -endif - -.PHONY: all -all: +all README.md - -.PHONY: binaries tests -binaries: all -tests: all - -README.md: $(OBJ)/scripts/+mkdocindex/mkdocindex$(EXT) - @echo $(PROGRESSINFO)MKDOC $@ - @csplit -s -f$(OBJ)/README. README.md '//' '%%' - @(cat $(OBJ)/README.00 && $< && cat $(OBJ)/README.01) > README.md - -.PHONY: tests - -clean:: - $(hide) rm -rf $(REALOBJ) - -include build/ab.mk - -docker-%: tests/docker/Dockerfile.% - docker build --progress=plain -t $* -f $< . diff --git a/arch/aeslanier/aeslanier.proto b/arch/aeslanier/aeslanier.proto deleted file mode 100644 index fe2df6898..000000000 --- a/arch/aeslanier/aeslanier.proto +++ /dev/null @@ -1,4 +0,0 @@ -syntax = "proto2"; - -message AesLanierDecoderProto {} - diff --git a/arch/agat/agat.proto b/arch/agat/agat.proto deleted file mode 100644 index 58377b12b..000000000 --- a/arch/agat/agat.proto +++ /dev/null @@ -1,19 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message AgatDecoderProto {} - -message AgatEncoderProto { - optional double target_clock_period_us = 1 - [default=2.00, (help)="Data clock period of target format."]; - optional double target_rotational_period_ms = 2 - [default=200.0, (help)="Rotational period of target format."]; - optional int32 post_index_gap_bytes = 3 - [default=40, (help)="Post-index gap before first sector header."]; - optional int32 pre_sector_gap_bytes = 4 - [default=11, (help)="Gap before each sector header."]; - optional int32 pre_data_gap_bytes = 5 - [default=2, (help)="Gap before each sector data record."]; -} - diff --git a/arch/amiga/amiga.proto b/arch/amiga/amiga.proto deleted file mode 100644 index ee3474dc8..000000000 --- a/arch/amiga/amiga.proto +++ /dev/null @@ -1,13 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message AmigaDecoderProto {} - -message AmigaEncoderProto { - optional double clock_rate_us = 1 - [default=2.00, (help)="Encoded data clock rate."]; - optional double post_index_gap_ms = 2 - [default=0.5, (help)="Post-index gap before first sector header."]; -} - diff --git a/arch/apple2/apple2.proto b/arch/apple2/apple2.proto deleted file mode 100644 index 5a18f8373..000000000 --- a/arch/apple2/apple2.proto +++ /dev/null @@ -1,22 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message Apple2DecoderProto { - optional uint32 side_one_track_offset = 1 - [ default = 0, (help) = "offset to apply to track numbers on side 1" ]; -} - -message Apple2EncoderProto -{ - /* 245kHz. */ - optional double clock_period_us = 1 - [ default = 4, (help) = "clock rate on the real device" ]; - - /* Apple II disk drives spin at 300rpm. */ - optional double rotational_period_ms = 2 - [ default = 200.0, (help) = "rotational period on the real device" ]; - - optional uint32 side_one_track_offset = 3 - [ default = 0, (help) = "offset to apply to track numbers on side 1" ]; -} diff --git a/arch/brother/brother.proto b/arch/brother/brother.proto deleted file mode 100644 index 7171e85b2..000000000 --- a/arch/brother/brother.proto +++ /dev/null @@ -1,18 +0,0 @@ -syntax = "proto2"; - -message BrotherDecoderProto {} - -enum BrotherFormat { - BROTHER240 = 0; - BROTHER120 = 1; -}; - -message BrotherEncoderProto { - optional double clock_rate_us = 1 [default = 3.83]; - optional double post_index_gap_ms = 2 [default = 1.0]; - optional double sector_spacing_ms = 3 [default = 16.2]; - optional double post_header_spacing_ms = 4 [default = 0.69]; - - optional BrotherFormat format = 6 [default = BROTHER240]; -} - diff --git a/arch/c64/c64.proto b/arch/c64/c64.proto deleted file mode 100644 index 641bc1826..000000000 --- a/arch/c64/c64.proto +++ /dev/null @@ -1,11 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message Commodore64DecoderProto {} - -message Commodore64EncoderProto { - optional double post_index_gap_us = 1 [default=0.0, - (help) = "post-index gap before first sector header."]; -} - diff --git a/arch/f85/f85.proto b/arch/f85/f85.proto deleted file mode 100644 index 5fac2a91e..000000000 --- a/arch/f85/f85.proto +++ /dev/null @@ -1,4 +0,0 @@ -syntax = "proto2"; - -message F85DecoderProto {} - diff --git a/arch/fb100/fb100.proto b/arch/fb100/fb100.proto deleted file mode 100644 index fb60a49ea..000000000 --- a/arch/fb100/fb100.proto +++ /dev/null @@ -1,4 +0,0 @@ -syntax = "proto2"; - -message Fb100DecoderProto {} - diff --git a/arch/ibm/ibm.proto b/arch/ibm/ibm.proto deleted file mode 100644 index ee289b343..000000000 --- a/arch/ibm/ibm.proto +++ /dev/null @@ -1,43 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message IbmDecoderProto { - // Next: 11 - message TrackdataProto { - optional int32 track = 7 [(help) = "if set, the format applies only to this track"]; - optional int32 head = 8 [(help) = "if set, the format applies only to this head"]; - - optional bool ignore_side_byte = 2 [default = false, (help) = "ignore side byte in sector header"]; - optional bool ignore_track_byte = 6 [default = false, (help) = "ignore track byte in sector header"]; - optional bool invert_side_byte = 4 [default = false, (help) = "invert the side byte in the sector header"]; - - repeated int32 ignore_sector = 10 [(help) = "sectors with these IDs will not be read"]; - } - - repeated TrackdataProto trackdata = 1; -} - -message IbmEncoderProto { - // Next: 20 - message TrackdataProto { - optional int32 track = 15 [(help) = "if set, the format applies only to this track"]; - optional int32 head = 16 [(help) = "if set, the format applies only to this head"]; - - optional bool emit_iam = 3 [default=true, (help) = "whether to emit an IAM record"]; - optional double target_clock_period_us = 5 [default=4, (help) = "data clock rate on target disk"]; - optional bool use_fm = 6 [default=false, (help) = "whether to use FM encoding rather than MFM"]; - optional int32 idam_byte = 7 [default=0x5554, (help) = "16-bit raw bit pattern of IDAM byte"]; - optional int32 dam_byte = 8 [default=0x5545, (help) = "16-bit raw bit pattern of DAM byte"]; - optional int32 gap0 = 9 [default=80, (help) = "size of gap 1 (the post-index gap)"]; - optional int32 gap1 = 10 [default=50, (help) = "size of gap 2 (the post-ID gap)"]; - optional int32 gap2 = 11 [default=22, (help) = "size of gap 3 (the pre-data gap)"]; - optional int32 gap3 = 12 [default=80, (help) = "size of gap 4 (the post-data or format gap)"]; - optional bool invert_side_byte = 19 [default=false, (help) = "invert the side byte before writing"]; - optional int32 gap_fill_byte = 18 [default=0x9254, (help) = "16-bit raw bit pattern of gap fill byte"]; - optional double target_rotational_period_ms = 1 [default=200, (help) = "rotational period of target disk"]; - } - - repeated TrackdataProto trackdata = 1; -} - diff --git a/arch/macintosh/macintosh.proto b/arch/macintosh/macintosh.proto deleted file mode 100644 index 5ff666a37..000000000 --- a/arch/macintosh/macintosh.proto +++ /dev/null @@ -1,11 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message MacintoshDecoderProto {} - -message MacintoshEncoderProto { - optional double post_index_gap_us = 1 [default = 0.0, - (help) = "post-index gap before first sector header (microseconds)."]; -} - diff --git a/arch/micropolis/micropolis.proto b/arch/micropolis/micropolis.proto deleted file mode 100644 index 4c4f34389..000000000 --- a/arch/micropolis/micropolis.proto +++ /dev/null @@ -1,37 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message MicropolisDecoderProto { - enum ChecksumType { - AUTO = 0; - MICROPOLIS = 1; - MZOS = 2; - } - enum EccType { - NONE = 0; - VECTOR = 1; - } - - optional int32 sector_output_size = 1 [default = 256, - (help) = "How much of the raw sector should be saved. Must be 256 or 275"]; - optional ChecksumType checksum_type = 2 [default = AUTO, - (help) = "Checksum type to use: AUTO, MICROPOLIS, MZOS"]; - optional EccType ecc_type = 3 [default = NONE, - (help) = "ECC type to use: NONE, VECTOR"]; -} - -message MicropolisEncoderProto { - enum EccType { - NONE = 0; - VECTOR = 1; - } - - optional double clock_period_us = 1 - [ default = 2.0, (help) = "clock rate on the real device" ]; - optional double rotational_period_ms = 2 - [ default = 200.0, (help) = "rotational period on the real device" ]; - optional EccType ecc_type = 3 [default = NONE, - (help) = "ECC type to use for IMG data: NONE, VECTOR"]; -} - diff --git a/arch/mx/mx.proto b/arch/mx/mx.proto deleted file mode 100644 index 72c86c240..000000000 --- a/arch/mx/mx.proto +++ /dev/null @@ -1,4 +0,0 @@ -syntax = "proto2"; - -message MxDecoderProto {} - diff --git a/arch/northstar/northstar.proto b/arch/northstar/northstar.proto deleted file mode 100644 index 0693e77df..000000000 --- a/arch/northstar/northstar.proto +++ /dev/null @@ -1,13 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message NorthstarDecoderProto {} - -message NorthstarEncoderProto { - optional double clock_period_us = 1 - [ default = 4.0, (help) = "clock rate on the real device (for FM)" ]; - optional double rotational_period_ms = 2 - [ default = 166.0, (help) = "rotational period on the real device" ]; -} - diff --git a/arch/rolandd20/rolandd20.proto b/arch/rolandd20/rolandd20.proto deleted file mode 100644 index 6ff0ef831..000000000 --- a/arch/rolandd20/rolandd20.proto +++ /dev/null @@ -1,5 +0,0 @@ -syntax = "proto2"; - -message RolandD20DecoderProto {} - - diff --git a/arch/smaky6/smaky6.proto b/arch/smaky6/smaky6.proto deleted file mode 100644 index 6a0bfed13..000000000 --- a/arch/smaky6/smaky6.proto +++ /dev/null @@ -1,4 +0,0 @@ -syntax = "proto2"; - -message Smaky6DecoderProto {} - diff --git a/arch/tartu/tartu.proto b/arch/tartu/tartu.proto deleted file mode 100644 index f66b2f27c..000000000 --- a/arch/tartu/tartu.proto +++ /dev/null @@ -1,27 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message TartuDecoderProto {} - -message TartuEncoderProto { - optional double clock_period_us = 1 - [ default = 2.0, (help) = "clock rate on the real device (for MFM)" ]; - optional double target_rotational_period_ms = 2 - [ default=200, (help) = "rotational period of target disk" ]; - optional double gap1_us = 3 - [ default = 1200, - (help) = "size of gap 1 (the post-index gap)" ]; - optional double gap3_us = 4 - [ default = 150, - (help) = "size of gap 3 (the pre-data gap)" ]; - optional double gap4_us = 5 - [ default = 180, - (help) = "size of gap 4 (the post-data or format gap)" ]; - optional uint64 header_marker = 6 - [ default = 0xaaaaaaaa44895554, - (help) = "64-bit raw bit pattern of header record marker" ]; - optional uint64 data_marker = 7 - [ default = 0xaaaaaaaa44895545, - (help) = "64-bit raw bit pattern of data record marker" ]; -} diff --git a/arch/tids990/tids990.proto b/arch/tids990/tids990.proto deleted file mode 100644 index 8091e5d73..000000000 --- a/arch/tids990/tids990.proto +++ /dev/null @@ -1,25 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message Tids990DecoderProto {} - -message Tids990EncoderProto { - optional double rotational_period_ms = 1 [ default = 166, - (help) = "length of a track" ]; - optional int32 sector_count = 2 [ default = 26, - (help) = "number of sectors per track" ]; - optional double clock_period_us = 3 [ default = 2, - (help) = "clock rate of data to write" ]; - optional int32 am1_byte = 4 [ default = 0x2244, - (help) = "16-bit RAW bit pattern to use for the AM1 ID byte" ]; - optional int32 am2_byte = 5 [ default = 0x2245, - (help) = "16-bit RAW bit pattern to use for the AM2 ID byte" ]; - optional int32 gap1_bytes = 6 [ default = 80, - (help) = "size of gap 1 (the post-index gap)" ]; - optional int32 gap2_bytes = 7 [ default = 21, - (help) = "size of gap 2 (the post-ID gap)" ]; - optional int32 gap3_bytes = 8 [ default = 51, - (help) = "size of gap 3 (the post-data or format gap)" ]; -} - diff --git a/arch/victor9k/victor9k.proto b/arch/victor9k/victor9k.proto deleted file mode 100644 index 8d0ea666a..000000000 --- a/arch/victor9k/victor9k.proto +++ /dev/null @@ -1,36 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message Victor9kDecoderProto {} - -// NEXT: 12 -message Victor9kEncoderProto -{ - message TrackdataProto - { - optional int32 min_track = 1 - [ (help) = "minimum track this format applies to" ]; - optional int32 max_track = 2 - [ (help) = "maximum track this format applies to" ]; - optional int32 head = 3 - [ (help) = "which head this format applies to" ]; - - optional double rotational_period_ms = 4 - [ (help) = "original rotational period of this track" ]; - optional double clock_period_us = 5 - [ (help) = "original data rate of this track" ]; - optional double post_index_gap_us = 6 - [ (help) = "size of post-index gap" ]; - optional int32 pre_header_sync_bits = 10 - [ (help) = "number of sync bits before the sector header" ]; - optional int32 pre_data_sync_bits = 8 - [ (help) = "number of sync bits before the sector data" ]; - optional int32 post_data_gap_bits = 9 - [ (help) = "size of gap between data and the next header" ]; - optional int32 post_header_gap_bits = 11 - [ (help) = "size of gap between header and the data" ]; - } - - repeated TrackdataProto trackdata = 1; -} diff --git a/arch/zilogmcz/zilogmcz.proto b/arch/zilogmcz/zilogmcz.proto deleted file mode 100644 index 0458a792a..000000000 --- a/arch/zilogmcz/zilogmcz.proto +++ /dev/null @@ -1,4 +0,0 @@ -syntax = "proto2"; - -message ZilogMczDecoderProto {} - diff --git a/build/_objectify.py b/build/_objectify.py deleted file mode 100644 index 171489541..000000000 --- a/build/_objectify.py +++ /dev/null @@ -1,19 +0,0 @@ -import sys -from functools import partial - -if len(sys.argv) != 3: - sys.exit("Usage: %s " % sys.argv[0]) -filename = sys.argv[1] -symbol = sys.argv[2] - -print("const uint8_t " + symbol + "[] = {") -n = 0 -with open(filename, "rb") as in_file: - for c in iter(partial(in_file.read, 1), b""): - print("0x%02X," % ord(c), end="") - n += 1 - if n % 16 == 0: - print() -print("};") - -print("const size_t " + symbol + "_len = sizeof(" + symbol + ");") diff --git a/build/_sandbox.py b/build/_sandbox.py deleted file mode 100644 index f7667a687..000000000 --- a/build/_sandbox.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/python3 - -from os.path import * -import argparse -import os -import shutil - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("-s", "--sandbox") - parser.add_argument("-v", "--verbose", action="store_true") - parser.add_argument("-l", "--link", action="store_true") - parser.add_argument("-e", "--export", action="store_true") - parser.add_argument("files", nargs="*") - args = parser.parse_args() - - assert args.sandbox, "You must specify a sandbox directory" - assert args.link ^ args.export, "You can't link and export at the same time" - - if args.link: - os.makedirs(args.sandbox, exist_ok=True) - for f in args.files: - sf = join(args.sandbox, f) - if args.verbose: - print("link", sf) - os.makedirs(dirname(sf), exist_ok=True) - try: - os.symlink(abspath(f), sf) - except PermissionError: - shutil.copy(f, sf) - - if args.export: - for f in args.files: - sf = join(args.sandbox, f) - if args.verbose: - print("export", sf) - df = dirname(f) - if df: - os.makedirs(df, exist_ok=True) - - try: - os.remove(f) - except FileNotFoundError: - pass - os.rename(sf, f) - - -main() diff --git a/build/_zip.py b/build/_zip.py deleted file mode 100755 index f5a49d091..000000000 --- a/build/_zip.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/python3 - -from os.path import * -import argparse -import os -from zipfile import ZipFile - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("-z", "--zipfile") - parser.add_argument("-v", "--verbose", action="store_true") - parser.add_argument("-f", "--file", nargs=2, action="append") - args = parser.parse_args() - - assert args.zipfile, "You must specify a zipfile to create" - - with ZipFile(args.zipfile, mode="w") as zf: - for zipname, filename in args.file: - if args.verbose: - print(filename, "->", zipname) - zf.write(filename, arcname=zipname) - - -main() diff --git a/build/ab.mk b/build/ab.mk deleted file mode 100644 index e8a9aa501..000000000 --- a/build/ab.mk +++ /dev/null @@ -1,126 +0,0 @@ -MAKENOT4 := $(if $(findstring 3.9999, $(lastword $(sort 3.9999 $(MAKE_VERSION)))),yes,no) - -ifeq ($(MAKENOT4),yes) -$(error You need GNU Make 4.x for this (if you're on OSX, use gmake).) -endif - -OBJ ?= .obj -PYTHON ?= python3 -PKG_CONFIG ?= pkg-config -HOST_PKG_CONFIG ?= $(PKG_CONFIG) -ECHO ?= echo -CP ?= cp - -HOSTCC ?= gcc -HOSTCXX ?= g++ -HOSTAR ?= ar -HOSTCFLAGS ?= -g -Og -HOSTCXXFLAGS ?= $(HOSTCFLAGS) -HOSTLDFLAGS ?= -g - -CC ?= $(HOSTCC) -CXX ?= $(HOSTCXX) -AR ?= $(HOSTAR) -CFLAGS ?= $(HOSTCFLAGS) -CXXFLAGS ?= $(CFLAGS) -LDFLAGS ?= $(HOSTLDFLAGS) - -NINJA ?= ninja - -ifdef VERBOSE - hide = -else - ifdef V - hide = - else - hide = @ - endif -endif - -# If enabled, shows a nice display of how far through the build you are. This -# doubles Make startup time. Also, on Make 4.3 and above, rebuilds don't show -# correct progress information. -AB_ENABLE_PROGRESS_INFO ?= true - -WINDOWS := no -OSX := no -LINUX := no -ifeq ($(OS),Windows_NT) - WINDOWS := yes -else - UNAME_S := $(shell uname -s) - ifeq ($(UNAME_S),Linux) - LINUX := yes - endif - ifeq ($(UNAME_S),Darwin) - OSX := yes - endif -endif - -ifeq ($(OS), Windows_NT) - EXT ?= .exe -endif -EXT ?= - -CWD=$(shell pwd) - -define newline - - -endef - -define check_for_command - $(shell command -v $1 >/dev/null || (echo "Required command '$1' missing" >&2 && kill $$PPID)) -endef - -$(call check_for_command,ninja) -$(call check_for_command,cmp) -$(call check_for_command,$(PYTHON)) - -pkg-config-hash = $(shell ($(PKG_CONFIG) --list-all && $(HOST_PKG_CONFIG) --list-all) | md5sum) -build-files = $(shell find . -name .obj -prune -o \( -name 'build.py' -a -type f \) -print) $(wildcard build/*.py) $(wildcard config.py) -build-file-timestamps = $(shell ls -l $(build-files) | md5sum) - -# Wipe the build file (forcing a regeneration) if the make environment is different. -# (Conveniently, this includes the pkg-config hash calculated above.) - -ignored-variables = MAKE_RESTARTS .VARIABLES MAKECMDGOALS MAKEFLAGS MFLAGS PAGER _ \ - DESKTOP_STARTUP_ID XAUTHORITY ICEAUTHORITY SSH_AUTH_SOCK SESSION_MANAGER \ - INVOCATION_ID SYSTEMD_EXEC_PID MANAGER_PID SSH_AGENT_PID JOURNAL_STREAM \ - GPG_TTY WINDOWID MANAGERPID MAKE_TERMOUT MAKE_TERMERR OLDPWD -$(shell mkdir -p $(OBJ)) -$(file >$(OBJ)/newvars.txt,$(foreach v,$(filter-out $(ignored-variables),$(.VARIABLES)),$(v)=$($(v))$(newline))) -$(shell touch $(OBJ)/vars.txt) -#$(shell diff -u $(OBJ)/vars.txt $(OBJ)/newvars.txt >&2) -$(shell cmp -s $(OBJ)/newvars.txt $(OBJ)/vars.txt || (rm -f $(OBJ)/build.ninja && echo "Environment changed --- regenerating" >&2)) -$(shell mv $(OBJ)/newvars.txt $(OBJ)/vars.txt) - -.PHONY: update-ab -update-ab: - @echo "Press RETURN to update ab from the repository, or CTRL+C to cancel." \ - && read a \ - && (curl -L https://github.com/davidgiven/ab/releases/download/dev/distribution.tar.xz | tar xvJf -) \ - && echo "Done." - -.PHONY: clean -clean:: - @echo CLEAN - $(hide) rm -rf $(OBJ) - -compile_commands.json: $(OBJ)/build.ninja - +$(hide) $(NINJA) -f $(OBJ)/build.ninja -t compdb > $@ - -export PYTHONHASHSEED = 1 -$(OBJ)/build.ninja $(OBJ)/build.targets &: - @echo "AB" - $(hide) $(PYTHON) -X pycache_prefix=$(OBJ)/__pycache__ build/ab.py \ - -o $(OBJ) build.py \ - -v $(OBJ)/vars.txt \ - || (rm -f $@ && false) - $(hide) cp $(OBJ)/compile_commands.json compile_commands.json - -include $(OBJ)/build.targets -.PHONY: $(ninja-targets) -.NOTPARALLEL: -$(ninja-targets): $(OBJ)/build.ninja - +$(hide) $(NINJA) -f $(OBJ)/build.ninja $@ diff --git a/build/ab.ninja b/build/ab.ninja deleted file mode 100644 index 98599f85f..000000000 --- a/build/ab.ninja +++ /dev/null @@ -1,2 +0,0 @@ -rule rule - command = $command diff --git a/build/ab.py b/build/ab.py deleted file mode 100644 index 655f9e2d1..000000000 --- a/build/ab.py +++ /dev/null @@ -1,798 +0,0 @@ -from collections import namedtuple -from copy import copy -from importlib.machinery import SourceFileLoader, PathFinder, ModuleSpec -from os.path import * -from pathlib import Path -from typing import Iterable -import argparse -import ast -import builtins -import functools -import hashlib -import importlib -import importlib.util -import inspect -import json -import os -import re -import string -import sys -import types - -VERBOSE_NINJA_FILE = False - -quiet = False -cwdStack = [""] -targets = {} -unmaterialisedTargets = {} # dict, not set, to get consistent ordering -materialisingStack = [] -defaultGlobals = {} -outputTargets = set() -commandsDb = [] -belatedErrors = [] -atexits = [] - -RE_FORMAT_SPEC = re.compile( - r"(?:(?P[\s\S])?(?P[<>=^]))?" - r"(?P[- +])?" - r"(?Pz)?" - r"(?P#)?" - r"(?P0)?" - r"(?P\d+)?" - r"(?P[_,])?" - r"(?:(?P\.)(?P\d+))?" - r"(?P[bcdeEfFgGnosxX%])?" -) - -CommandFormatSpec = namedtuple( - "CommandFormatSpec", RE_FORMAT_SPEC.groupindex.keys() -) - -sys.path += ["."] -old_import = builtins.__import__ - - -class Environment(types.SimpleNamespace): - def setdefault(self, name, value): - if not hasattr(self, name): - setattr(self, name, value) - - -G = Environment() - - -class PathFinderImpl(PathFinder): - def find_spec(self, fullname, path, target=None): - # The second test here is needed for Python 3.9. - if not path or not path[0]: - path = ["."] - if len(path) != 1: - return None - - try: - path = relpath(path[0]) - except ValueError: - return None - - realpath = fullname.replace(".", "/") - buildpath = realpath + ".py" - if isfile(buildpath): - spec = importlib.util.spec_from_file_location( - name=fullname, - location=buildpath, - loader=BuildFileLoaderImpl(fullname=fullname, path=buildpath), - submodule_search_locations=[], - ) - return spec - if isdir(realpath): - return ModuleSpec(fullname, None, origin=realpath, is_package=True) - return None - - -class BuildFileLoaderImpl(SourceFileLoader): - def exec_module(self, module): - sourcepath = relpath(module.__file__) - - if not quiet: - print("loading", sourcepath) - cwdStack.append(dirname(sourcepath)) - super(SourceFileLoader, self).exec_module(module) - cwdStack.pop() - - -sys.meta_path.insert(0, PathFinderImpl()) - - -class ABException(BaseException): - pass - - -def error(message): - raise ABException(message) - - -def _undo_escaped_dollar(s, op): - return s.replace(f"$${op}", f"${op}") - - -class BracketedFormatter(string.Formatter): - def parse(self, format_string): - while format_string: - m = re.search(f"(?:[^$]|^)()\\$\\[()", format_string) - if not m: - yield ( - _undo_escaped_dollar(format_string, "["), - None, - None, - None, - ) - break - left = format_string[: m.start(1)] - right = format_string[m.end(2) :] - - offset = len(right) + 1 - try: - ast.parse(right) - except SyntaxError as e: - if not str(e).startswith(f"unmatched ']'"): - raise e - offset = e.offset - - expr = right[0 : offset - 1] - format_string = right[offset:] - - yield ( - _undo_escaped_dollar(left, "[") if left else None, - expr, - None, - None, - ) - - -class GlobalFormatter(string.Formatter): - def parse(self, format_string): - while format_string: - m = re.search(f"(?:[^$]|^)()\\$\\(([^)]*)\\)()", format_string) - if not m: - yield ( - format_string, - None, - None, - None, - ) - break - left = format_string[: m.start(1)] - var = m[2] - format_string = format_string[m.end(3) :] - - yield ( - left if left else None, - var, - None, - None, - ) - - def get_field(self, name, a1, a2): - return ( - getattr(G, name), - False, - ) - - def format_field(self, value, format_spec): - if not value: - return "" - return str(value) - - -globalFormatter = GlobalFormatter() - - -def substituteGlobalVariables(value): - while True: - oldValue = value - value = globalFormatter.format(value) - if value == oldValue: - return _undo_escaped_dollar(value, "(") - - -def Rule(func): - sig = inspect.signature(func) - - @functools.wraps(func) - def wrapper(*, name=None, replaces=None, **kwargs): - cwd = None - if "cwd" in kwargs: - cwd = kwargs["cwd"] - del kwargs["cwd"] - - if not cwd: - if replaces: - cwd = replaces.cwd - else: - cwd = cwdStack[-1] - - if name: - if name[0] != "+": - name = "+" + name - t = Target(cwd, join(cwd, name)) - - assert ( - t.name not in targets - ), f"target {t.name} has already been defined" - targets[t.name] = t - elif replaces: - t = replaces - else: - raise ABException("you must supply either 'name' or 'replaces'") - - t.cwd = cwd - t.types = func.__annotations__ - t.callback = func - t.traits.add(func.__name__) - if "args" in kwargs: - t.explicit_args = kwargs["args"] - t.args.update(t.explicit_args) - del kwargs["args"] - if "traits" in kwargs: - t.traits |= kwargs["traits"] - del kwargs["traits"] - - t.binding = sig.bind(name=name, self=t, **kwargs) - t.binding.apply_defaults() - - unmaterialisedTargets[t] = None - if replaces: - t.materialise(replacing=True) - return t - - defaultGlobals[func.__name__] = wrapper - return wrapper - - -def _isiterable(xs): - return isinstance(xs, Iterable) and not isinstance( - xs, (str, bytes, bytearray) - ) - - -class Target: - def __init__(self, cwd, name): - self.name = name - self.localname = self.name.rsplit("+")[-1] - self.traits = set() - self.dir = join(G.OBJ, name) - self.ins = [] - self.outs = [] - self.deps = [] - self.materialised = False - self.args = {} - - def __eq__(self, other): - return self.name is other.name - - def __lt__(self, other): - return self.name < other.name - - def __hash__(self): - return id(self) - - def __repr__(self): - return f"Target('{self.name}')" - - def templateexpand(selfi, s): - class Formatter(BracketedFormatter): - def get_field(self, name, a1, a2): - return ( - eval(name, selfi.callback.__globals__, selfi.args), - False, - ) - - def format_field(self, value, format_spec): - if not value: - return "" - if type(value) == str: - return value - if _isiterable(value): - value = list(value) - if type(value) != list: - value = [value] - return " ".join( - [selfi.templateexpand(f) for f in filenamesof(value)] - ) - - s = Formatter().format(s) - return substituteGlobalVariables(s) - - def materialise(self, replacing=False): - if self not in unmaterialisedTargets: - return - - if not replacing and self in materialisingStack: - print("Found dependency cycle:") - for i in materialisingStack: - print(f" {i.name}") - print(f" {self.name}") - sys.exit(1) - materialisingStack.append(self) - - # Perform type conversion to the declared rule parameter types. - - try: - for k, v in self.binding.arguments.items(): - if k != "kwargs": - t = self.types.get(k, None) - if t: - v = t.convert(v, self) - self.args[k] = copy(v) - else: - for kk, vv in v.items(): - t = self.types.get(kk, None) - if t: - vv = t.convert(v, self) - self.args[kk] = copy(vv) - self.args["name"] = self.name - self.args["dir"] = self.dir - self.args["self"] = self - - # Actually call the callback. - - cwdStack.append(self.cwd) - if "kwargs" in self.binding.arguments.keys(): - # If the caller wants kwargs, return all arguments except the standard ones. - cbargs = { - k: v for k, v in self.args.items() if k not in {"dir"} - } - else: - # Otherwise, just call the callback with the ones it asks for. - cbargs = {} - for k in self.binding.arguments.keys(): - if k != "kwargs": - try: - cbargs[k] = self.args[k] - except KeyError: - error( - f"invocation of {self} failed because {k} isn't an argument" - ) - self.callback(**cbargs) - cwdStack.pop() - except BaseException as e: - print(f"Error materialising {self}: {self.callback}") - print(f"Arguments: {self.args}") - raise e - - if self.outs is None: - raise ABException(f"{self.name} didn't set self.outs") - - if self in unmaterialisedTargets: - del unmaterialisedTargets[self] - materialisingStack.pop() - self.materialised = True - - def convert(value, target): - if not value: - return None - return target.targetof(value) - - def targetof(self, value): - if isinstance(value, str) and (value[0] == "="): - value = join(self.dir, value[1:]) - - return targetof(value, self.cwd) - - -def _filetarget(value, cwd): - if value in targets: - return targets[value] - - t = Target(cwd, value) - t.outs = [value] - targets[value] = t - return t - - -def getcwd(): - return cwdStack[-1] - - -def targetof(value, cwd=None): - if not cwd: - cwd = cwdStack[-1] - if isinstance(value, Path): - value = value.as_posix() - if isinstance(value, Target): - t = value - else: - assert ( - value[0] != "=" - ), "can only use = for targets associated with another target" - - if value.startswith("."): - # Check for local rule. - if value.startswith(".+"): - value = normpath(join(cwd, value[1:])) - # Check for local path. - elif value.startswith("./"): - value = normpath(join(cwd, value)) - # Explicit directories are always raw files. - if value.endswith("/"): - return _filetarget(value, cwd) - # Anything in .obj is a raw file. - elif value.startswith(outputdir) or value.startswith(G.OBJ): - return _filetarget(value, cwd) - - # If this is not a rule lookup... - if "+" not in value: - # ...and if the value is pointing at a directory without a trailing /, - # it's a shorthand rule lookup. - if isdir(value): - value = value + "+" + basename(value) - # Otherwise it's an absolute file. - else: - return _filetarget(value, cwd) - - # At this point we have the fully qualified name of a rule. - - (path, target) = value.rsplit("+", 1) - value = join(path, "+" + target) - if value not in targets: - # Load the new build file. - - path = join(path, "build.py") - try: - loadbuildfile(path) - except ModuleNotFoundError: - error( - f"no such build file '{path}' while trying to resolve '{value}'" - ) - assert ( - value in targets - ), f"build file at '{path}' doesn't contain '+{target}' when trying to resolve '{value}'" - - t = targets[value] - - t.materialise() - return t - - -class Targets: - def convert(value, target): - if not value: - return [] - assert _isiterable(value), "cannot convert non-list to Targets" - return [target.targetof(x) for x in flatten(value)] - - -class TargetsMap: - def convert(value, target): - if not value: - return {} - output = {k: target.targetof(v) for k, v in value.items()} - for k, v in output.items(): - assert ( - len(filenamesof([v])) == 1 - ), f"targets of a TargetsMap used as an argument of {target} with key '{k}' must contain precisely one output file, but was {filenamesof([v])}" - return output - - -def _removesuffix(self, suffix): - # suffix='' should not call self[:-0]. - if suffix and self.endswith(suffix): - return self[: -len(suffix)] - else: - return self[:] - - -def loadbuildfile(filename): - modulename = _removesuffix(filename.replace("/", "."), ".py") - if modulename not in sys.modules: - spec = importlib.util.spec_from_file_location( - name=modulename, - location=filename, - loader=BuildFileLoaderImpl(fullname=modulename, path=filename), - submodule_search_locations=[], - ) - module = importlib.util.module_from_spec(spec) - sys.modules[modulename] = module - spec.loader.exec_module(module) - - -def flatten(items): - def generate(xs): - for x in xs: - if _isiterable(x): - yield from generate(x) - else: - yield x - - return list(generate(items)) - - -def targetnamesof(items): - assert _isiterable(items), "argument of filenamesof is not a collection" - - return [t.name for t in items] - - -def filenamesof(items): - assert _isiterable(items), "argument of filenamesof is not a collection" - - def generate(xs): - for x in xs: - if isinstance(x, Target): - x.materialise() - yield from generate(x.outs) - else: - yield x - - return list(generate(items)) - - -def filenameof(x): - xs = filenamesof(x.outs) - assert ( - len(xs) == 1 - ), f"tried to use filenameof() on {x} which does not have exactly one output: {x.outs}" - return xs[0] - - -def emit(*args, into=None): - s = " ".join(args) + "\n" - if into is not None: - into += [s] - else: - ninjaFp.write(s) - - -def shell(*args): - s = "".join(args) + "\n" - shellFp.write(s) - - -def add_commanddb_entry(commands, file): - global commandsDb - commandsDb += [ - { - "directory": os.getcwd(), - "command": (" && ".join(commands)), - "file": file, - } - ] - - -def add_belated_error(msg): - global belatedErrors - belatedErrors += [msg] - - -def add_atexit(cb): - global atexits - atexits += [cb] - - -def emit_rule( - self, ins, outs, cmds=[], label=None, sandbox=True, generator=False -): - name = self.name - fins = [self.templateexpand(f) for f in set(filenamesof(ins))] - fouts = [self.templateexpand(f) for f in filenamesof(outs)] - - global outputTargets - outputTargets.update(fouts) - outputTargets.add(name) - - emit("") - if VERBOSE_NINJA_FILE: - for k, v in self.args.items(): - emit(f"# {k} = {v}") - - if outs: - os.makedirs(self.dir, exist_ok=True) - rule = [] - - sandbox = sandbox and (G.AB_SANDBOX == "yes") - if sandbox: - sandbox = join(self.dir, "sandbox") - emit(f"rm -rf {sandbox}", into=rule) - emit( - f"{G.PYTHON} build/_sandbox.py --link -s", - sandbox, - *fins, - into=rule, - ) - for c in cmds: - emit(f"(cd {sandbox} &&", c, ")", into=rule) - emit( - f"{G.PYTHON} build/_sandbox.py --export -s", - sandbox, - *fouts, - into=rule, - ) - else: - for c in cmds: - emit(c, into=rule) - - ruletext = "".join(rule) - if len(ruletext) > 7000: - rulehash = hashlib.sha1(ruletext.encode()).hexdigest() - - rulef = join(self.dir, f"rule-{rulehash}.sh") - with open(rulef, "wt") as fp: - fp.write("set -e\n") - fp.write(ruletext) - - emit("build", *fouts, ":rule", *fins) - emit(" command=sh", rulef) - else: - emit("build", *fouts, ":rule", *fins) - emit( - " command=", - "&&".join([s.strip() for s in rule]).replace("$", "$$"), - ) - if label: - emit(" description=", label) - if generator: - emit(" generator=true") - - emit("build", name, ":phony", *fouts) - else: - assert len(cmds) == 0, "rules with no outputs cannot have commands" - emit("build", name, ":phony", *fins) - - emit("") - - -@Rule -def simplerule( - self, - name, - ins: Targets = [], - outs: Targets = [], - deps: Targets = [], - commands=[], - add_to_commanddb=False, - sandbox=True, - generator=False, - label="RULE", -): - self.ins = ins - self.outs = outs - self.deps = deps - - dirs = [] - cs = [] - for out in filenamesof(outs): - dir = dirname(out) - if dir and dir not in dirs: - dirs += [dir] - - cs = [("mkdir -p %s" % dir) for dir in dirs] - - coreCommands = [] - for c in commands: - coreCommands += [self.templateexpand(c)] - cs += coreCommands - - if add_to_commanddb: - infiles = filenamesof(ins) - if len(infiles) > 0: - global commandsDb - commandsDb += [ - { - "directory": os.getcwd(), - "command": (" && ".join(coreCommands)), - "file": infiles[0], - } - ] - - emit_rule( - self=self, - ins=ins + deps, - outs=outs, - label=self.templateexpand("$[label] $[name]") if label else None, - cmds=cs, - sandbox=sandbox, - generator=generator, - ) - - -@Rule -def export(self, name=None, items: TargetsMap = {}, deps: Targets = []): - ins = [] - outs = [] - for dest, src in items.items(): - dest = self.targetof(dest) - outs += [dest] - - destf = self.templateexpand(filenameof(dest)) - outputTargets.update([destf]) - - srcs = filenamesof([src]) - assert ( - len(srcs) == 1 - ), "a dependency of an exported file must have exactly one output file" - srcf = self.templateexpand(srcs[0]) - - subrule = simplerule( - name=f"{self.localname}/{destf}", - cwd=self.cwd, - ins=[srcs[0]], - outs=[destf], - commands=["$(CP) -H %s %s" % (srcf, destf)], - label="EXPORT", - ) - subrule.materialise() - - self.ins = [] - self.outs = deps + outs - outputTargets.add(name) - - emit("") - emit( - "build", - name, - ":phony", - *[self.templateexpand(f) for f in filenamesof(outs + deps)], - ) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("-q", "--quiet", action="store_true") - parser.add_argument("-v", "--varfile") - parser.add_argument("-o", "--outputdir") - parser.add_argument("-D", "--define", action="append", default=[]) - parser.add_argument("files", nargs="+") - args = parser.parse_args() - - global quiet - quiet = args.quiet - - vardefs = args.define - if args.varfile: - with open(args.varfile, "rt") as fp: - vardefs = vardefs + list(fp) - - for line in vardefs: - if "=" in line: - name, value = line.split("=", 1) - G.setdefault(name.strip(), value.strip()) - G.setdefault("AB_SANDBOX", "yes") - - global ninjaFp, shellFp, jsonFp, outputdir - outputdir = args.outputdir - G.setdefault("OBJ", outputdir) - ninjaFp = open(outputdir + "/build.ninja", "wt") - ninjaFp.write(f"include build/ab.ninja\n") - - for k in ["Rule"]: - defaultGlobals[k] = globals()[k] - - global __name__ - sys.modules["build.ab"] = sys.modules[__name__] - __name__ = "build.ab" - - for f in args.files: - loadbuildfile(f) - - while unmaterialisedTargets: - t = next(iter(unmaterialisedTargets)) - t.materialise() - - if belatedErrors: - print("FAILED:") - for s in belatedErrors: - print(s) - sys.exit(1) - - for cb in atexits: - cb() - - with open(outputdir + "/build.targets", "wt") as fp: - fp.write("ninja-targets =") - fp.write(substituteGlobalVariables(" ".join(outputTargets))) - - with open(outputdir + "/compile_commands.json", "wt") as fp: - json.dump(commandsDb, fp) - - -main() diff --git a/build/c.py b/build/c.py deleted file mode 100644 index 67df0dced..000000000 --- a/build/c.py +++ /dev/null @@ -1,597 +0,0 @@ -from build.ab import ( - Rule, - Targets, - TargetsMap, - filenameof, - filenamesof, - flatten, - simplerule, - add_commanddb_entry, - G, -) -from build.utils import stripext, collectattrs -from build.toolchain import Toolchain, HostToolchain -from os.path import * - -if G.OSX != "yes": - G.STARTGROUP = "-Wl,--start-group" - G.ENDGROUP = "-Wl,--end-group" -else: - G.STARTGROUP = "" - G.ENDGROUP = "" - -Toolchain.CC = ["$(CC) -c -o $[outs[0]] $[ins[0]] $(CFLAGS) $[cflags]"] -Toolchain.CPP = ["$(CC) -E -P -o $[outs] $[cflags] -x c $[ins]"] -Toolchain.CXX = ["$(CXX) -c -o $[outs[0]] $[ins[0]] $(CXXFLAGS) $[cflags]"] -Toolchain.AR = ["$(AR) cqs $[outs[0]] $[ins]"] -Toolchain.ARXX = ["$(AR) cqs $[outs[0]] $[ins]"] -Toolchain.CLINK = [ - "$(CC) -o $[outs[0]] $(STARTGROUP) $[ins] $[ldflags] $(LDFLAGS) $(ENDGROUP)" -] -Toolchain.CXXLINK = [ - "$(CXX) -o $[outs[0]] $(STARTGROUP) $[ins] $[ldflags] $(LDFLAGS) $(ENDGROUP)" -] - -Toolchain.is_source_file = ( - lambda f: f.endswith(".c") - or f.endswith(".cc") - or f.endswith(".cpp") - or f.endswith(".S") - or f.endswith(".s") - or f.endswith(".m") - or f.endswith(".mm") -) - - -# Given a set of dependencies, finds the set of relevant library targets (i.e. -# contributes *.a files) for compiling C programs. The actual list of libraries -# is in dep.clibrary_files. -def _toolchain_find_library_targets(deps): - lib_deps = [] - for d in deps: - lib_deps = _combine(lib_deps, d.args.get("clibrary_deps", [])) - return lib_deps - - -Toolchain.find_c_library_targets = _toolchain_find_library_targets - - -# Given a set of dependencies, finds the set of relevant header targets (i.e. -# contributes *.h files) for compiling C programs. The actual list of libraries -# is in dep.cheader_files. -def _toolchain_find_header_targets(deps, initial=[]): - hdr_deps = initial - for d in deps: - hdr_deps = _combine(hdr_deps, d.args.get("cheader_deps", [])) - return hdr_deps - - -Toolchain.find_c_header_targets = _toolchain_find_header_targets - - -HostToolchain.CC = [ - "$(HOSTCC) -c -o $[outs[0]] $[ins[0]] $(HOSTCFLAGS) $[cflags]" -] -HostToolchain.CPP = ["$(HOSTCC) -E -P -o $[outs] $[cflags] -x c $[ins]"] -HostToolchain.CXX = [ - "$(HOSTCXX) -c -o $[outs[0]] $[ins[0]] $(HOSTCFLAGS) $[cflags]" -] -HostToolchain.AR = ["$(HOSTAR) cqs $[outs[0]] $[ins]"] -HostToolchain.ARXX = ["$(HOSTAR) cqs $[outs[0]] $[ins]"] -HostToolchain.CLINK = [ - "$(HOSTCC) -o $[outs[0]] $(STARTGROUP) $[ins] $[ldflags] $(HOSTLDFLAGS) $(ENDGROUP)" -] -HostToolchain.CXXLINK = [ - "$(HOSTCXX) -o $[outs[0]] $(STARTGROUP) $[ins] $[ldflags] $(HOSTLDFLAGS) $(ENDGROUP)" -] - - -def _combine(list1, list2): - r = list(list1) - for i in list2: - if i not in r: - r.append(i) - return r - - -def _indirect(deps, name): - r = [] - for d in deps: - r = _combine(r, d.args.get(name, [d])) - return r - - -def cfileimpl( - self, name, srcs, deps, suffix, commands, label, toolchain, cflags -): - outleaf = "=" + stripext(basename(filenameof(srcs[0]))) + suffix - - hdr_deps = toolchain.find_c_header_targets(deps) - other_deps = [ - d - for d in deps - if ("cheader_deps" not in d.args) and ("clibrary_deps" not in d.args) - ] - hdr_files = collectattrs(targets=hdr_deps, name="cheader_files") - cflags = collectattrs( - targets=hdr_deps, name="caller_cflags", initial=cflags - ) - - t = simplerule( - replaces=self, - ins=srcs, - deps=other_deps + hdr_files, - outs=[outleaf], - label=label, - commands=commands, - add_to_commanddb=True, - args={"cflags": cflags}, - ) - - -@Rule -def cfile( - self, - name, - srcs: Targets = None, - deps: Targets = None, - cflags=[], - suffix=".o", - toolchain=Toolchain, - label="CC", -): - cfileimpl( - self, - name, - srcs, - deps, - suffix, - toolchain.CC, - toolchain.PREFIX + label, - toolchain, - cflags, - ) - - -@Rule -def cxxfile( - self, - name, - srcs: Targets = None, - deps: Targets = None, - cflags=[], - suffix=".o", - toolchain=Toolchain, - label="CXX", -): - cfileimpl( - self, - name, - srcs, - deps, - suffix, - toolchain.CXX, - toolchain.PREFIX + label, - toolchain, - cflags, - ) - - -def _removeprefix(self, prefix): - if self.startswith(prefix): - return self[len(prefix) :] - else: - return self[:] - - -def findsources(self, srcs, deps, cflags, filerule, toolchain, cwd): - for f in filenamesof(srcs): - if not toolchain.is_source_file(f): - cflags = cflags + [f"-I{dirname(f)}"] - deps = deps + [f] - - objs = [] - for s in flatten(srcs): - objs += [ - filerule( - name=join(self.localname, _removeprefix(f, G.OBJ + "/")), - srcs=[f], - deps=deps, - cflags=sorted(set(cflags)), - toolchain=toolchain, - cwd=cwd, - args=getattr(self, "explicit_args", {}), - ) - for f in filenamesof([s]) - if toolchain.is_source_file(f) - ] - if any(f.endswith(".o") for f in filenamesof([s])): - objs += [s] - - return objs - - -def libraryimpl( - self, - name, - srcs, - deps, - hdrs, - caller_cflags, - caller_ldflags, - cflags, - ldflags, - toolchain, - commands, - label, - filerule, -): - hdr_deps = toolchain.find_c_header_targets(deps) + [self] - lib_deps = toolchain.find_c_library_targets(deps) + [self] - - hr = None - hf = [] - ar = None - if hdrs: - cs = [] - ins = hdrs.values() - outs = [] - i = 0 - for dest, src in hdrs.items(): - s = filenamesof([src]) - assert ( - len(s) == 1 - ), "the target of a header must return exactly one file" - - cs += [f"$(CP) $[ins[{i}]] $[outs[{i}]]"] - outs += ["=" + dest] - i = i + 1 - - hr = simplerule( - name=f"{self.localname}_hdr", - ins=ins, - outs=outs, - commands=cs, - label=toolchain.PREFIX + "CHEADERS", - ) - hr.args["cheader_deps"] = [hr] - hr.args["cheader_files"] = [hr] - hf = [f"-I{hr.dir}"] - - if srcs: - # Can't depend on the current target to get the library headers, because - # if we do it'll cause a dependency loop. - objs = findsources( - self, - srcs, - deps + ([hr] if hr else []), - cflags + hf, - filerule, - toolchain, - self.cwd, - ) - - ar = simplerule( - name=f"{self.localname}_lib", - ins=objs, - outs=[f"={self.localname}.a"], - deps=deps, - label=label, - commands=commands, - ) - ar.materialise() - - self.outs = ([hr] if hr else []) + ([ar] if ar else []) - self.deps = self.outs - self.args["cheader_deps"] = hdr_deps - self.args["clibrary_deps"] = lib_deps - self.args["cheader_files"] = [hr] if hr else [] - self.args["clibrary_files"] = [ar] if ar else [] - self.args["caller_cflags"] = caller_cflags + hf - self.args["caller_ldflags"] = caller_ldflags - - -@Rule -def clibrary( - self, - name, - srcs: Targets = None, - deps: Targets = None, - hdrs: TargetsMap = None, - caller_cflags=[], - caller_ldflags=[], - cflags=[], - ldflags=[], - toolchain=Toolchain, - label="LIB", - cfilerule=cfile, -): - libraryimpl( - self, - name, - srcs, - deps, - hdrs, - caller_cflags, - caller_ldflags, - cflags, - ldflags, - toolchain, - toolchain.AR, - toolchain.PREFIX + label, - cfilerule, - ) - - -@Rule -def hostclibrary( - self, - name, - srcs: Targets = None, - deps: Targets = None, - hdrs: TargetsMap = None, - caller_cflags=[], - caller_ldflags=[], - cflags=[], - ldflags=[], - toolchain=HostToolchain, - label="LIB", - cfilerule=cfile, -): - libraryimpl( - self, - name, - srcs, - deps, - hdrs, - caller_cflags, - caller_ldflags, - cflags, - ldflags, - toolchain, - toolchain.AR, - toolchain.PREFIX + label, - cfilerule, - ) - - -@Rule -def cxxlibrary( - self, - name, - srcs: Targets = None, - deps: Targets = None, - hdrs: TargetsMap = None, - caller_cflags=[], - caller_ldflags=[], - cflags=[], - ldflags=[], - toolchain=Toolchain, - label="CXXLIB", - cxxfilerule=cxxfile, -): - libraryimpl( - self, - name, - srcs, - deps, - hdrs, - caller_cflags, - caller_ldflags, - cflags, - ldflags, - toolchain, - toolchain.ARXX, - toolchain.PREFIX + label, - cxxfilerule, - ) - - -@Rule -def hostcxxlibrary( - self, - name, - srcs: Targets = None, - deps: Targets = None, - hdrs: TargetsMap = None, - caller_cflags=[], - caller_ldflags=[], - cflags=[], - ldflags=[], - toolchain=HostToolchain, - label="CXXLIB", - cxxfilerule=cxxfile, -): - libraryimpl( - self, - name, - srcs, - deps, - hdrs, - caller_cflags, - caller_ldflags, - cflags, - ldflags, - toolchain, - toolchain.ARXX, - toolchain.PREFIX + label, - cxxfilerule, - ) - - -def programimpl( - self, - name, - srcs, - deps, - cflags, - ldflags, - toolchain, - commands, - label, - filerule, -): - cfiles = findsources( - self, srcs, deps, cflags, filerule, toolchain, self.cwd - ) - - lib_deps = toolchain.find_c_library_targets(deps) - libs = collectattrs(targets=lib_deps, name="clibrary_files") - ldflags = collectattrs( - targets=lib_deps, name="caller_ldflags", initial=ldflags - ) - - simplerule( - replaces=self, - ins=cfiles + libs, - outs=[f"={self.localname}{toolchain.EXE}"], - deps=deps, - label=label, - commands=commands, - args={"ldflags": ldflags}, - ) - - -@Rule -def cprogram( - self, - name, - srcs: Targets = None, - deps: Targets = None, - cflags=[], - ldflags=[], - toolchain=Toolchain, - label="CLINK", - cfilerule=cfile, -): - programimpl( - self, - name, - srcs, - deps, - cflags, - ldflags, - toolchain, - toolchain.CLINK, - toolchain.PREFIX + label, - cfilerule, - ) - - -@Rule -def hostcprogram( - self, - name, - srcs: Targets = None, - deps: Targets = None, - cflags=[], - ldflags=[], - toolchain=HostToolchain, - label="CLINK", - cfilerule=cfile, -): - programimpl( - self, - name, - srcs, - deps, - cflags, - ldflags, - toolchain, - toolchain.CLINK, - toolchain.PREFIX + label, - cfilerule, - ) - - -@Rule -def cxxprogram( - self, - name, - srcs: Targets = None, - deps: Targets = None, - cflags=[], - ldflags=[], - toolchain=Toolchain, - label="CXXLINK", - cxxfilerule=cxxfile, -): - programimpl( - self, - name, - srcs, - deps, - cflags, - ldflags, - toolchain, - toolchain.CXXLINK, - toolchain.PREFIX + label, - cxxfilerule, - ) - - -@Rule -def hostcxxprogram( - self, - name, - srcs: Targets = None, - deps: Targets = None, - cflags=[], - ldflags=[], - toolchain=HostToolchain, - label="CXXLINK", - cxxfilerule=cxxfile, -): - programimpl( - self, - name, - srcs, - deps, - cflags, - ldflags, - toolchain, - toolchain.CXXLINK, - toolchain.PREFIX + label, - cxxfilerule, - ) - - -def _cppfileimpl(self, name, srcs, deps, cflags, toolchain): - hdr_deps = _indirect(deps, "cheader_deps") - cflags = collectattrs( - targets=hdr_deps, name="caller_cflags", initial=cflags - ) - - simplerule( - replaces=self, - ins=srcs, - outs=[f"={self.localname}"], - deps=deps, - commands=toolchain.CPP, - args={"cflags": cflags}, - label=toolchain.PREFIX + "CPPFILE", - ) - - -@Rule -def cppfile( - self, - name, - srcs: Targets = [], - deps: Targets = [], - cflags=[], - toolchain=Toolchain, -): - _cppfileimpl(self, name, srcs, deps, cflags, toolchain) - - -@Rule -def hostcppfile( - self, - name, - srcs: Targets = [], - deps: Targets = [], - cflags=[], - toolchain=HostToolchain, -): - _cppfileimpl(self, name, srcs, deps, cflags, toolchain) diff --git a/build/git.py b/build/git.py deleted file mode 100644 index cb7ec267b..000000000 --- a/build/git.py +++ /dev/null @@ -1,26 +0,0 @@ -from build.ab import Rule, simplerule -from build.utils import add_wildcard_dependency - - -@Rule -def git_repository(self, name, url, branch, path, commit=None): - simplerule( - replaces=self, - outs=[f"{path}/.git/config"], - commands=[ - f"rmdir {path}/.git", - f"git clone -q {url} --depth=1 -c advice.detachedHead=false -b {branch} {path}", - ] - + ( - [ - f"cd {path} && git fetch --depth=1 origin {commit} && git checkout {commit}" - ] - if commit - else [] - ), - sandbox=False, - generator=True, - label="GITREPOSITORY", - ) - - add_wildcard_dependency(self, f"{path}/**/*", exclude="**/.git/**") diff --git a/build/pkg.py b/build/pkg.py deleted file mode 100644 index f948d7ae3..000000000 --- a/build/pkg.py +++ /dev/null @@ -1,87 +0,0 @@ -from build.ab import Rule, Target, G, add_belated_error -import subprocess - - -class _PkgConfig: - package_present = set() - package_properties = {} - pkgconfig = None - - def __init__(self, cmd): - assert cmd, "no pkg-config environment variable supplied" - self.pkgconfig = cmd - - r = subprocess.run(f"{cmd} --list-all", shell=True, capture_output=True) - ps = r.stdout.decode("utf-8") - self.package_present = {l.split(" ", 1)[0] for l in ps.splitlines()} - - def has_package(self, name): - return name in self.package_present - - def get_property(self, name, flag): - p = f"{name}.{flag}" - if p not in self.package_properties: - r = subprocess.run( - f"{self.pkgconfig} {flag} {name}", - shell=True, - capture_output=True, - ) - self.package_properties[p] = r.stdout.decode("utf-8").strip() - return self.package_properties[p] - - -TargetPkgConfig = _PkgConfig(G.PKG_CONFIG) -HostPkgConfig = _PkgConfig(G.HOST_PKG_CONFIG) - - -def _package(self, name, package, fallback, pkgconfig): - if pkgconfig.has_package(package): - print(f"package '{package}' found") - cflags = pkgconfig.get_property(package, "--cflags") - ldflags = pkgconfig.get_property(package, "--libs") - - if cflags: - self.args["caller_cflags"] = [cflags] - if ldflags: - self.args["caller_ldflags"] = [ldflags] - self.args["clibrary_deps"] = [self] - self.args["cheader_deps"] = [self] - self.traits.update({"clibrary", "cxxlibrary"}) - return - - if not fallback: - add_belated_error(f"Required package '{package}' not installed") - return - - print(f"package '{package}' not found; using fallback") - - if "cheader_deps" in fallback.args: - self.args["cheader_deps"] = fallback.args["cheader_deps"] - if "clibrary_deps" in fallback.args: - self.args["clibrary_deps"] = fallback.args["clibrary_deps"] - if "cheader_files" in fallback.args: - self.args["cheader_files"] = fallback.args["cheader_files"] - if "clibrary_files" in fallback.args: - self.args["clibrary_files"] = fallback.args["clibrary_files"] - self.ins = fallback.ins - self.outs = fallback.outs - self.deps = fallback.deps - self.traits = fallback.traits - - -@Rule -def package(self, name, package=None, fallback: Target = None): - _package(self, name, package, fallback, TargetPkgConfig) - - -@Rule -def hostpackage(self, name, package=None, fallback: Target = None): - _package(self, name, package, fallback, HostPkgConfig) - - -def has_package(name): - return TargetPkgConfig.has_package(name) - - -def has_host_package(name): - return HostPkgConfig.has_package(name) diff --git a/build/protobuf.py b/build/protobuf.py deleted file mode 100644 index b6674b5eb..000000000 --- a/build/protobuf.py +++ /dev/null @@ -1,192 +0,0 @@ -from build.ab import ( - Rule, - Targets, - emit, - simplerule, - filenamesof, - G, - add_belated_error, -) -from build.utils import filenamesmatchingof, collectattrs -from os.path import join, abspath, dirname, relpath -from build.pkg import has_package, TargetPkgConfig -import platform - -G.setdefault("PROTOC", "protoc") -G.setdefault("HOSTPROTOC", "hostprotoc") - -if not has_package("protobuf"): - add_belated_error("Required package 'protobuf' not installed") - -PROTO_SEPARATOR = ";" if (platform.system() == "Windows") else ":" - - -def _getprotodeps(deps): - r = set() - for d in deps: - r.update(d.args.get("protodeps", {d})) - return sorted(r) - - -@Rule -def proto(self, name, srcs: Targets = [], deps: Targets = []): - protodeps = _getprotodeps(deps) - descriptorlist = PROTO_SEPARATOR.join( - [ - relpath(f, start=self.dir) - for f in filenamesmatchingof(protodeps, "*.descriptor") - ] - ) - - dirs = sorted({"$[dir]/" + dirname(f) for f in filenamesof(srcs)}) - simplerule( - replaces=self, - ins=srcs, - outs=[f"={self.localname}.descriptor"], - deps=protodeps, - commands=( - ["mkdir -p " + (" ".join(dirs))] - + [f"$(CP) {f} $[dir]/{f}" for f in filenamesof(srcs)] - + [ - "cd $[dir] && " - + ( - " ".join( - [ - "$(PROTOC)", - "--proto_path=.", - "--include_source_info", - f"--descriptor_set_out={self.localname}.descriptor", - ] - + ( - [f"--descriptor_set_in='{descriptorlist}'"] - if descriptorlist - else [] - ) - + ["$[ins]"] - ) - ) - ] - ), - label="PROTO", - args={ - "protosrcs": filenamesof(srcs), - "protodeps": set(protodeps) | {self}, - }, - ) - - -@Rule -def protolib(self, name, srcs: Targets = []): - simplerule( - replaces=self, - label="PROTOLIB", - args={ - "protosrcs": collectattrs(targets=srcs, name="protosrcs"), - "protodeps": set(_getprotodeps(srcs)), - }, - ) - - -@Rule -def protocc(self, name, srcs: Targets = [], deps: Targets = []): - outs = [] - protos = [] - - allsrcs = collectattrs(targets=srcs, name="protosrcs") - assert allsrcs, "no sources provided" - for f in filenamesmatchingof(allsrcs, "*.proto"): - cc = f.replace(".proto", ".pb.cc") - h = f.replace(".proto", ".pb.h") - protos += [f] - outs += ["=" + cc, "=" + h] - - protodeps = _getprotodeps(deps + srcs) - descriptorlist = PROTO_SEPARATOR.join( - [ - relpath(f, start=self.dir) - for f in filenamesmatchingof(protodeps, "*.descriptor") - ] - ) - - r = simplerule( - name=f"{self.localname}_srcs", - cwd=self.cwd, - ins=srcs, - outs=outs, - deps=protodeps, - commands=[ - "cd $[dir] && " - + ( - " ".join( - [ - "$(PROTOC)", - "--proto_path=.", - "--cpp_out=.", - f"--descriptor_set_in='{descriptorlist}'", - ] - + protos - ) - ) - ], - label="PROTOCC", - ) - - headers = {f[1:]: join(r.dir, f[1:]) for f in outs if f.endswith(".pb.h")} - - from build.c import cxxlibrary - - cxxlibrary( - replaces=self, - srcs=[r], - deps=deps, - hdrs=headers, - ) - - -@Rule -def protojava(self, name, srcs: Targets = [], deps: Targets = []): - outs = [] - - allsrcs = collectattrs(targets=srcs, name="protosrcs") - assert allsrcs, "no sources provided" - protos = [] - for f in filenamesmatchingof(allsrcs, "*.proto"): - protos += [f] - srcs += [f] - - descriptorlist = PROTO_SEPARATOR.join( - [abspath(f) for f in filenamesmatchingof(srcs + deps, "*.descriptor")] - ) - - r = simplerule( - name=f"{self.localname}_srcs", - cwd=self.cwd, - ins=protos, - outs=[f"={self.localname}.srcjar"], - deps=srcs + deps, - commands=[ - "mkdir -p $[dir]/srcs", - "cd $[dir]/srcs && " - + ( - " ".join( - [ - "$(PROTOC)", - "--proto_path=.", - "--java_out=.", - f"--descriptor_set_in='{descriptorlist}'", - ] - + protos - ) - ), - "$(JAR) cf $[outs[0]] -C $[dir]/srcs .", - ], - traits={"srcjar"}, - label="PROTOJAVA", - ) - - from build.java import javalibrary - - javalibrary( - replaces=self, - deps=[r] + deps, - ) diff --git a/build/toolchain.py b/build/toolchain.py deleted file mode 100644 index e728ef87d..000000000 --- a/build/toolchain.py +++ /dev/null @@ -1,12 +0,0 @@ -import platform - -_is_windows = platform.system() == "Windows" - - -class Toolchain: - PREFIX = "" - EXE = ".exe" if _is_windows else "" - - -class HostToolchain(Toolchain): - PREFIX = "HOST" diff --git a/build/utils.py b/build/utils.py deleted file mode 100644 index a793ad9fe..000000000 --- a/build/utils.py +++ /dev/null @@ -1,181 +0,0 @@ -from build.ab import ( - Rule, - Target, - Targets, - filenameof, - filenamesof, - getcwd, - error, - simplerule, - add_atexit, - targets, - emit, - G, -) -from os.path import relpath, splitext, join, basename, isfile, normpath -from os import walk -from glob import iglob -import fnmatch -import subprocess -import shutil -import re -import functools - - -def filenamesmatchingof(xs, pattern): - return fnmatch.filter(filenamesof(xs), pattern) - - -def stripext(path): - return splitext(path)[0] - - -def targetswithtraitsof(xs, trait): - return [t for t in xs if trait in t.traits] - - -def collectattrs(*, targets, name, initial=[]): - s = set(initial) - for a in [t.args.get(name, []) for t in targets]: - s.update(a) - return sorted(s) - - -@functools.cache -def _glob_to_re(glob_str): - if glob_str.startswith("./"): - glob_str = normpath(join(getcwd(), glob_str)) - - opts = re.compile("([.]|[*][*]/|[*]|[?])|(.)") - out = "" - for pattern_match, literal_text in opts.findall(glob_str): - if pattern_match == ".": - out += "[.]" - elif pattern_match == "**/": - out += "(?:.*/)?" - elif pattern_match == "*": - out += "[^/]*" - elif pattern_match == "?": - out += "." - elif literal_text: - out += literal_text - return re.compile(out) - - -def _glob_filter(paths, pattern): - r = _glob_to_re(pattern) - for f in paths: - if r.match(f): - yield f - - -def _glob_matches(path, pattern): - r = _glob_to_re(pattern) - return r.match(path) - - -def glob(include=["*"], exclude=[], dir=None, relative_to="."): - if not dir: - dir = getcwd() - if dir.startswith("./"): - dir = normpath(join(getcwd(), dir)) - if relative_to.startswith("./"): - relative_to = normpath(join(getcwd(), relative_to)) - - def iterate(): - for dirpath, dirnames, filenames in walk( - dir, topdown=True, followlinks=True - ): - dirpath = relpath(dirpath, relative_to) - filenames = [normpath(join(dirpath, f)) for f in filenames] - matching = set() - for p in include: - matching.update([f for f in _glob_filter(filenames, p)]) - for p in exclude: - matching = [n for n in matching if not _glob_matches(n, p)] - for f in matching: - yield f - - return list(iterate()) - - -def itemsof(pattern, root=None, cwd=None): - if not cwd: - cwd = getcwd() - if not root: - root = "." - - pattern = join(cwd, pattern) - root = join(cwd, root) - - result = {} - for f in iglob(pattern, recursive=True): - try: - if isfile(f): - result[relpath(f, root)] = f - except ValueError: - error(f"file '{f}' is not in root '{root}'") - return result - - -def does_command_exist(cmd): - basecmd = cmd.strip().split()[0] - return shutil.which(basecmd) - - -def shell(cmd): - r = subprocess.check_output([G.SHELL, "-c", cmd]) - return r.decode("utf-8").strip() - - -def add_wildcard_dependency(dep, pattern, exclude="."): - def cb(): - yesre = _glob_to_re(pattern) - nore = _glob_to_re(exclude) - for t in targets.values(): - for o in t.outs: - if (type(o) == str) and yesre.match(o) and not nore.match(o): - emit("build", o, ":phony", dep.name) - - add_atexit(cb) - - -@Rule -def objectify(self, name, src: Target, symbol): - simplerule( - replaces=self, - ins=["build/_objectify.py", src], - outs=[f"={basename(filenameof(src))}.h"], - commands=["$(PYTHON) $[ins[0]] $[ins[1]] " + symbol + " > $[outs]"], - label="OBJECTIFY", - ) - - -@Rule -def test( - self, - name, - command: Target = None, - commands=None, - ins: Targets = None, - deps: Targets = None, - label="TEST", -): - if command: - simplerule( - replaces=self, - ins=[command], - outs=["=sentinel"], - commands=["$[ins[0]]", "touch $[outs[0]]"], - deps=deps, - label=label, - ) - else: - simplerule( - replaces=self, - ins=ins, - outs=["=sentinel"], - commands=commands + ["touch $[outs[0]]"], - deps=deps, - label=label, - ) diff --git a/build/zip.py b/build/zip.py deleted file mode 100644 index 2b631c694..000000000 --- a/build/zip.py +++ /dev/null @@ -1,27 +0,0 @@ -from build.ab import ( - Rule, - simplerule, - TargetsMap, - filenameof, -) - - -@Rule -def zip( - self, name, flags="", items: TargetsMap = {}, extension="zip", label="ZIP" -): - cs = ["$(PYTHON) build/_zip.py -z $[outs]"] - - ins = [] - for k, v in items.items(): - cs += [f"-f {k} {filenameof(v)}"] - ins += [v] - - simplerule( - replaces=self, - ins=ins, - deps=["build/_zip.py"], - outs=[f"={self.localname}." + extension], - commands=[" ".join(cs)], - label=label, - ) diff --git a/corpus.bzl b/corpus.bzl new file mode 100644 index 000000000..48e396e84 --- /dev/null +++ b/corpus.bzl @@ -0,0 +1,88 @@ +load("@rules_java//java:defs.bzl", "java_test") + +# Encode/decode round-trip tests, ported from the corpus tests in build.py. +# Each test generates a random sector image, writes it to a flux file, reads it +# back, and checks the result matches, using the EncodeDecodeTest tool. + +CORPUS = [ + ("acorndfs", "", "--200"), + ("agat", "", ""), + ("amiga", "", ""), + ("apple2", "", "--140 --drivetype=40"), + ("atarist", "", "--360"), + ("atarist", "", "--370"), + ("atarist", "", "--400"), + ("atarist", "", "--410"), + ("atarist", "", "--720"), + ("atarist", "", "--740"), + ("atarist", "", "--800"), + ("atarist", "", "--820"), + ("bk", "", ""), + ("brother", "", "--120 --drivetype=40"), + ("brother", "", "--240"), + ( + "commodore", + "scripts/commodore1541_test.textpb", + "--171 --drivetype=40", + ), + ( + "commodore", + "scripts/commodore1541_test.textpb", + "--192 --drivetype=40", + ), + ("commodore", "", "--800"), + ("commodore", "", "--1620"), + ("hplif", "", "--264"), + ("hplif", "", "--608"), + ("hplif", "", "--616"), + ("hplif", "", "--770"), + ("ibm", "", "--1200"), + ("ibm", "", "--1232"), + ("ibm", "", "--1440"), + ("ibm", "", "--1680"), + ("ibm", "", "--180 --drivetype=40"), + ("ibm", "", "--160 --drivetype=40"), + ("ibm", "", "--320 --drivetype=40"), + ("ibm", "", "--360 --drivetype=40"), + ("ibm", "", "--720_96"), + ("ibm", "", "--720_135"), + ("mac", "scripts/mac400_test.textpb", "--400"), + ("mac", "scripts/mac800_test.textpb", "--800"), + ("n88basic", "", ""), + ("rx50", "", ""), + ("tartu", "", "--390 --drivetype=40"), + ("tartu", "", "--780"), + ("tids990", "", ""), + ("victor9k", "", "--612"), + ("victor9k", "", "--1224"), +] + +def _sanitize(s): + result = "" + for ch in s.elems(): + result += ch if ch.isalnum() else "_" + return result + +def define_corpus_tests(): + tests = [] + for entry in CORPUS: + format = entry[0] + script = entry[1] + flags = entry[2] + name = _sanitize(format + script + flags) + for ext in ["scp", "flux"]: + test_name = "corpustest_%s_%s" % (name, ext) + args = [format, ext] + if flags: + args += flags.split(" ") + java_test( + name = test_name, + main_class = "com.cowlark.fluxengine.buildtools.EncodeDecodeTest", + use_testrunner = False, + args = args, + runtime_deps = ["//java/com/cowlark/fluxengine/buildtools:encodedecodetest"], + size = "small", + timeout = "moderate", + ) + tests.append(test_name) + return tests diff --git a/fluxengine.iml b/fluxengine.iml new file mode 100644 index 000000000..c01b9e60d --- /dev/null +++ b/fluxengine.iml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/java/BUILD.bazel b/java/BUILD.bazel new file mode 100644 index 000000000..6200e1a77 --- /dev/null +++ b/java/BUILD.bazel @@ -0,0 +1,6 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files(["javax.usb.properties"]) + +# Top-level package BUILD within java/ kept minimal: per-package BUILD files live under com/. +# This file intentionally contains no targets that compile sources; see per-package BUILD files. diff --git a/java/com/cowlark/fluxengine/BUILD.bazel b/java/com/cowlark/fluxengine/BUILD.bazel new file mode 100644 index 000000000..2834ffaba --- /dev/null +++ b/java/com/cowlark/fluxengine/BUILD.bazel @@ -0,0 +1,75 @@ +load("@rules_java//java:defs.bzl", "java_binary") +load("//:jpackage.bzl", "jpackage", "jpackage_app_image") + +package(default_visibility = ["//visibility:public"]) + +java_binary( + name = "fluxengine", + jvm_flags = ["--enable-native-access=ALL-UNNAMED"], + main_class = "com.cowlark.fluxengine.cli.Main", + runtime_deps = ["//java/com/cowlark/fluxengine/cli"], +) + +jpackage( + name = "fluxengine_deb", + package_name = "fluxengine", + app_version = "1.0.0", + jar = ":fluxengine_deploy.jar", + main_class = "com.cowlark.fluxengine.cli.Main", + extra_launchers = [":fluxengine-gui.properties"], + package_type = "deb", + tags = ["manual"], +) + +jpackage( + name = "fluxengine_rpm", + package_name = "fluxengine", + app_version = "1.0.0", + jar = ":fluxengine_deploy.jar", + main_class = "com.cowlark.fluxengine.cli.Main", + extra_launchers = [":fluxengine-gui.properties"], + package_type = "rpm", + tags = ["manual"], +) + +# MSI and DMG installers can only be built on their native platforms +# (jpackage can't cross-compile), so select() picks the package type per +# platform; on other platforms it's "unsupported", which produces an empty +# target so `bazel build //java/...` still works everywhere. +jpackage( + name = "fluxengine_msi", + package_name = "fluxengine", + app_version = "1.0.0", + jar = ":fluxengine_deploy.jar", + main_class = "com.cowlark.fluxengine.cli.Main", + extra_launchers = [":fluxengine-gui.properties"], + package_type = select({ + "@platforms//os:windows": "msi", + "//conditions:default": "unsupported", + }), + tags = ["manual"], +) + +jpackage( + name = "fluxengine_dmg", + package_name = "fluxengine", + app_version = "1.0.0", + jar = ":fluxengine_deploy.jar", + main_class = "com.cowlark.fluxengine.cli.Main", + extra_launchers = [":fluxengine-gui.properties"], + package_type = select({ + "@platforms//os:osx": "dmg", + "//conditions:default": "unsupported", + }), + tags = ["manual"], +) + +jpackage_app_image( + name = "fluxengine_app_image", + package_name = "fluxengine", + app_version = "1.0.0", + jar = ":fluxengine_deploy.jar", + main_class = "com.cowlark.fluxengine.cli.Main", + extra_launchers = [":fluxengine-gui.properties"], + tags = ["manual"], +) diff --git a/java/com/cowlark/fluxengine/algorithms/BUILD.bazel b/java/com/cowlark/fluxengine/algorithms/BUILD.bazel new file mode 100644 index 000000000..2b62a0f89 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/BUILD.bazel @@ -0,0 +1,27 @@ +load("@rules_java//java:defs.bzl", "java_library") + +package(default_visibility = ["//visibility:public"]) + +java_library( + name = "algorithms", + srcs = glob(["*.java"]), + deps = [ + "//java/com/cowlark/fluxengine/arch", + "//java/com/cowlark/fluxengine/config:common_java_proto", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/decoders", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "//java/com/cowlark/fluxengine/encoders", + "//java/com/cowlark/fluxengine/fluxsink", + "//java/com/cowlark/fluxengine/fluxsource", + "//java/com/cowlark/fluxengine/imagereader", + "//java/com/cowlark/fluxengine/imagewriter", + "//java/com/cowlark/fluxengine/usb", + "@com_google_protobuf//java/core", + "@maven//:com_google_guava_guava", + "@maven//:io_reactivex_rxjava3_rxjava", + "@maven//:org_apache_commons_commons_lang3", + ], +) diff --git a/java/com/cowlark/fluxengine/algorithms/BeginOperationLogMessage.java b/java/com/cowlark/fluxengine/algorithms/BeginOperationLogMessage.java new file mode 100644 index 000000000..fa065e652 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/BeginOperationLogMessage.java @@ -0,0 +1,16 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; + +/** + * We're starting a large-scale operation, ported from + * lib/algorithms/readerwriter.cc. + */ +public record BeginOperationLogMessage(String message) implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/BeginReadOperationLogMessage.java b/java/com/cowlark/fluxengine/algorithms/BeginReadOperationLogMessage.java new file mode 100644 index 000000000..23865ae94 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/BeginReadOperationLogMessage.java @@ -0,0 +1,17 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; + +/** + * We're starting a read operation on a track, ported from + * lib/algorithms/readerwriter.cc. + */ +public record BeginReadOperationLogMessage(int track, int head) implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + r.header(String.format("R%2d.%d: ", track, head)); + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/BeginSpeedOperationLogMessage.java b/java/com/cowlark/fluxengine/algorithms/BeginSpeedOperationLogMessage.java new file mode 100644 index 000000000..5f1cfa5e7 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/BeginSpeedOperationLogMessage.java @@ -0,0 +1,17 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; + +/** + * We're starting to measure the drive's rotational speed, ported from + * lib/algorithms/readerwriter.cc. + */ +public record BeginSpeedOperationLogMessage() implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + r.newline().add("Measuring rotational speed...").newline(); + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/BeginWriteOperationLogMessage.java b/java/com/cowlark/fluxengine/algorithms/BeginWriteOperationLogMessage.java new file mode 100644 index 000000000..d3707c127 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/BeginWriteOperationLogMessage.java @@ -0,0 +1,17 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; + +/** + * We're starting a write operation on a track, ported from + * lib/algorithms/readerwriter.cc. + */ +public record BeginWriteOperationLogMessage(int track, int head) implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + r.header(String.format("W%2d.%d: ", track, head)); + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/Common.java b/java/com/cowlark/fluxengine/algorithms/Common.java new file mode 100644 index 000000000..dc4611021 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/Common.java @@ -0,0 +1,38 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.fluxsource.FluxSource; +import com.cowlark.fluxengine.fluxsource.FluxReadParameters; +import com.cowlark.fluxengine.fluxsource.FluxSourceIterator; +import java.util.HashMap; +import java.util.Map; + +class Common +{ + static void testForEmergencyStop() + { + } + + static class FluxSourceIteratorHolder + { + private final FluxSource fluxSource; + private final Map cache = new HashMap<>(); + + FluxSourceIteratorHolder(FluxSource fluxSource) + { + this.fluxSource = fluxSource; + } + + FluxSourceIterator getIterator(FluxReadParameters parameters) + { + CylinderHead key = new CylinderHead(parameters.cylinder(), parameters.head()); + FluxSourceIterator it = cache.get(key); + if (it == null) + { + it = fluxSource.readFlux(parameters); + cache.put(key, it); + } + return it; + } + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/DiskReadLogMessage.java b/java/com/cowlark/fluxengine/algorithms/DiskReadLogMessage.java new file mode 100644 index 000000000..950d2bc81 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/DiskReadLogMessage.java @@ -0,0 +1,16 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; +import com.cowlark.fluxengine.data.Disk; + +/** + * We've just read a disk, ported from lib/algorithms/readerwriter.cc. + */ +public record DiskReadLogMessage(Disk disk) implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/EndOperationLogMessage.java b/java/com/cowlark/fluxengine/algorithms/EndOperationLogMessage.java new file mode 100644 index 000000000..123dff651 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/EndOperationLogMessage.java @@ -0,0 +1,16 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; + +/** + * We've finished a large-scale operation, ported from + * lib/algorithms/readerwriter.cc. + */ +public record EndOperationLogMessage(String message) implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/EndReadOperationLogMessage.java b/java/com/cowlark/fluxengine/algorithms/EndReadOperationLogMessage.java new file mode 100644 index 000000000..310e378db --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/EndReadOperationLogMessage.java @@ -0,0 +1,16 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; + +/** + * We've finished a read operation on a track, ported from + * lib/algorithms/readerwriter.cc. + */ +public record EndReadOperationLogMessage() implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/EndSpeedOperationLogMessage.java b/java/com/cowlark/fluxengine/algorithms/EndSpeedOperationLogMessage.java new file mode 100644 index 000000000..0890c3efb --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/EndSpeedOperationLogMessage.java @@ -0,0 +1,20 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; + +/** + * We've just finished measuring the drive's rotational speed, ported from + * lib/algorithms/readerwriter.cc. + */ +public record EndSpeedOperationLogMessage(double rotationalPeriodNs) implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + r.newline().add(String.format( + "Rotational period is %.1fms (%.1frpm)", + rotationalPeriodNs / 1e6, + 60e9 / rotationalPeriodNs)).newline(); + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/EndWriteOperationLogMessage.java b/java/com/cowlark/fluxengine/algorithms/EndWriteOperationLogMessage.java new file mode 100644 index 000000000..394d08eee --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/EndWriteOperationLogMessage.java @@ -0,0 +1,16 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; + +/** + * We've finished a write operation on a track, ported from + * lib/algorithms/readerwriter.cc. + */ +public record EndWriteOperationLogMessage() implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/FluxOperation.java b/java/com/cowlark/fluxengine/algorithms/FluxOperation.java new file mode 100644 index 000000000..46335aa02 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/FluxOperation.java @@ -0,0 +1,95 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.Logger; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import io.reactivex.rxjava3.subjects.PublishSubject; +import java.util.function.Consumer; + +/** + * Runs an operation once on its own worker thread, multicasting its log + * messages to all subscribers via a {@link PublishSubject}. + */ +public abstract class FluxOperation> implements Runnable +{ + /* Serialises all operations across the whole program: only one may run at + * a time, because the hardware doesn't cope with concurrent access. */ + private static final Object lock = new Object(); + + protected ConfigProto configProto = null; + private boolean disposed = false; + + protected FluxOperation() + { + } + + public FluxOperation setConfig(ConfigProto config) + { + this.configProto = config; + return this; + } + + public ConfigProto getConfig() + { + return configProto; + } + + /* Runs the given operation on its own fresh worker thread, forwarding the + * messages it logs to all subscribers of the returned Observable. The + * factory is disposed when the returned Observable terminates, so that any + * AutoCloseable resources it holds are released. */ + public Observable create() + { + PublishSubject subject = PublishSubject.create(); + + Schedulers.newThread().scheduleDirect(() -> { + synchronized (lock) + { + Consumer oldLogger = Logger.getLogger(); + Logger.setLogger(subject::onNext); + try + { + init(); + run(); + subject.onComplete(); + } catch (Throwable t) + { + subject.onError(t); + } finally + { + Logger.setLogger(oldLogger); + } + } + }); + + return Observable.using(() -> this, op -> subject, op -> op.dispose()); + } + + /* Disposes the factory, releasing any AutoCloseable resources it holds. + * Safe to call multiple times; only the first call has any effect. */ + public void dispose() + { + boolean wasDisposed; + synchronized (this) + { + wasDisposed = disposed; + disposed = true; + } + if (!wasDisposed) + onDispose(); + } + + /* Hook for subclasses to close their AutoCloseable resources. Called at + * most once, by dispose(). */ + protected void onDispose() + { + } + + public void init() + { + } + + public abstract void run(); +} diff --git a/java/com/cowlark/fluxengine/algorithms/Operation.java b/java/com/cowlark/fluxengine/algorithms/Operation.java new file mode 100644 index 000000000..e28b467bd --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/Operation.java @@ -0,0 +1,147 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.arch.Arch; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.Logger; +import com.cowlark.fluxengine.core.SupplierOfAutocloseable; +import com.cowlark.fluxengine.data.DiskLayout; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.fluxsink.FluxSinkFactory; +import com.cowlark.fluxengine.fluxsource.FluxSource; +import com.cowlark.fluxengine.imagereader.ImageReader; +import com.cowlark.fluxengine.imagewriter.ImageWriter; +import com.cowlark.fluxengine.usb.UsbDevice; +import com.cowlark.fluxengine.usb.UsbFactory; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; + +public abstract class Operation implements AutoCloseable +{ + private final ConfigProto configProto; + private double diskRotationalPeriodNs; + private Supplier diskLayoutSupplier; + private SupplierOfAutocloseable fluxSourceSupplier; + private SupplierOfAutocloseable fluxSinkFactorySupplier; + private SupplierOfAutocloseable usbDeviceSupplier; + private Supplier decoderSupplier; + private Supplier encoderSupplier; + private SupplierOfAutocloseable imageReaderSupplier; + private SupplierOfAutocloseable imageWriterSupplier; + + public Operation(ConfigProto configProto) + { + this.configProto = configProto; + diskLayoutSupplier = Suppliers.memoize(() -> new DiskLayout(configProto)); + fluxSourceSupplier = new SupplierOfAutocloseable(() -> FluxSource.create(configProto)); + fluxSinkFactorySupplier = + new SupplierOfAutocloseable(() -> FluxSinkFactory.create(configProto)); + usbDeviceSupplier = new SupplierOfAutocloseable(() -> UsbFactory.connect(configProto)); + decoderSupplier = Suppliers.memoize(() -> Arch.createDecoder(configProto)); + encoderSupplier = Suppliers.memoize(() -> Arch.createEncoder( + configProto, getDiskRotationalPeriodNs())); + imageWriterSupplier = new SupplierOfAutocloseable(() -> ImageWriter.create(configProto)); + imageReaderSupplier = new SupplierOfAutocloseable(() -> ImageReader.create(configProto)); + } + + @Override + public void close() throws Exception + { + fluxSourceSupplier.close(); + fluxSinkFactorySupplier.close(); + usbDeviceSupplier.close(); + imageWriterSupplier.close(); + imageReaderSupplier.close(); + } + + public ConfigProto getConfig() + { + return configProto; + } + + public DiskLayout getDiskLayout() + { + return diskLayoutSupplier.get(); + } + + public FluxSource getFluxSource() + { + return fluxSourceSupplier.get(); + } + + public FluxSinkFactory getFluxSinkFactory() + { + return fluxSinkFactorySupplier.get(); + } + + public Decoder getDecoder() + { + return decoderSupplier.get(); + } + + public Encoder getEncoder() + { + return encoderSupplier.get(); + } + + public ImageReader getImageReader() + { + return imageReaderSupplier.get(); + } + + public ImageWriter getImageWriter() + { + return imageWriterSupplier.get(); + } + + public double getDiskRotationalPeriodNs() + { + if (diskRotationalPeriodNs != 0) + return diskRotationalPeriodNs; + diskRotationalPeriodNs = configProto.getDrive().getRotationalPeriodMs() * 1e6; + if (diskRotationalPeriodNs == 0) + { + UsbDevice device = UsbFactory.reconnect(configProto); + + Logger.log(new BeginOperationLogMessage("Measuring drive rotational speed")); + Logger.log(new BeginSpeedOperationLogMessage()); + + int retries = 5; + do + { + diskRotationalPeriodNs = + device.getRotationalPeriod(configProto.getDrive().getHardSectorCount()); + retries--; + } while ((diskRotationalPeriodNs == 0) && (retries > 0)); + Logger.log(new EndOperationLogMessage("")); + } + + if (diskRotationalPeriodNs == 0) + throw new FluxEngineException("Failed\nIs a disk in the drive?"); + + Logger.log(new EndSpeedOperationLogMessage(diskRotationalPeriodNs)); + return diskRotationalPeriodNs; + } + + void adjustTrackOnError(int baseTrack) + { + switch (getConfig().getDrive().getErrorBehaviour()) + { + case NOTHING: + break; + + case RECALIBRATE: + getFluxSource().recalibrate(); + break; + + case JIGGLE: + if (baseTrack > 0) + getFluxSource().seek(baseTrack - 1); + else + getFluxSource().seek(baseTrack + 1); + break; + } + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/OperationProgressLogMessage.java b/java/com/cowlark/fluxengine/algorithms/OperationProgressLogMessage.java new file mode 100644 index 000000000..7f81b3481 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/OperationProgressLogMessage.java @@ -0,0 +1,16 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; + +/** + * A large-scale operation has made progress, ported from + * lib/algorithms/readerwriter.cc. + */ +public record OperationProgressLogMessage(int progress) implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/ReadWriteFluxOperation.java b/java/com/cowlark/fluxengine/algorithms/ReadWriteFluxOperation.java new file mode 100644 index 000000000..f12f7f3ce --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/ReadWriteFluxOperation.java @@ -0,0 +1,797 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.arch.Arch; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.Logger; +import com.cowlark.fluxengine.core.SupplierOfAutocloseable; +import com.cowlark.fluxengine.core.Utils; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.Disk; +import com.cowlark.fluxengine.data.DiskLayout; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.PhysicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.data.Track; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.fluxsink.FluxSink; +import com.cowlark.fluxengine.fluxsink.FluxSinkFactory; +import com.cowlark.fluxengine.fluxsource.FluxReadParameters; +import com.cowlark.fluxengine.fluxsource.FluxSource; +import com.cowlark.fluxengine.fluxsource.FluxSourceIterator; +import com.cowlark.fluxengine.imagereader.ImageReader; +import com.cowlark.fluxengine.imagewriter.ImageWriter; +import com.cowlark.fluxengine.usb.UsbDevice; +import com.cowlark.fluxengine.usb.UsbFactory; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; + +public abstract class ReadWriteFluxOperation extends FluxOperation +{ + private double diskRotationalPeriodNs; + private Supplier diskLayoutSupplier; + private SupplierOfAutocloseable fluxSourceSupplier; + private SupplierOfAutocloseable fluxSinkFactorySupplier; + private SupplierOfAutocloseable usbDeviceSupplier; + private Supplier decoderSupplier; + private Supplier encoderSupplier; + private SupplierOfAutocloseable imageReaderSupplier; + private SupplierOfAutocloseable imageWriterSupplier; + + @Override + public void init() + { + ConfigProto configProto = getConfig(); + + diskLayoutSupplier = Suppliers.memoize(() -> new DiskLayout(configProto)); + fluxSourceSupplier = new SupplierOfAutocloseable(() -> FluxSource.create(configProto)); + fluxSinkFactorySupplier = + new SupplierOfAutocloseable(() -> FluxSinkFactory.create(configProto)); + usbDeviceSupplier = new SupplierOfAutocloseable(() -> UsbFactory.connect(configProto)); + decoderSupplier = Suppliers.memoize(() -> Arch.createDecoder(configProto)); + encoderSupplier = Suppliers.memoize(() -> Arch.createEncoder( + configProto, + getDiskRotationalPeriodNs())); + imageWriterSupplier = new SupplierOfAutocloseable(() -> ImageWriter.create(configProto)); + imageReaderSupplier = new SupplierOfAutocloseable(() -> ImageReader.create(configProto)); + } + + public DiskLayout getDiskLayout() + { + return diskLayoutSupplier.get(); + } + + public FluxSource getFluxSource() + { + return fluxSourceSupplier.get(); + } + + public FluxSinkFactory getFluxSinkFactory() + { + return fluxSinkFactorySupplier.get(); + } + + public Decoder getDecoder() + { + return decoderSupplier.get(); + } + + public Encoder getEncoder() + { + return encoderSupplier.get(); + } + + public ImageReader getImageReader() + { + return imageReaderSupplier.get(); + } + + public ImageWriter getImageWriter() + { + return imageWriterSupplier.get(); + } + + public double getDiskRotationalPeriodNs() + { + if (diskRotationalPeriodNs != 0) + return diskRotationalPeriodNs; + diskRotationalPeriodNs = configProto.getDrive().getRotationalPeriodMs() * 1e6; + if (diskRotationalPeriodNs == 0) + { + UsbDevice device = UsbFactory.reconnect(configProto); + + Logger.log(new BeginOperationLogMessage("Measuring drive rotational speed")); + Logger.log(new BeginSpeedOperationLogMessage()); + + int retries = 5; + do + { + diskRotationalPeriodNs = + device.getRotationalPeriod(configProto.getDrive().getHardSectorCount()); + retries--; + } while ((diskRotationalPeriodNs == 0) && (retries > 0)); + Logger.log(new EndOperationLogMessage("")); + } + + if (diskRotationalPeriodNs == 0) + throw new FluxEngineException("Failed\nIs a disk in the drive?"); + + Logger.log(new EndSpeedOperationLogMessage(diskRotationalPeriodNs)); + return diskRotationalPeriodNs; + } + + @Override + protected void onDispose() + { + closeResource(fluxSourceSupplier); + closeResource(fluxSinkFactorySupplier); + closeResource(usbDeviceSupplier); + closeResource(imageWriterSupplier); + closeResource(imageReaderSupplier); + } + + private void closeResource(SupplierOfAutocloseable resource) + { + if (resource != null) + { + try + { + resource.close(); + } catch (Exception e) + { + throw new RuntimeException(e); + } + } + } + + void adjustTrackOnError(int baseTrack) + { + switch (getConfig().getDrive().getErrorBehaviour()) + { + case NOTHING: + break; + + case RECALIBRATE: + getFluxSource().recalibrate(); + break; + + case JIGGLE: + if (baseTrack > 0) + getFluxSource().seek(baseTrack - 1); + else + getFluxSource().seek(baseTrack + 1); + break; + } + } + + enum ReadResult + { + GOOD_READ, BAD_AND_CAN_RETRY, BAD_AND_CAN_NOT_RETRY + } + + enum BadSectorsState + { + HAS_NO_BAD_SECTORS, HAS_BAD_SECTORS + } + + static class CombinationResult + { + BadSectorsState result; + List sectors; + } + + static class ReadGroupResult + { + ReadResult result; + List combinedSectors; + } + + static CombinationResult combineRecordAndSectors(List tracks, LogicalTrackLayout ltl) + { + CombinationResult cr = new CombinationResult(); + cr.result = BadSectorsState.HAS_NO_BAD_SECTORS; + List trackSectors = new ArrayList<>(); + + /* Add the sectors which were there. */ + + for (Track track : tracks) + trackSectors.addAll(track.allSectors); + + /* Add the sectors which should be there. */ + + for (int sectorId : ltl.diskSectorOrder) + { + Sector sector = + new Sector(new LogicalLocation(ltl.logicalCylinder, ltl.logicalHead, sectorId)); + + sector.status = Sector.Status.MISSING; + sector.physicalLocation = new CylinderHead(ltl.physicalCylinder, ltl.physicalHead); + trackSectors.add(sector); + } + + /* Deduplicate. */ + + cr.sectors = collectSectors(trackSectors); + if (cr.sectors.isEmpty()) + cr.result = BadSectorsState.HAS_BAD_SECTORS; + for (Sector sector : cr.sectors) + if (sector.status != Sector.Status.OK) + cr.result = BadSectorsState.HAS_BAD_SECTORS; + + return cr; + } + + protected ReadGroupResult readGroup(Common.FluxSourceIteratorHolder fluxSourceIteratorHolder, + LogicalTrackLayout ltl, + List tracks) + { + ReadGroupResult rgr = new ReadGroupResult(); + rgr.result = ReadResult.BAD_AND_CAN_NOT_RETRY; + + /* Before doing the read, look to see if we already have the necessary + * sectors. */ + + { + CombinationResult cr = combineRecordAndSectors(tracks, ltl); + rgr.combinedSectors = cr.sectors; + if (cr.result == BadSectorsState.HAS_NO_BAD_SECTORS) + { + /* We have all necessary sectors, so can stop here. */ + rgr.result = ReadResult.GOOD_READ; + if (getConfig().getDecoder().getSkipUnnecessaryTracks()) + return rgr; + } + } + + for (int offset = 0; offset < ltl.groupSize; offset += getDiskLayout().headWidth) + { + int physicalCylinder = ltl.physicalCylinder + offset; + int physicalHead = ltl.physicalHead; + PhysicalTrackLayout ptl = getDiskLayout().layoutByPhysicalLocation.get(new CylinderHead( + physicalCylinder, + physicalHead)); + + /* Do the physical read. */ + + Logger.log(new BeginReadOperationLogMessage(physicalCylinder, physicalHead)); + + FluxSourceIterator fluxSourceIterator = + fluxSourceIteratorHolder.getIterator(FluxReadParameters.builder() + .setCylinder(physicalCylinder) + .setHead(physicalHead) + .setSyncWithIndex(getConfig().getDrive().getSyncWithIndex()) + .setReadTimeNs(getConfig().getDrive().getRevolutions() * + getDiskRotationalPeriodNs()) + .setHardSectorThresholdNs(getConfig().getDrive() + .getHardSectorThresholdNs()) + .build()); + if (!fluxSourceIterator.hasNext()) + continue; + + Fluxmap fluxmap = fluxSourceIterator.next(); + Logger.log(new EndReadOperationLogMessage()); + Logger.logf("%d ms in %d bytes", (int) (fluxmap.durationNs() / 1e6), fluxmap.bytes()); + + Track flux = getDecoder().decodeToSectors(fluxmap, ptl); + flux.normalisedSectors = collectSectors(flux.allSectors); + tracks.add(flux); + + /* Decode what we've got so far. */ + + CombinationResult cr = combineRecordAndSectors(tracks, ltl); + rgr.combinedSectors = cr.sectors; + if (cr.result == BadSectorsState.HAS_NO_BAD_SECTORS) + { + /* We have all necessary sectors, so can stop here. */ + rgr.result = ReadResult.GOOD_READ; + if (getConfig().getDecoder().getSkipUnnecessaryTracks()) + break; + } else if (fluxSourceIterator.hasNext()) + { + /* The flux source claims it can do more reads, so mark this + * group as being retryable. */ + rgr.result = ReadResult.BAD_AND_CAN_RETRY; + } + } + + return rgr; + } + + private void readAndDecodeTrack(LogicalTrackLayout ltl, + List tracks, + List combinedSectors) + { + Common.FluxSourceIteratorHolder fluxSourceIteratorHolder = + new Common.FluxSourceIteratorHolder(getFluxSource()); + int retriesRemaining = getConfig().getDecoder().getRetries(); + for (; ; ) + { + ReadGroupResult rgr = readGroup(fluxSourceIteratorHolder, ltl, tracks); + combinedSectors.clear(); + combinedSectors.addAll(rgr.combinedSectors); + if (rgr.result == ReadResult.GOOD_READ) + break; + if (rgr.result == ReadResult.BAD_AND_CAN_NOT_RETRY) + { + Logger.logf("no more data; giving up"); + break; + } + + if (retriesRemaining == 0) + { + Logger.logf("giving up"); + break; + } + + if (getFluxSource().isHardware()) + { + adjustTrackOnError(ltl.physicalCylinder); + Logger.logf("retrying; %d retries remaining", retriesRemaining); + retriesRemaining--; + } + } + } + + /* Given a set of sectors, deduplicates them sensibly (e.g. if there is a + * good and bad version of the same sector, the bad version is dropped). */ + static List collectSectors(List trackSectors, boolean collapseConflicts) + { + Map> sectors = new LinkedHashMap<>(); + for (Sector sector : trackSectors) + sectors.computeIfAbsent(sector.location, k -> new ArrayList<>()).add(sector); + + List sectorSet = new ArrayList<>(); + for (Map.Entry> entry : sectors.entrySet()) + { + List bucket = entry.getValue(); + Sector newSector = bucket.get(0); + for (int i = 1; i < bucket.size(); i++) + { + Sector right = bucket.get(i); + if ((newSector.status == Sector.Status.OK) && (right.status == Sector.Status.OK) && + (!newSector.data.equals(right.data))) + { + if (!collapseConflicts) + { + Sector s = new Sector(right); + s.status = Sector.Status.CONFLICT; + sectorSet.add(s); + } + Sector s = new Sector(newSector); + s.status = Sector.Status.CONFLICT; + newSector = s; + continue; + } + if (newSector.status == Sector.Status.CONFLICT) + continue; + if (right.status == Sector.Status.CONFLICT) + { + newSector = right; + continue; + } + if (newSector.status == Sector.Status.OK) + continue; + if (right.status == Sector.Status.OK) + newSector = right; + } + sectorSet.add(newSector); + } + + return sectorSet; + } + + static List collectSectors(List trackSectors) + { + return collectSectors(trackSectors, true); + } + + public void readDisk(Disk disk) + { + FluxSinkFactory outputFluxSinkFactory = null; + if (getConfig().getDecoder().hasCopyFluxTo()) + outputFluxSinkFactory = + FluxSinkFactory.create(getConfig(), getConfig().getDecoder().getCopyFluxTo()); + + Map> tracksByLogicalLocation = new HashMap<>(); + for (Map.Entry entry : disk.tracksByPhysicalLocation.entries()) + { + Track track = entry.getValue(); + tracksByLogicalLocation.computeIfAbsent( + new CylinderHead(track.ltl.logicalCylinder, track.ltl.logicalHead), + k -> new ArrayList<>()).add(track); + } + + Logger.log(new BeginOperationLogMessage("Reading and decoding disk")); + + disk.rotationalPeriodNs = getDiskRotationalPeriodNs(); + + try (FluxSink outputFluxSink = outputFluxSinkFactory != null ? + outputFluxSinkFactory.create() : + null) + { + int index = 0; + for (Map.Entry entry : + getDiskLayout().layoutByLogicalLocation.entrySet()) + { + CylinderHead logicalLocation = entry.getKey(); + LogicalTrackLayout ltl = entry.getValue(); + Logger.log(new OperationProgressLogMessage( + index * 100 / getDiskLayout().layoutByLogicalLocation.size())); + index++; + + Common.testForEmergencyStop(); + + List trackFluxes = tracksByLogicalLocation.computeIfAbsent( + logicalLocation, + k -> new ArrayList<>()); + List trackSectors = new ArrayList<>(); + readAndDecodeTrack(ltl, trackFluxes, trackSectors); + + /* Replace all tracks on the disk by the new combined set. */ + + for (Track flux : trackFluxes) + disk.tracksByPhysicalLocation.removeAll(new CylinderHead( + flux.ptl.physicalCylinder, + flux.ptl.physicalHead)); + for (Track flux : trackFluxes) + disk.tracksByPhysicalLocation.put( + new CylinderHead( + flux.ptl.physicalCylinder, + flux.ptl.physicalHead), + flux); + + /* Likewise for sectors. */ + + for (Sector sector : trackSectors) + disk.sectorsByPhysicalLocation.removeAll(sector.physicalLocation); + for (Sector sector : trackSectors) + disk.sectorsByPhysicalLocation.put(sector.physicalLocation, sector); + + if (outputFluxSink != null) + { + for (Track data : trackFluxes) + outputFluxSink.addFlux( + data.ptl.physicalCylinder, + data.ptl.physicalHead, + data.fluxmap); + } + + if (getConfig().getDecoder().getDumpRecords()) + { + List sortedRecords = new ArrayList<>(); + for (Track data : trackFluxes) + sortedRecords.addAll(data.records); + sortedRecords.sort(Comparator.comparingDouble(r -> r.startTimeNs)); + + System.out.println("\nRaw (undecoded) records follow:\n"); + for (com.cowlark.fluxengine.data.Record record : sortedRecords) + { + System.out.printf( + "I+%.2fus with %.2fus clock%n", + record.startTimeNs / 1000.0, + record.clockNs / 1000.0); + Utils.hexdump(System.out, record.rawData); + System.out.println(); + } + } + + if (getConfig().getDecoder().getDumpSectors()) + { + List sectors = collectSectors(trackSectors, false); + sectors.sort(Comparator.comparing((Sector s) -> s.location.logicalCylinder()) + .thenComparing((Sector s) -> s.location.logicalHead()) + .thenComparing((Sector s) -> s.location.logicalSector())); + + System.out.println("\nDecoded sectors follow:\n"); + for (Sector sector : sectors) + { + System.out.printf( + "%d.%02d.%02d: I+%.2fus with %.2fus clock: " + "status %s%n", + sector.location.logicalCylinder(), + sector.location.logicalHead(), + sector.location.logicalSector(), + sector.headerStartTimeNs / 1000.0, + sector.clockNs / 1000.0, + Sector.statusToString(sector.status)); + Utils.hexdump(System.out, sector.data); + System.out.println(); + } + } + + /* track can't be modified below this point. */ + Logger.log(new TrackReadLogMessage(trackFluxes, trackSectors)); + + List allSectors = new ArrayList<>(); + for (Sector sector : disk.sectorsByPhysicalLocation.values()) + allSectors.add(sector); + allSectors = collectSectors(allSectors); + disk.image = new Image(allSectors); + + /* Log a _copy_ of the disk structure so that the logger + * doesn't see the disk get mutated in subsequent reads. */ + Logger.log(new DiskReadLogMessage(new Disk(disk))); + } + } + + if (disk.image == null) + disk.image = new Image(); + + Logger.log(new EndOperationLogMessage("Read complete")); + } + + public Disk readDisk() + { + Disk disk = new Disk(); + readDisk(disk); + + ImageWriter writer = getImageWriter(); + writer.printMap(disk.image); + if (getConfig().getDecoder().hasWriteCsvTo()) + writer.writeCsv(disk.image, getConfig().getDecoder().getWriteCsvTo()); + writer.writeImage(disk.image); + + return disk; + } + + private void writeTracks(Function producer, + Predicate verifier, + List logicalLocations) + { + Logger.log(new BeginOperationLogMessage("Encoding and writing to disk")); + + getDiskRotationalPeriodNs(); + try (FluxSink fluxSink = getFluxSinkFactory().create()) + { + int index = 0; + for (CylinderHead ch : logicalLocations) + { + Logger.log(new OperationProgressLogMessage( + index * 100 / logicalLocations.size())); + index++; + + Common.testForEmergencyStop(); + + LogicalTrackLayout ltl = getDiskLayout().layoutByLogicalLocation.get(ch); + int retriesRemaining = getConfig().getDecoder().getRetries(); + for (; ; ) + { + for (int offset = 0; offset < ltl.groupSize; + offset += getDiskLayout().headWidth) + { + int physicalCylinder = ltl.physicalCylinder + offset; + int physicalHead = ltl.physicalHead; + + Logger.log(new BeginWriteOperationLogMessage( + physicalCylinder, + ltl.physicalHead)); + + boolean erase = false; + if (offset == getConfig().getDrive().getGroupOffset()) + { + Fluxmap fluxmap = producer.apply(ltl); + if (fluxmap == null) + erase = true; + else + { + fluxSink.addFlux(physicalCylinder, physicalHead, fluxmap); + Logger.logf( + "writing %d ms in %d bytes", + (int) (fluxmap.durationNs() / 1e6), + fluxmap.bytes()); + } + } else + erase = true; + + if (erase) + { + /* Erase this track rather than writing. */ + + Fluxmap blank = new Fluxmap(); + fluxSink.addFlux(physicalCylinder, physicalHead, blank); + Logger.logf("erased"); + } + + Logger.log(new EndWriteOperationLogMessage()); + } + + if (verifier.test(ltl)) + break; + + if (retriesRemaining == 0) + throw new FluxEngineException("fatal error on write"); + + Logger.logf("retrying; %d retries remaining", retriesRemaining); + retriesRemaining--; + } + } + } + + Logger.log(new EndOperationLogMessage("Write complete")); + } + + public void rawWrite() + { + writeTracks( + ltl -> { + FluxSourceIterator iterator = + getFluxSource().readFlux(FluxReadParameters.builder() + .setCylinder(ltl.physicalCylinder) + .setHead(ltl.physicalHead) + .build()); + if (!iterator.hasNext()) + return null; + return iterator.next(); + }, ltl -> true, getDiskLayout().logicalLocations); + } + + private void writeTracks(Function producer, + Predicate verifier, + ImmutableSet logicalLocations) + { + Logger.log(new BeginOperationLogMessage("Encoding and writing to disk")); + + getDiskRotationalPeriodNs(); + try (FluxSink fluxSink = getFluxSinkFactory().create()) + { + int index = 0; + for (CylinderHead ch : logicalLocations) + { + Logger.log(new OperationProgressLogMessage( + index * 100 / logicalLocations.size())); + index++; + + Common.testForEmergencyStop(); + + LogicalTrackLayout ltl = getDiskLayout().layoutByLogicalLocation.get(ch); + int retriesRemaining = getConfig().getDecoder().getRetries(); + for (; ; ) + { + for (int offset = 0; offset < ltl.groupSize; + offset += getDiskLayout().headWidth) + { + int physicalCylinder = ltl.physicalCylinder + offset; + int physicalHead = ltl.physicalHead; + + Logger.log(new BeginWriteOperationLogMessage( + physicalCylinder, + ltl.physicalHead)); + + boolean erase = false; + if (offset == getConfig().getDrive().getGroupOffset()) + { + Fluxmap fluxmap = producer.apply(ltl); + if (fluxmap == null) + erase = true; + else + { + fluxSink.addFlux(physicalCylinder, physicalHead, fluxmap); + Logger.logf( + "writing %d ms in %d bytes", + (int) (fluxmap.durationNs() / 1e6), + fluxmap.bytes()); + } + } else + erase = true; + + if (erase) + { + /* Erase this track rather than writing. */ + + Fluxmap blank = new Fluxmap(); + fluxSink.addFlux(physicalCylinder, physicalHead, blank); + Logger.logf("erased"); + } + + Logger.log(new EndWriteOperationLogMessage()); + } + + if (verifier.test(ltl)) + break; + + if (retriesRemaining == 0) + throw new FluxEngineException("fatal error on write"); + + Logger.logf("retrying; %d retries remaining", retriesRemaining); + retriesRemaining--; + } + } + } + + Logger.log(new EndOperationLogMessage("Write complete")); + } + + private void writeTracks(Image image, ImmutableSet chs) + { + writeTracks( + ltl -> { + ImmutableList sectors = getEncoder().collectSectors(ltl, image); + return getEncoder().encode(ltl, sectors, image); + }, ltl -> true, chs); + } + + private void writeTracksAndVerify(Image image, ImmutableSet chs) + { + writeTracks( + ltl -> { + List sectors = getEncoder().collectSectors(ltl, image); + return getEncoder().encode(ltl, sectors, image); + }, ltl -> { + Common.FluxSourceIteratorHolder fluxSourceIteratorHolder = + new Common.FluxSourceIteratorHolder(getFluxSource()); + List tracks = new ArrayList<>(); + ReadGroupResult rgr = readGroup(fluxSourceIteratorHolder, ltl, tracks); + + if (rgr.result != ReadResult.GOOD_READ) + { + adjustTrackOnError(ltl.physicalCylinder); + Logger.logf("bad read"); + return false; + } + + Image wanted = new Image(); + for (Sector sector : getEncoder().collectSectors(ltl, image)) + wanted.put( + sector.location.logicalCylinder(), + sector.location.logicalHead(), + sector.location.logicalSector()).data = sector.data; + + for (Sector sector : rgr.combinedSectors) + { + Sector s = wanted.get( + sector.location.logicalCylinder(), + sector.location.logicalHead(), + sector.location.logicalSector()); + if (s == null) + { + Logger.logf("spurious sector on verify"); + return false; + } + if (!s.data.equals(sector.data.slice(0, s.data.size()))) + { + Logger.logf("data mismatch on verify"); + return false; + } + wanted.erase( + sector.location.logicalCylinder(), + sector.location.logicalHead(), + sector.location.logicalSector()); + } + if (!wanted.empty()) + { + Logger.logf("missing sector on verify"); + return false; + } + return true; + }, chs); + } + + public void writeDisk(Image image, Collection physicalLocations) + { + ImmutableSet chs = getDiskLayout().layoutByLogicalLocation.keySet(); + if (getConfig().getVerifyWrites()) + writeTracksAndVerify(image, chs); + else + writeTracks(image, chs); + } + + public void writeDisk(Image image) + { + writeDisk(image, getDiskLayout().layoutByLogicalLocation.keySet()); + } +} diff --git a/java/com/cowlark/fluxengine/algorithms/TrackReadLogMessage.java b/java/com/cowlark/fluxengine/algorithms/TrackReadLogMessage.java new file mode 100644 index 000000000..fd22dbad9 --- /dev/null +++ b/java/com/cowlark/fluxengine/algorithms/TrackReadLogMessage.java @@ -0,0 +1,65 @@ +package com.cowlark.fluxengine.algorithms; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; +import com.cowlark.fluxengine.data.Record; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.data.Track; +import java.util.Collections; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; + +/** + * We've just read a track (we might reread it if there are errors), ported + * from lib/algorithms/readerwriter.cc. + */ +public record TrackReadLogMessage(List tracks, List sectors) implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + Set rawSectors = new HashSet<>(); + Set rawRecords = new HashSet<>(); + for (Track track : tracks) + { + rawSectors.addAll(track.allSectors); + rawRecords.addAll(track.records); + } + + double clock = 0; + for (Sector sector : rawSectors) + clock += sector.clockNs; + if (!rawSectors.isEmpty()) + clock /= rawSectors.size(); + + r.comma() + .add(String.format( + "%d raw records, %d raw sectors", + rawRecords.size(), + rawSectors.size())); + if (clock != 0) + r.comma() + .add(String.format( + "%.2fus clock (%.0fkHz)", + clock / 1000.0, + 1000000.0 / clock)); + + r.newline().add("sectors:"); + + for (Sector sector : rawSectors) + r.add(String.format( + "%d.%d.%d%s", + sector.location.logicalCylinder(), + sector.location.logicalHead(), + sector.location.logicalSector(), + Sector.statusToChar(sector.status))); + + int size = 0; + for (Sector sector : sectors) + size += sector.data.size(); + + r.newline().add(String.format("%d bytes decoded\n", size)); + } +} diff --git a/java/com/cowlark/fluxengine/arch/Arch.java b/java/com/cowlark/fluxengine/arch/Arch.java new file mode 100644 index 000000000..6475a2485 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/Arch.java @@ -0,0 +1,143 @@ +package com.cowlark.fluxengine.arch; + +import com.cowlark.fluxengine.arch.aeslanier.AesLanierDecoder; +import com.cowlark.fluxengine.arch.agat.AgatDecoder; +import com.cowlark.fluxengine.arch.agat.AgatEncoder; +import com.cowlark.fluxengine.arch.amiga.AmigaDecoder; +import com.cowlark.fluxengine.arch.amiga.AmigaEncoder; +import com.cowlark.fluxengine.arch.apple2.Apple2Decoder; +import com.cowlark.fluxengine.arch.apple2.Apple2Encoder; +import com.cowlark.fluxengine.arch.brother.BrotherDecoder; +import com.cowlark.fluxengine.arch.brother.BrotherEncoder; +import com.cowlark.fluxengine.arch.c64.Commodore64Decoder; +import com.cowlark.fluxengine.arch.c64.Commodore64Encoder; +import com.cowlark.fluxengine.arch.f85.DurangoF85Decoder; +import com.cowlark.fluxengine.arch.fb100.Fb100Decoder; +import com.cowlark.fluxengine.arch.ibm.IbmDecoder; +import com.cowlark.fluxengine.arch.ibm.IbmEncoder; +import com.cowlark.fluxengine.arch.macintosh.MacintoshDecoder; +import com.cowlark.fluxengine.arch.macintosh.MacintoshEncoder; +import com.cowlark.fluxengine.arch.micropolis.MicropolisDecoder; +import com.cowlark.fluxengine.arch.micropolis.MicropolisEncoder; +import com.cowlark.fluxengine.arch.mx.MxDecoder; +import com.cowlark.fluxengine.arch.northstar.NorthstarDecoder; +import com.cowlark.fluxengine.arch.northstar.NorthstarEncoder; +import com.cowlark.fluxengine.arch.rolandd20.RolandD20Decoder; +import com.cowlark.fluxengine.arch.smaky6.Smaky6Decoder; +import com.cowlark.fluxengine.arch.tartu.TartuDecoder; +import com.cowlark.fluxengine.arch.tartu.TartuEncoder; +import com.cowlark.fluxengine.arch.tids990.Tids990Decoder; +import com.cowlark.fluxengine.arch.tids990.Tids990Encoder; +import com.cowlark.fluxengine.arch.victor9k.Victor9kDecoder; +import com.cowlark.fluxengine.arch.victor9k.Victor9kEncoder; +import com.cowlark.fluxengine.arch.zilogmcz.ZilogMczDecoder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.encoders.Encoder; + +/** + * The Arch class, ported from arch/arch.{h,cc}. + */ +public final class Arch +{ + private Arch() + { + } + + public static Decoder createDecoder(ConfigProto config) + { + if (!config.hasDecoder()) + throw new FluxEngineException("no decoder configured"); + return createDecoder(config.getDecoder()); + } + + public static Decoder createDecoder(DecoderProto config) + { + switch (config.getFormatCase()) + { + case AGAT: + return new AgatDecoder(config); + case AESLANIER: + return new AesLanierDecoder(config); + case AMIGA: + return new AmigaDecoder(config); + case APPLE2: + return new Apple2Decoder(config); + case BROTHER: + return new BrotherDecoder(config); + case C64: + return new Commodore64Decoder(config); + case F85: + return new DurangoF85Decoder(config); + case FB100: + return new Fb100Decoder(config); + case IBM: + return new IbmDecoder(config); + case MACINTOSH: + return new MacintoshDecoder(config); + case MICROPOLIS: + return new MicropolisDecoder(config); + case MX: + return new MxDecoder(config); + case NORTHSTAR: + return new NorthstarDecoder(config); + case ROLANDD20: + return new RolandD20Decoder(config); + case SMAKY6: + return new Smaky6Decoder(config); + case TARTU: + return new TartuDecoder(config); + case TIDS990: + return new Tids990Decoder(config); + case VICTOR9K: + return new Victor9kDecoder(config); + case ZILOGMCZ: + return new ZilogMczDecoder(config); + default: + throw new FluxEngineException("no decoder specified"); + } + } + + public static Encoder createEncoder(ConfigProto config) + { + return createEncoder(config, config.getDrive().getRotationalPeriodMs() * 1e6); + } + + public static Encoder createEncoder(ConfigProto config, double diskRotationalPeriodNs) + { + if (!config.hasEncoder()) + throw new FluxEngineException("no encoder configured"); + + switch (config.getEncoder().getFormatCase()) + { + case AGAT: + return new AgatEncoder(config, diskRotationalPeriodNs); + case AMIGA: + return new AmigaEncoder(config, diskRotationalPeriodNs); + case APPLE2: + return new Apple2Encoder(config, diskRotationalPeriodNs); + case BROTHER: + return new BrotherEncoder(config, diskRotationalPeriodNs); + case C64: + return new Commodore64Encoder(config, diskRotationalPeriodNs); + case IBM: + return new IbmEncoder(config, diskRotationalPeriodNs); + case MACINTOSH: + return new MacintoshEncoder(config, diskRotationalPeriodNs); + case MICROPOLIS: + return new MicropolisEncoder(config, diskRotationalPeriodNs); + case NORTHSTAR: + return new NorthstarEncoder(config, diskRotationalPeriodNs); + case TARTU: + return new TartuEncoder(config, diskRotationalPeriodNs); + case TIDS990: + return new Tids990Encoder(config, diskRotationalPeriodNs); + case VICTOR9K: + return new Victor9kEncoder(config, diskRotationalPeriodNs); + default: + throw new FluxEngineException("no encoder specified"); + } + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/BUILD.bazel b/java/com/cowlark/fluxengine/arch/BUILD.bazel new file mode 100644 index 000000000..32e137131 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/BUILD.bazel @@ -0,0 +1,36 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "arch_proto", + srcs = glob(["*/*.proto"]), + strip_import_prefix = "/java/", + deps = ["//java/com/cowlark/fluxengine/config:common_proto"], +) + +java_proto_library( + name = "arch_java_proto", + deps = [":arch_proto"], +) + +java_library( + name = "arch", + srcs = glob([ + "*.java", + "*/*.java", + ]), + deps = [ + ":arch_java_proto", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/decoders", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "//java/com/cowlark/fluxengine/encoders", + "//java/com/cowlark/fluxengine/encoders:encoders_java_proto", + "//java/com/cowlark/fluxengine/external", + ], +) diff --git a/java/com/cowlark/fluxengine/arch/aeslanier/AesLanier.java b/java/com/cowlark/fluxengine/arch/aeslanier/AesLanier.java new file mode 100644 index 000000000..df3a13bf8 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/aeslanier/AesLanier.java @@ -0,0 +1,15 @@ +package com.cowlark.fluxengine.arch.aeslanier; + +/** + * Constants for the AES Lanier format, ported from arch/aeslanier/aeslanier.h. + */ +public final class AesLanier +{ + public static final int AESLANIER_RECORD_SEPARATOR = 0x55555122; + public static final int AESLANIER_SECTOR_LENGTH = 256; + public static final int AESLANIER_RECORD_SIZE = AESLANIER_SECTOR_LENGTH + 5; + + private AesLanier() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/aeslanier/AesLanierDecoder.java b/java/com/cowlark/fluxengine/arch/aeslanier/AesLanierDecoder.java new file mode 100644 index 000000000..c2df766af --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/aeslanier/AesLanierDecoder.java @@ -0,0 +1,71 @@ +package com.cowlark.fluxengine.arch.aeslanier; + +import static com.cowlark.fluxengine.arch.aeslanier.AesLanier.AESLANIER_RECORD_SEPARATOR; +import static com.cowlark.fluxengine.arch.aeslanier.AesLanier.AESLANIER_RECORD_SIZE; +import static com.cowlark.fluxengine.arch.aeslanier.AesLanier.AESLANIER_SECTOR_LENGTH; +import static com.cowlark.fluxengine.external.Crc.MODBUS_POLY_REF; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.Crc; +import com.cowlark.fluxengine.external.FmMfm; + +/** + * The AES Lanier decoder, ported from arch/aeslanier/decoder.cc. + */ +public class AesLanierDecoder extends Decoder +{ + private static final FluxPattern SECTOR_PATTERN = + new FluxPattern(32, AESLANIER_RECORD_SEPARATOR); + + public AesLanierDecoder(DecoderProto config) + { + super(config); + } + + /* This is actually M2FM, rather than MFM, but our MFM/FM decoder copes fine + * with it. */ + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(SECTOR_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + /* Skip ID mark (we know it's a AESLANIER_RECORD_SEPARATOR). */ + + readRawBits(16); + + Bits rawbits = readRawBits(AESLANIER_RECORD_SIZE * 16); + Bytes bytes = FmMfm.decodeFmMfm(rawbits).slice(0, AESLANIER_RECORD_SIZE); + Bytes reversed = bytes.reverseBits(); + + sector.location = + new LogicalLocation(reversed.getByte(1) & 0xff, 0, reversed.getByte(2) & 0xff); + + /* Check header 'checksum' (which seems far too simple to mean much). */ + + { + int wanted = reversed.getByte(3) & 0xff; + int got = ((reversed.getByte(1) & 0xff) + (reversed.getByte(2) & 0xff)) & 0xff; + if (wanted != got) + return; + } + + /* Check data checksum, which also includes the header and is + * significantly better. */ + + sector.data = reversed.slice(1, AESLANIER_SECTOR_LENGTH); + int wanted = reversed.iterator().seek(0x101).readLe16(); + int got = Crc.crc16ref(MODBUS_POLY_REF, sector.data); + sector.status = (wanted == got) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/arch/aeslanier/aeslanier.h b/java/com/cowlark/fluxengine/arch/aeslanier/aeslanier.h similarity index 100% rename from arch/aeslanier/aeslanier.h rename to java/com/cowlark/fluxengine/arch/aeslanier/aeslanier.h diff --git a/java/com/cowlark/fluxengine/arch/aeslanier/aeslanier.proto b/java/com/cowlark/fluxengine/arch/aeslanier/aeslanier.proto new file mode 100644 index 000000000..fb4dcb7dd --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/aeslanier/aeslanier.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.aeslanier"; +option java_multiple_files = true; + +message AesLanierDecoderProto {} + diff --git a/arch/aeslanier/decoder.cc b/java/com/cowlark/fluxengine/arch/aeslanier/decoder.cc similarity index 100% rename from arch/aeslanier/decoder.cc rename to java/com/cowlark/fluxengine/arch/aeslanier/decoder.cc diff --git a/java/com/cowlark/fluxengine/arch/agat/Agat.java b/java/com/cowlark/fluxengine/arch/agat/Agat.java new file mode 100644 index 000000000..8744af3ff --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/agat/Agat.java @@ -0,0 +1,37 @@ +package com.cowlark.fluxengine.arch.agat; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; + +/** + * Constants and helpers for the Agat format, ported from + * arch/agat/agat.h and arch/agat/agat.cc. + */ +public final class Agat +{ + public static final int AGAT_SECTOR_SIZE = 256; + + public static final long SECTOR_ID = 0x8924555549111444L; + public static final long DATA_ID = 0x8924555514444911L; + + private Agat() + { + } + + public static int agatChecksum(Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + int checksum = 0; + + while (!br.eof()) + { + int b = br.read8(); + if (checksum > 0xff) + checksum = (checksum + 1) & 0xff; + + checksum += b; + } + + return checksum & 0xff; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/agat/AgatDecoder.java b/java/com/cowlark/fluxengine/arch/agat/AgatDecoder.java new file mode 100644 index 000000000..9d3ef99e9 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/agat/AgatDecoder.java @@ -0,0 +1,95 @@ +package com.cowlark.fluxengine.arch.agat; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.FmMfm; + +/** + * The Agat decoder, ported from arch/agat/decoder.cc. + */ +public class AgatDecoder extends Decoder +{ + /* + * data: X X X X X X X X X - - X - X - X - X X - X - X - = + * 0xff956a + * flux: 01 01 01 01 01 01 01 01 01 00 10 01 00 01 00 01 00 01 01 00 01 00 01 00 = + * 0x555549111444 + * + * data: X X X X X X X X - X X - X - X - X - - X - X - X = + * 0xff6a95 + * flux: 01 01 01 01 01 01 01 01 00 01 01 00 01 00 01 00 01 00 10 01 00 01 00 01 = + * 0x555514444911 + * + * Each pattern is prefixed with this one: + * + * data: - - - X - - X - = 0x12 + * flux: (10) 10 10 10 01 00 10 01 00 = 0xa924 + * magic: (10) 10 00 10 01 00 10 01 00 = 0x8924 + * ^ + * + * This seems to be generated by emitting A4 in MFM and then a single 0 bit + * to shift it out of phase, so the data bits become clock bits and vice + * versa. + * + * X - X - - X - - = 0xA4 + * 0100010010010010 = MFM encoded + * 1000100100100100 = with trailing zero + * - - - X - - X - = effective bitstream = 0x12 + */ + private static final FluxPattern SECTOR_PATTERN = new FluxPattern(64, Agat.SECTOR_ID); + private static final FluxPattern DATA_PATTERN = new FluxPattern(64, Agat.DATA_ID); + + private static final FluxMatchers ALL_PATTERNS = FluxMatchers.of(SECTOR_PATTERN, DATA_PATTERN); + + public AgatDecoder(DecoderProto config) + { + super(config); + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(ALL_PATTERNS); + } + + @Override + protected void decodeSectorRecord() + { + if (readRaw64() != Agat.SECTOR_ID) + return; + + Bytes bytes = FmMfm.decodeFmMfm(readRawBits(64)).slice(0, 4); + if (bytes.getByte(3) != 0x5a) + return; + + int logicalCylinder = (bytes.getByte(1) & 0xff) >> 1; + int logicalSector = bytes.getByte(2) & 0xff; + int logicalHead = bytes.getByte(1) & 1; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + sector.status = Sector.Status.DATA_MISSING; + } + + @Override + protected void decodeDataRecord() + { + if (readRaw64() != Agat.DATA_ID) + return; + + Bytes bytes = FmMfm.decodeFmMfm(readRawBits((Agat.AGAT_SECTOR_SIZE + 2) * 16)) + .slice(0, Agat.AGAT_SECTOR_SIZE + 2); + + if (bytes.getByte(Agat.AGAT_SECTOR_SIZE + 1) != 0x5a) + return; + + sector.data = bytes.slice(0, Agat.AGAT_SECTOR_SIZE); + int wantChecksum = bytes.getByte(Agat.AGAT_SECTOR_SIZE) & 0xff; + int gotChecksum = Agat.agatChecksum(sector.data); + sector.status = + (wantChecksum == gotChecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/agat/AgatEncoder.java b/java/com/cowlark/fluxengine/arch/agat/AgatEncoder.java new file mode 100644 index 000000000..5dd0bca7b --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/agat/AgatEncoder.java @@ -0,0 +1,114 @@ +package com.cowlark.fluxengine.arch.agat; + +import com.cowlark.fluxengine.agat.AgatEncoderProto; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.external.FmMfm; +import java.util.List; + +/** + * The Agat encoder, ported from arch/agat/encoder.cc. + */ +public class AgatEncoder extends Encoder +{ + private final AgatEncoderProto config; + private final boolean[] lastBit = new boolean[1]; + private Bits bits; + private Bits.Cursor cursor; + + public AgatEncoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getAgat(); + } + + private void writeRawBits(long data, int width) + { + cursor.advance(width); + lastBit[0] = (data & 1) != 0; + for (int i = 0; i < width; i++) + { + int pos = cursor.get() - i - 1; + if (pos < bits.size()) + bits.setBit(pos, (data & 1) != 0); + data >>= 1; + } + } + + private void writeBytes(Bytes bytes) + { + FmMfm.encodeMfm(bits, cursor, bytes, lastBit); + } + + private void writeByte(int byte_) + { + Bytes b = new Bytes(1); + b.writer().write8(byte_); + writeBytes(b); + } + + private void writeFillerRawBytes(int count, int byte_) + { + for (int i = 0; i < count; i++) + writeRawBits(byte_, 16); + } + + private void writeFillerBytes(int count, int byte_) + { + Bytes b = new Bytes(1); + b.writer().write8(byte_); + for (int i = 0; i < count; i++) + writeBytes(b); + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + double clockRateUs = config.getTargetClockPeriodUs() / 2.0; + int bitsPerRevolution = + (int) ((config.getTargetRotationalPeriodMs() * 1000.0) / clockRateUs); + bits = new Bits(bitsPerRevolution); + cursor = new Bits.Cursor(0); + + writeFillerRawBytes(config.getPostIndexGapBytes(), 0xaaaa); + + for (Sector sector : sectors) + { + /* Header */ + + writeFillerRawBytes(config.getPreSectorGapBytes(), 0xaaaa); + writeRawBits(Agat.SECTOR_ID, 64); + writeByte(0x5a); + writeByte((sector.location.logicalCylinder() << 1) | sector.location.logicalHead()); + writeByte(sector.location.logicalSector()); + writeByte(0x5a); + + /* Data */ + + writeFillerRawBytes(config.getPreDataGapBytes(), 0xaaaa); + Bytes data = sector.data.slice(0, Agat.AGAT_SECTOR_SIZE); + writeRawBits(Agat.DATA_ID, 64); + writeBytes(data); + writeByte(Agat.agatChecksum(data)); + writeByte(0x5a); + } + + if (cursor.get() >= bits.size()) + throw new FluxEngineException("track data overrun"); + bits.fillBitmapTo(cursor, bits.size(), new boolean[]{true, false}); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits( + bits, (long) calculatePhysicalClockPeriodNs( + config.getTargetClockPeriodUs() * 1e3, + config.getTargetRotationalPeriodMs() * 1e6)); + return fluxmap; + } +} diff --git a/arch/agat/agat.cc b/java/com/cowlark/fluxengine/arch/agat/agat.cc similarity index 100% rename from arch/agat/agat.cc rename to java/com/cowlark/fluxengine/arch/agat/agat.cc diff --git a/arch/agat/agat.h b/java/com/cowlark/fluxengine/arch/agat/agat.h similarity index 100% rename from arch/agat/agat.h rename to java/com/cowlark/fluxengine/arch/agat/agat.h diff --git a/java/com/cowlark/fluxengine/arch/agat/agat.proto b/java/com/cowlark/fluxengine/arch/agat/agat.proto new file mode 100644 index 000000000..26f9c812c --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/agat/agat.proto @@ -0,0 +1,22 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.agat"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message AgatDecoderProto {} + +message AgatEncoderProto { + optional double target_clock_period_us = 1 + [default = 2.00, (help) = "Data clock period of target format."]; + optional double target_rotational_period_ms = 2 + [default = 200.0, (help) = "Rotational period of target format."]; + optional int32 post_index_gap_bytes = 3 + [default = 40, (help) = "Post-index gap before first sector header."]; + optional int32 pre_sector_gap_bytes = 4 + [default = 11, (help) = "Gap before each sector header."]; + optional int32 pre_data_gap_bytes = 5 + [default = 2, (help) = "Gap before each sector data record."]; +} + diff --git a/arch/agat/decoder.cc b/java/com/cowlark/fluxengine/arch/agat/decoder.cc similarity index 100% rename from arch/agat/decoder.cc rename to java/com/cowlark/fluxengine/arch/agat/decoder.cc diff --git a/arch/agat/encoder.cc b/java/com/cowlark/fluxengine/arch/agat/encoder.cc similarity index 100% rename from arch/agat/encoder.cc rename to java/com/cowlark/fluxengine/arch/agat/encoder.cc diff --git a/java/com/cowlark/fluxengine/arch/amiga/Amiga.java b/java/com/cowlark/fluxengine/arch/amiga/Amiga.java new file mode 100644 index 000000000..46bebd3b3 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/amiga/Amiga.java @@ -0,0 +1,119 @@ +package com.cowlark.fluxengine.arch.amiga; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; + +/** + * Constants and helpers for the Amiga format, ported from arch/amiga/amiga.h + * and arch/amiga/amiga.cc. + */ +public final class Amiga +{ + public static final long AMIGA_SECTOR_RECORD = 0xaaaa44894489L; + + public static final int AMIGA_TRACKS_PER_DISK = 80; + public static final int AMIGA_SECTORS_PER_TRACK = 11; + public static final int AMIGA_RECORD_SIZE = 0x21c; + + private Amiga() + { + } + + public static int amigaChecksum(Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + int checksum = 0; + + while (!br.eof()) + checksum ^= br.readBe32(); + + return checksum & 0x55555555; + } + + private static int everyother(int x) + { + /* aabb ccdd eeff gghh */ + x &= 0x6666; /* 0ab0 0cd0 0ef0 0gh0 */ + x >>= 1; /* 00ab 00cd 00ef 00gh */ + x |= x << 2; /* abab cdcd efef ghgh */ + x &= 0x3c3c; /* 00ab cd00 00ef gh00 */ + x >>= 2; /* 0000 abcd 0000 efgh */ + x |= x >> 4; /* 0000 abcd abcd efgh */ + return x; + } + + public static Bytes amigaInterleave(Bytes input) + { + Bytes output = new Bytes(); + ByteWriter bw = new ByteWriter(output); + + /* Write all odd bits. (Numbering starts at 0...) */ + + { + ByteReader br = new ByteReader(input); + while (!br.eof()) + { + int x = br.readBe16(); + x &= 0xaaaa; /* a0b0 c0d0 e0f0 g0h0 */ + x |= x >> 1; /* aabb ccdd eeff gghh */ + x = everyother(x); /* 0000 0000 abcd efgh */ + bw.write8(x); + } + } + + /* Write all even bits. */ + + { + ByteReader br = new ByteReader(input); + while (!br.eof()) + { + int x = br.readBe16(); + x &= 0x5555; /* 0a0b 0c0d 0e0f 0g0h */ + x |= x << 1; /* aabb ccdd eeff gghh */ + x = everyother(x); /* 0000 0000 abcd efgh */ + bw.write8(x); + } + } + + return output; + } + + /* Deinterleaves `len` bytes starting at `index[0]` within `input`, + * advancing `index[0]` by `len`. Mirrors the pointer-advancing C++ + * amigaDeinterleave(). */ + public static Bytes amigaDeinterleave(Bytes input, int[] index, int len) + { + int start = index[0]; + int odds = start; + int evens = start + len / 2; + Bytes output = new Bytes(); + ByteWriter bw = new ByteWriter(output); + + for (int i = 0; i < len / 2; i++) + { + int o = input.getByte(odds++) & 0xff; + int e = input.getByte(evens++) & 0xff; + + /* This is the 'Interleave bits with 64-bit multiply' technique + * from + * http://graphics.stanford.edu/~seander/bithacks.html#InterleaveBMN + */ + long result = + ((((e * 0x0101010101010101L) & 0x8040201008040201L) * 0x0102040810204081L >>> + 49) & 0x5555) | ((((o * 0x0101010101010101L) & 0x8040201008040201L) * + 0x0102040810204081L >>> 48) & 0xAAAA); + + bw.writeBe16((int) result); + } + + index[0] += len; + return output; + } + + public static Bytes amigaDeinterleave(Bytes input) + { + int[] index = {0}; + return amigaDeinterleave(input, index, input.size()); + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/amiga/AmigaDecoder.java b/java/com/cowlark/fluxengine/arch/amiga/AmigaDecoder.java new file mode 100644 index 000000000..f9250b544 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/amiga/AmigaDecoder.java @@ -0,0 +1,75 @@ +package com.cowlark.fluxengine.arch.amiga; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.FmMfm; + +/** + * The Amiga decoder, ported from arch/amiga/decoder.cc. + */ +public class AmigaDecoder extends Decoder +{ + /* + * Amiga disks use MFM but it's not quite the same as IBM MFM. They only + * use a single type of record with a different marker byte. + * + * See the big comment in the IBM MFM decoder for the gruesome details of + * how MFM works. + */ + private static final FluxPattern SECTOR_PATTERN = + new FluxPattern(48, Amiga.AMIGA_SECTOR_RECORD); + + public AmigaDecoder(DecoderProto config) + { + super(config); + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(SECTOR_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + if (readRaw48() != Amiga.AMIGA_SECTOR_RECORD) + return; + + Bits rawbits = readRawBits(Amiga.AMIGA_RECORD_SIZE * 16); + if (rawbits.size() < (Amiga.AMIGA_RECORD_SIZE * 16)) + return; + Bytes rawbytes = rawbits.toBytes().slice(0, Amiga.AMIGA_RECORD_SIZE * 2); + Bytes bytes = FmMfm.decodeFmMfm(rawbits).slice(0, Amiga.AMIGA_RECORD_SIZE); + + int[] index = {0}; + + Bytes header = Amiga.amigaDeinterleave(bytes, index, 4); + Bytes recoveryinfo = Amiga.amigaDeinterleave(bytes, index, 16); + + int logicalCylinder = (header.getByte(1) & 0xff) >> 1; + int logicalHead = header.getByte(1) & 1; + int logicalSector = header.getByte(2) & 0xff; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + + int wantedheaderchecksum = Amiga.amigaDeinterleave(bytes, index, 4).iterator().readBe32(); + int gotheaderchecksum = Amiga.amigaChecksum(rawbytes.slice(0, 40)); + if (gotheaderchecksum != wantedheaderchecksum) + return; + + int wanteddatachecksum = Amiga.amigaDeinterleave(bytes, index, 4).iterator().readBe32(); + int gotdatachecksum = Amiga.amigaChecksum(rawbytes.slice(56, 1024)); + + Bytes data = new Bytes(); + data.writer().write(Amiga.amigaDeinterleave(bytes, index, 512)).write(recoveryinfo); + sector.data = data; + sector.status = (gotdatachecksum == wanteddatachecksum) ? + Sector.Status.OK : + Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/amiga/AmigaEncoder.java b/java/com/cowlark/fluxengine/arch/amiga/AmigaEncoder.java new file mode 100644 index 000000000..18bbc7364 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/amiga/AmigaEncoder.java @@ -0,0 +1,144 @@ +package com.cowlark.fluxengine.arch.amiga; + +import com.cowlark.fluxengine.amiga.AmigaEncoderProto; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.external.FmMfm; +import java.util.List; + +/** + * The Amiga encoder, ported from arch/amiga/encoder.cc. + */ +public class AmigaEncoder extends Encoder +{ + private final AmigaEncoderProto config; + private final boolean[] lastBit = new boolean[1]; + + public AmigaEncoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getAmiga(); + } + + private void writeBits(Bits bits, Bits.Cursor cursor, boolean[] src) + { + for (boolean bit : src) + { + if (cursor.get() < bits.size()) + { + lastBit[0] = bit; + bits.setBit(cursor.get(), bit); + cursor.advance(); + } + } + } + + private void writeBits(Bits bits, Bits.Cursor cursor, long data, int width) + { + cursor.advance(width); + lastBit[0] = (data & 1) != 0; + for (int i = 0; i < width; i++) + { + int pos = cursor.get() - i - 1; + if (pos < bits.size()) + bits.setBit(pos, (data & 1) != 0); + data >>= 1; + } + } + + private void writeBits(Bits bits, Bits.Cursor cursor, Bytes bytes) + { + Bits bitr = bytes.toBits(); + for (int i = 0; i < bitr.size(); i++) + { + if (cursor.get() < bits.size()) + { + bits.setBit(cursor.get(), bitr.getBit(i)); + cursor.advance(); + } + } + } + + private void writeInterleavedBytes(Bits bits, Bits.Cursor cursor, Bytes bytes, int[] checksum) + { + Bytes interleaved = Amiga.amigaInterleave(bytes); + Bytes mfm = FmMfm.encodeMfm(interleaved, lastBit); + checksum[0] ^= Amiga.amigaChecksum(mfm); + checksum[0] &= 0x55555555; + writeBits(bits, cursor, mfm); + } + + private void writeInterleavedWord(Bits bits, Bits.Cursor cursor, int word, int[] checksum) + { + Bytes b = new Bytes(4); + b.writer().writeBe32(word); + writeInterleavedBytes(bits, cursor, b, checksum); + } + + private void writeSector(Bits bits, Bits.Cursor cursor, Sector sector) + { + if ((sector.data.size() != 512) && (sector.data.size() != 528)) + throw new FluxEngineException("unsupported sector size --- you must pick 512 or 528"); + + int[] checksum = {0}; + + writeBits(bits, cursor, 0xaaaa, 2 * 8); + writeBits(bits, cursor, Amiga.AMIGA_SECTOR_RECORD, 6 * 8); + + Bytes header = Bytes.of( + 0xff, /* Amiga 1.0 format byte */ + (sector.location.logicalCylinder() << 1) | sector.location.logicalHead(), + sector.location.logicalSector(), + Amiga.AMIGA_SECTORS_PER_TRACK - sector.location.logicalSector()); + writeInterleavedBytes(bits, cursor, header, checksum); + Bytes recoveryInfo = new Bytes(16); + if (sector.data.size() == 528) + recoveryInfo = sector.data.slice(512, 16); + writeInterleavedBytes(bits, cursor, recoveryInfo, checksum); + writeInterleavedWord(bits, cursor, checksum[0], checksum); + + Bytes data = sector.data.slice(0, 512); + writeInterleavedWord( + bits, + cursor, + Amiga.amigaChecksum(FmMfm.encodeMfm(Amiga.amigaInterleave(data), lastBit)), + checksum); + writeInterleavedBytes(bits, cursor, data, checksum); + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + /* Number of bits for one nominal revolution of a real 200ms Amiga + * disk. */ + int bitsPerRevolution = (int) (200e3 / config.getClockRateUs()); + Bits bits = new Bits(bitsPerRevolution); + Bits.Cursor cursor = new Bits.Cursor(0); + + bits.fillBitmapTo( + cursor, + (int) (config.getPostIndexGapMs() * 1000 / config.getClockRateUs()), + new boolean[]{true, false}); + lastBit[0] = false; + + for (Sector sector : sectors) + writeSector(bits, cursor, sector); + + if (cursor.get() >= bits.size()) + throw new FluxEngineException("track data overrun"); + bits.fillBitmapTo(cursor, bits.size(), new boolean[]{true, false}); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits( + bits, + (long) calculatePhysicalClockPeriodNs(config.getClockRateUs() * 1e3, 200e6)); + return fluxmap; + } +} diff --git a/arch/amiga/amiga.cc b/java/com/cowlark/fluxengine/arch/amiga/amiga.cc similarity index 100% rename from arch/amiga/amiga.cc rename to java/com/cowlark/fluxengine/arch/amiga/amiga.cc diff --git a/arch/amiga/amiga.h b/java/com/cowlark/fluxengine/arch/amiga/amiga.h similarity index 100% rename from arch/amiga/amiga.h rename to java/com/cowlark/fluxengine/arch/amiga/amiga.h diff --git a/java/com/cowlark/fluxengine/arch/amiga/amiga.proto b/java/com/cowlark/fluxengine/arch/amiga/amiga.proto new file mode 100644 index 000000000..be6023e83 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/amiga/amiga.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.amiga"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message AmigaDecoderProto {} + +message AmigaEncoderProto { + optional double clock_rate_us = 1 + [default = 2.00, (help) = "Encoded data clock rate."]; + optional double post_index_gap_ms = 2 + [default = 0.5, (help) = "Post-index gap before first sector header."]; +} + diff --git a/arch/amiga/decoder.cc b/java/com/cowlark/fluxengine/arch/amiga/decoder.cc similarity index 100% rename from arch/amiga/decoder.cc rename to java/com/cowlark/fluxengine/arch/amiga/decoder.cc diff --git a/arch/amiga/encoder.cc b/java/com/cowlark/fluxengine/arch/amiga/encoder.cc similarity index 100% rename from arch/amiga/encoder.cc rename to java/com/cowlark/fluxengine/arch/amiga/encoder.cc diff --git a/java/com/cowlark/fluxengine/arch/apple2/Apple2.java b/java/com/cowlark/fluxengine/arch/apple2/Apple2.java new file mode 100644 index 000000000..1d001290a --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/apple2/Apple2.java @@ -0,0 +1,19 @@ +package com.cowlark.fluxengine.arch.apple2; + +/** + * Constants for the Apple II format, ported from arch/apple2/apple2.h. + */ +public final class Apple2 +{ + public static final int APPLE2_SECTOR_RECORD = 0xd5aa96; + public static final int APPLE2_DATA_RECORD = 0xd5aaad; + + public static final int APPLE2_SECTOR_LENGTH = 256; + public static final int APPLE2_ENCODED_SECTOR_LENGTH = 342; + + public static final int APPLE2_SECTORS = 16; + + private Apple2() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/apple2/Apple2Decoder.java b/java/com/cowlark/fluxengine/arch/apple2/Apple2Decoder.java new file mode 100644 index 000000000..4043779ca --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/apple2/Apple2Decoder.java @@ -0,0 +1,303 @@ +package com.cowlark.fluxengine.arch.apple2; + +import com.cowlark.fluxengine.apple2.Apple2DecoderProto; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; + +/** + * The Apple II decoder, ported from arch/apple2/decoder.cc. + */ +public class Apple2Decoder extends Decoder +{ + private static final FluxPattern SECTOR_RECORD_PATTERN = + new FluxPattern(24, Apple2.APPLE2_SECTOR_RECORD); + private static final FluxPattern DATA_RECORD_PATTERN = + new FluxPattern(24, Apple2.APPLE2_DATA_RECORD); + private static final FluxMatchers ANY_RECORD_PATTERN = + FluxMatchers.of(SECTOR_RECORD_PATTERN, DATA_RECORD_PATTERN); + private final Apple2DecoderProto config; + + public Apple2Decoder(DecoderProto config) + { + super(config); + this.config = config.getApple2(); + } + + private static int decodeDataGcr(int gcr) + { + switch (gcr) + { + case 0x96: + return 0x00; + case 0x97: + return 0x01; + case 0x9a: + return 0x02; + case 0x9b: + return 0x03; + case 0x9d: + return 0x04; + case 0x9e: + return 0x05; + case 0x9f: + return 0x06; + case 0xa6: + return 0x07; + case 0xa7: + return 0x08; + case 0xab: + return 0x09; + case 0xac: + return 0x0a; + case 0xad: + return 0x0b; + case 0xae: + return 0x0c; + case 0xaf: + return 0x0d; + case 0xb2: + return 0x0e; + case 0xb3: + return 0x0f; + case 0xb4: + return 0x10; + case 0xb5: + return 0x11; + case 0xb6: + return 0x12; + case 0xb7: + return 0x13; + case 0xb9: + return 0x14; + case 0xba: + return 0x15; + case 0xbb: + return 0x16; + case 0xbc: + return 0x17; + case 0xbd: + return 0x18; + case 0xbe: + return 0x19; + case 0xbf: + return 0x1a; + case 0xcb: + return 0x1b; + case 0xcd: + return 0x1c; + case 0xce: + return 0x1d; + case 0xcf: + return 0x1e; + case 0xd3: + return 0x1f; + case 0xd6: + return 0x20; + case 0xd7: + return 0x21; + case 0xd9: + return 0x22; + case 0xda: + return 0x23; + case 0xdb: + return 0x24; + case 0xdc: + return 0x25; + case 0xdd: + return 0x26; + case 0xde: + return 0x27; + case 0xdf: + return 0x28; + case 0xe5: + return 0x29; + case 0xe6: + return 0x2a; + case 0xe7: + return 0x2b; + case 0xe9: + return 0x2c; + case 0xea: + return 0x2d; + case 0xeb: + return 0x2e; + case 0xec: + return 0x2f; + case 0xed: + return 0x30; + case 0xee: + return 0x31; + case 0xef: + return 0x32; + case 0xf2: + return 0x33; + case 0xf3: + return 0x34; + case 0xf4: + return 0x35; + case 0xf5: + return 0x36; + case 0xf6: + return 0x37; + case 0xf7: + return 0x38; + case 0xf9: + return 0x39; + case 0xfa: + return 0x3a; + case 0xfb: + return 0x3b; + case 0xfc: + return 0x3c; + case 0xfd: + return 0x3d; + case 0xfe: + return 0x3e; + case 0xff: + return 0x3f; + default: + return -1; + } + } + + private static int combine(int word) + { + return (word & (word >> 7)) & 0xff; + } + + /* This is extremely inspired by the MESS implementation, written by Nathan + * Woods and R. Belmont: + * https://github.com/mamedev/mame/blob/7914a6083a3b3a8c243ae6c3b8cb50b023f21e0e/src/lib + * /formats/ap2_dsk.cpp + */ + private static Bytes decodeCrazyData(Bytes input, Sector.Status[] status) + { + Bytes output = new Bytes(Apple2.APPLE2_SECTOR_LENGTH); + + int checksum = 0; + for (int i = 0; i < Apple2.APPLE2_ENCODED_SECTOR_LENGTH; i++) + { + checksum ^= decodeDataGcr(input.getByte(i) & 0xff); + + if (i >= 86) + { + /* 6 bit */ + output.setByte(i - 86, (byte) (output.getByte(i - 86) | (checksum << 2))); + } else + { + /* 3 * 2 bit */ + output.setByte(i, (byte) (((checksum >> 1) & 0x01) | ((checksum << 1) & 0x02))); + output.setByte( + i + 86, + (byte) (((checksum >> 3) & 0x01) | ((checksum >> 1) & 0x02))); + if ((i + 172) < Apple2.APPLE2_SECTOR_LENGTH) + output.setByte( + i + 172, + (byte) (((checksum >> 5) & 0x01) | ((checksum >> 3) & 0x02))); + } + } + + checksum &= 0x3f; + int wantedchecksum = + decodeDataGcr(input.getByte(Apple2.APPLE2_ENCODED_SECTOR_LENGTH) & 0xff); + status[0] = (checksum == wantedchecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + return output; + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(ANY_RECORD_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + if (readRaw24() != Apple2.APPLE2_SECTOR_RECORD) + return; + + /* Read header. */ + + Bytes header = readRawBits(8 * 8).toBytes().slice(0, 8); + ByteReader br = header.iterator(); + + int volume = combine(br.readBe16()); + int logicalCylinder = combine(br.readBe16()); + int logicalHead = ltl.logicalHead; + int logicalSector = combine(br.readBe16()); + int checksum = combine(br.readBe16()); + + /* If the checksum is correct, upgrade the sector from MISSING to + * DATA_MISSING in anticipation of its data record. */ + if (checksum == (volume ^ logicalCylinder ^ logicalSector)) + sector.status = Sector.Status.DATA_MISSING; + + if (logicalHead == 1) + logicalCylinder -= config.getSideOneTrackOffset(); + + /* Sanity check. */ + + if (logicalCylinder > 100) + { + sector.status = Sector.Status.MISSING; + return; + } + + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + } + + @Override + protected void decodeDataRecord() + { + /* Check ID. */ + + if (readRaw24() != Apple2.APPLE2_DATA_RECORD) + return; + + /* Read and decode data. */ + + /* Sometimes there's a 1-bit gap between APPLE2_DATA_RECORD and the + * data itself. This has been seen on real world disks such as the + * Apple II Operating System Kit from Apple2Online. However, I haven't + * seen it described in any of the various references. + * + * This extra '0' bit would not affect the real disk interface, as it + * was a '1' reaching the top bit of a shift register that triggered a + * byte to be available, but it affects the way the data is read here. + * + * While the floppies tested only seemed to need this applied to the + * first byte of the data record, applying it consistently to all of + * them doesn't seem to hurt, and simplifies the code. + */ + + int recordLength = Apple2.APPLE2_ENCODED_SECTOR_LENGTH + 2; + Bytes bytes = new Bytes(recordLength); + for (int i = 0; i < recordLength; i++) + { + int result = 0; + while ((result & 0x80) == 0) + { + Bits b = readRawBits(1); + if (b.size() == 0) + break; + result = (result << 1) | (b.getBit(0) ? 1 : 0); + } + bytes.setByte(i, (byte) result); + } + + /* Upgrade the sector from MISSING to BAD_CHECKSUM. If + * decodeCrazyData succeeds, it upgrades the sector to OK. */ + + sector.status = Sector.Status.BAD_CHECKSUM; + Sector.Status[] status = {sector.status}; + sector.data = decodeCrazyData(bytes, status); + sector.status = status[0]; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/apple2/Apple2Encoder.java b/java/com/cowlark/fluxengine/arch/apple2/Apple2Encoder.java new file mode 100644 index 000000000..178fcb9b0 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/apple2/Apple2Encoder.java @@ -0,0 +1,243 @@ +package com.cowlark.fluxengine.arch.apple2; + +import com.cowlark.fluxengine.apple2.Apple2EncoderProto; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import java.util.List; + +/** + * The Apple II encoder, ported from arch/apple2/encoder.cc. + */ +public class Apple2Encoder extends Encoder +{ + private static final int[] ENCODE_DATA_GCR = new int[64]; + + static + { + ENCODE_DATA_GCR[0x00] = 0x96; + ENCODE_DATA_GCR[0x01] = 0x97; + ENCODE_DATA_GCR[0x02] = 0x9a; + ENCODE_DATA_GCR[0x03] = 0x9b; + ENCODE_DATA_GCR[0x04] = 0x9d; + ENCODE_DATA_GCR[0x05] = 0x9e; + ENCODE_DATA_GCR[0x06] = 0x9f; + ENCODE_DATA_GCR[0x07] = 0xa6; + ENCODE_DATA_GCR[0x08] = 0xa7; + ENCODE_DATA_GCR[0x09] = 0xab; + ENCODE_DATA_GCR[0x0a] = 0xac; + ENCODE_DATA_GCR[0x0b] = 0xad; + ENCODE_DATA_GCR[0x0c] = 0xae; + ENCODE_DATA_GCR[0x0d] = 0xaf; + ENCODE_DATA_GCR[0x0e] = 0xb2; + ENCODE_DATA_GCR[0x0f] = 0xb3; + ENCODE_DATA_GCR[0x10] = 0xb4; + ENCODE_DATA_GCR[0x11] = 0xb5; + ENCODE_DATA_GCR[0x12] = 0xb6; + ENCODE_DATA_GCR[0x13] = 0xb7; + ENCODE_DATA_GCR[0x14] = 0xb9; + ENCODE_DATA_GCR[0x15] = 0xba; + ENCODE_DATA_GCR[0x16] = 0xbb; + ENCODE_DATA_GCR[0x17] = 0xbc; + ENCODE_DATA_GCR[0x18] = 0xbd; + ENCODE_DATA_GCR[0x19] = 0xbe; + ENCODE_DATA_GCR[0x1a] = 0xbf; + ENCODE_DATA_GCR[0x1b] = 0xcb; + ENCODE_DATA_GCR[0x1c] = 0xcd; + ENCODE_DATA_GCR[0x1d] = 0xce; + ENCODE_DATA_GCR[0x1e] = 0xcf; + ENCODE_DATA_GCR[0x1f] = 0xd3; + ENCODE_DATA_GCR[0x20] = 0xd6; + ENCODE_DATA_GCR[0x21] = 0xd7; + ENCODE_DATA_GCR[0x22] = 0xd9; + ENCODE_DATA_GCR[0x23] = 0xda; + ENCODE_DATA_GCR[0x24] = 0xdb; + ENCODE_DATA_GCR[0x25] = 0xdc; + ENCODE_DATA_GCR[0x26] = 0xdd; + ENCODE_DATA_GCR[0x27] = 0xde; + ENCODE_DATA_GCR[0x28] = 0xdf; + ENCODE_DATA_GCR[0x29] = 0xe5; + ENCODE_DATA_GCR[0x2a] = 0xe6; + ENCODE_DATA_GCR[0x2b] = 0xe7; + ENCODE_DATA_GCR[0x2c] = 0xe9; + ENCODE_DATA_GCR[0x2d] = 0xea; + ENCODE_DATA_GCR[0x2e] = 0xeb; + ENCODE_DATA_GCR[0x2f] = 0xec; + ENCODE_DATA_GCR[0x30] = 0xed; + ENCODE_DATA_GCR[0x31] = 0xee; + ENCODE_DATA_GCR[0x32] = 0xef; + ENCODE_DATA_GCR[0x33] = 0xf2; + ENCODE_DATA_GCR[0x34] = 0xf3; + ENCODE_DATA_GCR[0x35] = 0xf4; + ENCODE_DATA_GCR[0x36] = 0xf5; + ENCODE_DATA_GCR[0x37] = 0xf6; + ENCODE_DATA_GCR[0x38] = 0xf7; + ENCODE_DATA_GCR[0x39] = 0xf9; + ENCODE_DATA_GCR[0x3a] = 0xfa; + ENCODE_DATA_GCR[0x3b] = 0xfb; + ENCODE_DATA_GCR[0x3c] = 0xfc; + ENCODE_DATA_GCR[0x3d] = 0xfd; + ENCODE_DATA_GCR[0x3e] = 0xfe; + ENCODE_DATA_GCR[0x3f] = 0xff; + } + + private final Apple2EncoderProto config; + private int volumeId = 254; + + public Apple2Encoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getApple2(); + } + + private static int encodeDataGcr(int data) + { + if (data < 0 || data >= ENCODE_DATA_GCR.length) + return -1; + return ENCODE_DATA_GCR[data]; + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + int bitsPerRevolution = + (int) ((config.getRotationalPeriodMs() * 1e3) / config.getClockPeriodUs()); + + Bits bits = new Bits(bitsPerRevolution); + Bits.Cursor cursor = new Bits.Cursor(0); + + for (Sector sector : sectors) + writeSector(bits, cursor, sector); + + if (cursor.get() >= bits.size()) + throw new FluxEngineException( + "track data overrun by " + (cursor.get() - bits.size()) + " bits"); + bits.fillBitmapTo(cursor, bits.size(), new boolean[]{true, false}); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits( + bits, + (long) calculatePhysicalClockPeriodNs( + config.getClockPeriodUs() * 1e3, + config.getRotationalPeriodMs() * 1e6)); + return fluxmap; + } + + /* This is extremely inspired by the MESS implementation, written by Nathan + * Woods and R. Belmont: + * https://github.com/mamedev/mame/blob/7914a6083a3b3a8c243ae6c3b8cb50b023f21e0e/src/lib + * /formats/ap2_dsk.cpp + * as well as Understanding the Apple II (1983) Chapter 9 + * https://archive.org/details/Understanding_the_Apple_II_1983_Quality_Software/page/n230 + * /mode/1up?view=theater + */ + + private void writeSector(Bits bits, Bits.Cursor cursor, Sector sector) + { + if ((sector.status == Sector.Status.OK) || (sector.status == Sector.Status.BAD_CHECKSUM)) + { + // The special "FF40" sequence is used to synchronize the receiving + // shift register. It's written as "1111 1111 00"; FF indicates the + // 8 consecutive 1-bits, while "40" indicates the total number of + // microseconds. + // There is data to encode to disk. + if ((sector.data.size() != Apple2.APPLE2_SECTOR_LENGTH)) + throw new FluxEngineException( + "unsupported sector size " + sector.data.size() + " --- you must pick 256"); + + // Write address syncing leader : A sequence of "FF40"s; 5 of them + // are said to suffice to synchronize the decoder. + // "FF40" indicates that the actual data written is "1111 + // 1111 00" i.e., 8 1s and a total of 40 microseconds + // + // In standard formatting, the first logical sector apparently gets + // extra padding. + writeFf40(bits, cursor, sector.location.logicalSector() == 0 ? 32 : 8); + + int track = sector.location.logicalCylinder(); + if (sector.location.logicalHead() == 1) + track += config.getSideOneTrackOffset(); + + // Write address field: APPLE2_SECTOR_RECORD + sector identifier + + // DE AA EB + writeBits(bits, cursor, Apple2.APPLE2_SECTOR_RECORD, 24); + writeGcr44(bits, cursor, volumeId); + writeGcr44(bits, cursor, track); + writeGcr44(bits, cursor, sector.location.logicalSector()); + writeGcr44(bits, cursor, volumeId ^ track ^ sector.location.logicalSector()); + writeBits(bits, cursor, 0xDEAAEB, 24); + + // Write data syncing leader: FF40 + APPLE2_DATA_RECORD + sector + // data + sum + DE AA EB (+ mystery bits cut off of the scan?) + writeFf40(bits, cursor, 8); + writeBits(bits, cursor, Apple2.APPLE2_DATA_RECORD, 24); + + // Convert the sector data to GCR, append the checksum, and write it + // out + final int TWOBIT_COUNT = 0x56; // Size of the 'twobit' area at the start of the GCR data + int checksum = 0; + for (int i = 0; i < Apple2.APPLE2_ENCODED_SECTOR_LENGTH; i++) + { + int value; + if (i >= TWOBIT_COUNT) + { + value = sector.data.getByte(i - TWOBIT_COUNT) >> 2; + } else + { + int tmp = sector.data.getByte(i); + value = ((tmp & 1) << 1) | ((tmp & 2) >> 1); + + tmp = sector.data.getByte(i + TWOBIT_COUNT); + value |= ((tmp & 1) << 3) | ((tmp & 2) << 1); + + if (i + 2 * TWOBIT_COUNT < Apple2.APPLE2_SECTOR_LENGTH) + { + tmp = sector.data.getByte(i + 2 * TWOBIT_COUNT); + value |= ((tmp & 1) << 5) | ((tmp & 2) << 3); + } + } + checksum ^= value; + writeGcr6(bits, cursor, checksum); + checksum = value; + } + if (sector.status == Sector.Status.BAD_CHECKSUM) + checksum ^= 0x3f; + writeGcr6(bits, cursor, checksum); + writeBits(bits, cursor, 0xDEAAEB, 24); + } + } + + private void writeBit(Bits bits, Bits.Cursor cursor, boolean val) + { + if (cursor.get() < bits.size()) + bits.setBit(cursor.get(), val); + cursor.advance(); + } + + private void writeBits(Bits bits, Bits.Cursor cursor, int data, int width) + { + for (int i = width; i-- != 0; ) + writeBit(bits, cursor, (data & (1 << i)) != 0); + } + + private void writeGcr44(Bits bits, Bits.Cursor cursor, int value) + { + writeBits(bits, cursor, (value << 7) | value | 0xaaaa, 16); + } + + private void writeGcr6(Bits bits, Bits.Cursor cursor, int value) + { + writeBits(bits, cursor, encodeDataGcr(value), 8); + } + + private void writeFf40(Bits bits, Bits.Cursor cursor, int n) + { + for (; n-- != 0; ) + writeBits(bits, cursor, 0xff << 2, 10); + } +} diff --git a/arch/apple2/apple2.h b/java/com/cowlark/fluxengine/arch/apple2/apple2.h similarity index 100% rename from arch/apple2/apple2.h rename to java/com/cowlark/fluxengine/arch/apple2/apple2.h diff --git a/java/com/cowlark/fluxengine/arch/apple2/apple2.proto b/java/com/cowlark/fluxengine/arch/apple2/apple2.proto new file mode 100644 index 000000000..51ff3a14a --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/apple2/apple2.proto @@ -0,0 +1,25 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.apple2"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message Apple2DecoderProto { + optional uint32 side_one_track_offset = 1 + [default = 0, (help) = "offset to apply to track numbers on side 1"]; +} + +message Apple2EncoderProto +{ + /* 245kHz. */ + optional double clock_period_us = 1 + [default = 4, (help) = "clock rate on the real device"]; + + /* Apple II disk drives spin at 300rpm. */ + optional double rotational_period_ms = 2 + [default = 200.0, (help) = "rotational period on the real device"]; + + optional uint32 side_one_track_offset = 3 + [default = 0, (help) = "offset to apply to track numbers on side 1"]; +} diff --git a/arch/apple2/data_gcr.h b/java/com/cowlark/fluxengine/arch/apple2/data_gcr.h similarity index 100% rename from arch/apple2/data_gcr.h rename to java/com/cowlark/fluxengine/arch/apple2/data_gcr.h diff --git a/arch/apple2/decoder.cc b/java/com/cowlark/fluxengine/arch/apple2/decoder.cc similarity index 100% rename from arch/apple2/decoder.cc rename to java/com/cowlark/fluxengine/arch/apple2/decoder.cc diff --git a/arch/apple2/encoder.cc b/java/com/cowlark/fluxengine/arch/apple2/encoder.cc similarity index 100% rename from arch/apple2/encoder.cc rename to java/com/cowlark/fluxengine/arch/apple2/encoder.cc diff --git a/arch/arch.cc b/java/com/cowlark/fluxengine/arch/arch.cc similarity index 100% rename from arch/arch.cc rename to java/com/cowlark/fluxengine/arch/arch.cc diff --git a/arch/arch.h b/java/com/cowlark/fluxengine/arch/arch.h similarity index 100% rename from arch/arch.h rename to java/com/cowlark/fluxengine/arch/arch.h diff --git a/java/com/cowlark/fluxengine/arch/brother/Brother.java b/java/com/cowlark/fluxengine/arch/brother/Brother.java new file mode 100644 index 000000000..42ffb7179 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/brother/Brother.java @@ -0,0 +1,22 @@ +package com.cowlark.fluxengine.arch.brother; + +/** + * Constants for the Brother word processor format (or at least, one of them), + * ported from arch/brother/brother.h. + */ +public final class Brother +{ + public static final int BROTHER_SECTOR_RECORD = 0xFFFFFD57; + public static final int BROTHER_DATA_RECORD = 0xFFFFFDDB; + public static final int BROTHER_DATA_RECORD_PAYLOAD = 256; + public static final int BROTHER_DATA_RECORD_CHECKSUM = 3; + public static final int BROTHER_DATA_RECORD_ENCODED_SIZE = 415; + + public static final int BROTHER_TRACKS_PER_240KB_DISK = 78; + public static final int BROTHER_TRACKS_PER_120KB_DISK = 39; + public static final int BROTHER_SECTORS_PER_TRACK = 12; + + private Brother() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/brother/BrotherDecoder.java b/java/com/cowlark/fluxengine/arch/brother/BrotherDecoder.java new file mode 100644 index 000000000..e81281d85 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/brother/BrotherDecoder.java @@ -0,0 +1,338 @@ +package com.cowlark.fluxengine.arch.brother; + +import com.cowlark.fluxengine.core.BitWriter; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.Crc; + +/** + * The Brother word processor decoder, ported from arch/brother/decoder.cc. + */ +public class BrotherDecoder extends Decoder +{ + private static final FluxPattern SECTOR_RECORD_PATTERN = + new FluxPattern(32, Brother.BROTHER_SECTOR_RECORD); + private static final FluxPattern DATA_RECORD_PATTERN = + new FluxPattern(32, Brother.BROTHER_DATA_RECORD); + private static final FluxMatchers ANY_RECORD_PATTERN = + FluxMatchers.of(SECTOR_RECORD_PATTERN, DATA_RECORD_PATTERN); + + /* + * Brother disks have this very very non-IBM system where sector header + * records and data records use two different kinds of GCR: sector headers + * are 8-in-16 (but the encodable values range from 0 to 77ish only) and + * data headers are 5-in-8. In addition, there's a non-encoded 10-bit ID + * word at the beginning of each record, as well as a string of 53 1s + * introducing them. That does at least make them easy to find. + * + * Disk formats vary from machine to machine, but mine uses 78 tracks. + * Track 0 is erased but not formatted. Track alignment is extremely + * dubious and Brother track 0 shows up on my machine at track 2. + */ + + public BrotherDecoder(DecoderProto config) + { + super(config); + } + + private static int decodeDataGcr(int gcr) + { + switch (gcr) + { + case 0x55: + return 0; + case 0x57: + return 1; + case 0x5b: + return 2; + case 0x5d: + return 3; + case 0x5f: + return 4; + case 0x6b: + return 5; + case 0x6d: + return 6; + case 0x6f: + return 7; + case 0x75: + return 8; + case 0x77: + return 9; + case 0x7b: + return 10; + case 0x7d: + return 11; + case 0x7f: + return 12; + case 0xab: + return 13; + case 0xad: + return 14; + case 0xaf: + return 15; + case 0xb5: + return 16; + case 0xb7: + return 17; + case 0xbb: + return 18; + case 0xbd: + return 19; + case 0xbf: + return 20; + case 0xd5: + return 21; + case 0xd7: + return 22; + case 0xdb: + return 23; + case 0xdd: + return 24; + case 0xdf: + return 25; + case 0xeb: + return 26; + case 0xed: + return 27; + case 0xef: + return 28; + case 0xf5: + return 29; + case 0xf7: + return 30; + case 0xfb: + return 31; + default: + return -1; + } + } + + private static int decodeHeaderGcr(int word) + { + switch (word) + { + case 0xDFB5: + return 0; + case 0x5B6F: + return 1; + case 0x7DF7: + return 2; + case 0xBFD5: + return 3; + case 0xF57F: + return 4; + case 0x6D5D: + return 5; + case 0xAFEB: + return 6; + case 0xDDB7: + return 7; + case 0x5775: + return 8; + case 0x7BFB: + return 9; + case 0xBDD7: + return 10; + case 0xEFAB: + return 11; + case 0x6B5F: + return 12; + case 0xADED: + return 13; + case 0xDBBB: + return 14; + case 0x5577: + return 15; + case 0x77DB: + return 16; + case 0xBBAD: + return 17; + case 0xED6B: + return 18; + case 0x5FEF: + return 19; + case 0xABBD: + return 20; + case 0xD77B: + return 21; + case 0xFB57: + return 22; + case 0x75DD: + return 23; + case 0xB7AF: + return 24; + case 0xEB6D: + return 25; + case 0x5DF5: + return 26; + case 0x7FBF: + return 27; + case 0xD57D: + return 28; + case 0xF75B: + return 29; + case 0x6FDF: + return 30; + case 0xB5B5: + return 31; + case 0xDF6F: + return 32; + case 0x5BF7: + return 33; + case 0x7DD5: + return 34; + case 0xBF7F: + return 35; + case 0xF55D: + return 36; + case 0x6DEB: + return 37; + case 0xAFB7: + return 38; + case 0xDD75: + return 39; + case 0x57FB: + return 40; + case 0x7BD7: + return 41; + case 0xBDAB: + return 42; + case 0xEF5F: + return 43; + case 0x6BED: + return 44; + case 0xADBB: + return 45; + case 0xDB77: + return 46; + case 0xBB55: + return 47; + case 0xEDDB: + return 48; + case 0x5FAD: + return 49; + case 0xAB6B: + return 50; + case 0xD7EF: + return 51; + case 0xFBBD: + return 52; + case 0x757B: + return 53; + case 0xB757: + return 54; + case 0xEBDD: + return 55; + case 0x5DAF: + return 56; + case 0x7F6D: + return 57; + case 0xD5F5: + return 58; + case 0xF7BF: + return 59; + case 0x6F7D: + return 60; + case 0xB55B: + return 61; + case 0xDFDF: + return 62; + case 0x5BB5: + return 63; + case 0x7D6F: + return 64; + case 0xBFF7: + return 65; + case 0xF5D5: + return 66; + case 0x6D7F: + return 67; + case 0xAF5D: + return 68; + case 0xDDEB: + return 69; + case 0x57B7: + return 70; + case 0x7B75: + return 71; + case 0xBDFB: + return 72; + case 0xEFD7: + return 73; + case 0x6BAB: + return 74; + case 0xAD5F: + return 75; + case 0xDBED: + return 76; + case 0x55BB: + return 77; + default: + return -1; + } + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(ANY_RECORD_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + if (readRaw32() != Brother.BROTHER_SECTOR_RECORD) + return; + + Bits rawbits = readRawBits(32); + Bytes bytes = rawbits.toBytes().slice(0, 4); + + ByteReader br = bytes.iterator(); + int logicalCylinder = decodeHeaderGcr(br.readBe16()); + int logicalSector = decodeHeaderGcr(br.readBe16()); + + /* Sanity check the values read; there's no header checksum and + * occasionally we get garbage due to bit errors. */ + if (logicalSector > 11) + return; + if (logicalCylinder > 79) + return; + + sector.location = new LogicalLocation(logicalCylinder, 0, logicalSector); + sector.status = Sector.Status.DATA_MISSING; + } + + @Override + protected void decodeDataRecord() + { + if (readRaw32() != Brother.BROTHER_DATA_RECORD) + return; + + Bits rawbits = readRawBits(Brother.BROTHER_DATA_RECORD_ENCODED_SIZE * 8); + Bytes rawbytes = rawbits.toBytes().slice(0, Brother.BROTHER_DATA_RECORD_ENCODED_SIZE); + + Bytes bytes = new Bytes(); + ByteWriter bw = new ByteWriter(bytes); + BitWriter bitw = new BitWriter(bw); + for (int i = 0; i < rawbytes.size(); i++) + { + int nibble = decodeDataGcr(rawbytes.getByte(i) & 0xff); + bitw.push(nibble, 5); + } + bitw.flush(); + + sector.data = bytes.slice(0, Brother.BROTHER_DATA_RECORD_PAYLOAD); + int realCrc = Crc.crcbrother(sector.data); + int wantCrc = bytes.iterator().seek(Brother.BROTHER_DATA_RECORD_PAYLOAD).readBe24(); + sector.status = (realCrc == wantCrc) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/brother/BrotherEncoder.java b/java/com/cowlark/fluxengine/arch/brother/BrotherEncoder.java new file mode 100644 index 000000000..acfb195df --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/brother/BrotherEncoder.java @@ -0,0 +1,257 @@ +package com.cowlark.fluxengine.arch.brother; + +import com.cowlark.fluxengine.brother.BrotherEncoderProto; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.external.Crc; +import java.util.List; + +/** + * The Brother encoder, ported from arch/brother/encoder.cc. + */ +public class BrotherEncoder extends Encoder +{ + private static final int[] ENCODE_HEADER_GCR = new int[78]; + private static final int[] ENCODE_DATA_GCR = new int[32]; + + static + { + ENCODE_HEADER_GCR[0] = 0xDFB5; + ENCODE_HEADER_GCR[1] = 0x5B6F; + ENCODE_HEADER_GCR[2] = 0x7DF7; + ENCODE_HEADER_GCR[3] = 0xBFD5; + ENCODE_HEADER_GCR[4] = 0xF57F; + ENCODE_HEADER_GCR[5] = 0x6D5D; + ENCODE_HEADER_GCR[6] = 0xAFEB; + ENCODE_HEADER_GCR[7] = 0xDDB7; + ENCODE_HEADER_GCR[8] = 0x5775; + ENCODE_HEADER_GCR[9] = 0x7BFB; + ENCODE_HEADER_GCR[10] = 0xBDD7; + ENCODE_HEADER_GCR[11] = 0xEFAB; + ENCODE_HEADER_GCR[12] = 0x6B5F; + ENCODE_HEADER_GCR[13] = 0xADED; + ENCODE_HEADER_GCR[14] = 0xDBBB; + ENCODE_HEADER_GCR[15] = 0x5577; + ENCODE_HEADER_GCR[16] = 0x77DB; + ENCODE_HEADER_GCR[17] = 0xBBAD; + ENCODE_HEADER_GCR[18] = 0xED6B; + ENCODE_HEADER_GCR[19] = 0x5FEF; + ENCODE_HEADER_GCR[20] = 0xABBD; + ENCODE_HEADER_GCR[21] = 0xD77B; + ENCODE_HEADER_GCR[22] = 0xFB57; + ENCODE_HEADER_GCR[23] = 0x75DD; + ENCODE_HEADER_GCR[24] = 0xB7AF; + ENCODE_HEADER_GCR[25] = 0xEB6D; + ENCODE_HEADER_GCR[26] = 0x5DF5; + ENCODE_HEADER_GCR[27] = 0x7FBF; + ENCODE_HEADER_GCR[28] = 0xD57D; + ENCODE_HEADER_GCR[29] = 0xF75B; + ENCODE_HEADER_GCR[30] = 0x6FDF; + ENCODE_HEADER_GCR[31] = 0xB5B5; + ENCODE_HEADER_GCR[32] = 0xDF6F; + ENCODE_HEADER_GCR[33] = 0x5BF7; + ENCODE_HEADER_GCR[34] = 0x7DD5; + ENCODE_HEADER_GCR[35] = 0xBF7F; + ENCODE_HEADER_GCR[36] = 0xF55D; + ENCODE_HEADER_GCR[37] = 0x6DEB; + ENCODE_HEADER_GCR[38] = 0xAFB7; + ENCODE_HEADER_GCR[39] = 0xDD75; + ENCODE_HEADER_GCR[40] = 0x57FB; + ENCODE_HEADER_GCR[41] = 0x7BD7; + ENCODE_HEADER_GCR[42] = 0xBDAB; + ENCODE_HEADER_GCR[43] = 0xEF5F; + ENCODE_HEADER_GCR[44] = 0x6BED; + ENCODE_HEADER_GCR[45] = 0xADBB; + ENCODE_HEADER_GCR[46] = 0xDB77; + ENCODE_HEADER_GCR[47] = 0xBB55; + ENCODE_HEADER_GCR[48] = 0xEDDB; + ENCODE_HEADER_GCR[49] = 0x5FAD; + ENCODE_HEADER_GCR[50] = 0xAB6B; + ENCODE_HEADER_GCR[51] = 0xD7EF; + ENCODE_HEADER_GCR[52] = 0xFBBD; + ENCODE_HEADER_GCR[53] = 0x757B; + ENCODE_HEADER_GCR[54] = 0xB757; + ENCODE_HEADER_GCR[55] = 0xEBDD; + ENCODE_HEADER_GCR[56] = 0x5DAF; + ENCODE_HEADER_GCR[57] = 0x7F6D; + ENCODE_HEADER_GCR[58] = 0xD5F5; + ENCODE_HEADER_GCR[59] = 0xF7BF; + ENCODE_HEADER_GCR[60] = 0x6F7D; + ENCODE_HEADER_GCR[61] = 0xB55B; + ENCODE_HEADER_GCR[62] = 0xDFDF; + ENCODE_HEADER_GCR[63] = 0x5BB5; + ENCODE_HEADER_GCR[64] = 0x7D6F; + ENCODE_HEADER_GCR[65] = 0xBFF7; + ENCODE_HEADER_GCR[66] = 0xF5D5; + ENCODE_HEADER_GCR[67] = 0x6D7F; + ENCODE_HEADER_GCR[68] = 0xAF5D; + ENCODE_HEADER_GCR[69] = 0xDDEB; + ENCODE_HEADER_GCR[70] = 0x57B7; + ENCODE_HEADER_GCR[71] = 0x7B75; + ENCODE_HEADER_GCR[72] = 0xBDFB; + ENCODE_HEADER_GCR[73] = 0xEFD7; + ENCODE_HEADER_GCR[74] = 0x6BAB; + ENCODE_HEADER_GCR[75] = 0xAD5F; + ENCODE_HEADER_GCR[76] = 0xDBED; + ENCODE_HEADER_GCR[77] = 0x55BB; + + ENCODE_DATA_GCR[0] = 0x55; + ENCODE_DATA_GCR[1] = 0x57; + ENCODE_DATA_GCR[2] = 0x5b; + ENCODE_DATA_GCR[3] = 0x5d; + ENCODE_DATA_GCR[4] = 0x5f; + ENCODE_DATA_GCR[5] = 0x6b; + ENCODE_DATA_GCR[6] = 0x6d; + ENCODE_DATA_GCR[7] = 0x6f; + ENCODE_DATA_GCR[8] = 0x75; + ENCODE_DATA_GCR[9] = 0x77; + ENCODE_DATA_GCR[10] = 0x7b; + ENCODE_DATA_GCR[11] = 0x7d; + ENCODE_DATA_GCR[12] = 0x7f; + ENCODE_DATA_GCR[13] = 0xab; + ENCODE_DATA_GCR[14] = 0xad; + ENCODE_DATA_GCR[15] = 0xaf; + ENCODE_DATA_GCR[16] = 0xb5; + ENCODE_DATA_GCR[17] = 0xb7; + ENCODE_DATA_GCR[18] = 0xbb; + ENCODE_DATA_GCR[19] = 0xbd; + ENCODE_DATA_GCR[20] = 0xbf; + ENCODE_DATA_GCR[21] = 0xd5; + ENCODE_DATA_GCR[22] = 0xd7; + ENCODE_DATA_GCR[23] = 0xdb; + ENCODE_DATA_GCR[24] = 0xdd; + ENCODE_DATA_GCR[25] = 0xdf; + ENCODE_DATA_GCR[26] = 0xeb; + ENCODE_DATA_GCR[27] = 0xed; + ENCODE_DATA_GCR[28] = 0xef; + ENCODE_DATA_GCR[29] = 0xf5; + ENCODE_DATA_GCR[30] = 0xf7; + ENCODE_DATA_GCR[31] = 0xfb; + } + + private final BrotherEncoderProto config; + + public BrotherEncoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getBrother(); + } + + private static int encodeHeaderGcr(int word) + { + if (word < 0 || word >= ENCODE_HEADER_GCR.length) + return -1; + return ENCODE_HEADER_GCR[word]; + } + + private static int encodeDataGcr(int data) + { + if (data < 0 || data >= ENCODE_DATA_GCR.length) + return -1; + return ENCODE_DATA_GCR[data]; + } + + private static void writeBits(Bits bits, Bits.Cursor cursor, int data, int width) + { + cursor.advance(width); + for (int i = 0; i < width; i++) + { + int pos = cursor.get() - i - 1; + if (pos < bits.size()) + bits.setBit(pos, (data & 1) != 0); + data >>= 1; + } + } + + private static void writeSectorHeader(Bits bits, Bits.Cursor cursor, int track, int sector) + { + writeBits(bits, cursor, 0xffffffff, 31); + writeBits(bits, cursor, Brother.BROTHER_SECTOR_RECORD, 32); + writeBits(bits, cursor, encodeHeaderGcr(track), 16); + writeBits(bits, cursor, encodeHeaderGcr(sector), 16); + writeBits(bits, cursor, encodeHeaderGcr(0x2f), 16); + } + + private static void writeSectorData(Bits bits, Bits.Cursor cursor, Bytes data) + { + writeBits(bits, cursor, 0xffffffff, 32); + writeBits(bits, cursor, Brother.BROTHER_DATA_RECORD, 32); + + if (data.size() != Brother.BROTHER_DATA_RECORD_PAYLOAD) + throw new FluxEngineException("unsupported sector size"); + + int[] fifo = {0}; + int[] width = {0}; + + /* Consume 5-bit quintets from a 16-bit fifo fed by 8-bit bytes. */ + java.util.function.IntConsumer writeByte = (byte_) -> { + fifo[0] = (fifo[0] | (byte_ << (8 - width[0]))) & 0xffff; + width[0] += 8; + + while (width[0] >= 5) + { + int quintet = fifo[0] >> 11; + fifo[0] = (fifo[0] << 5) & 0xffff; + width[0] -= 5; + + writeBits(bits, cursor, encodeDataGcr(quintet), 8); + } + }; + + for (int i = 0; i < data.size(); i++) + writeByte.accept(data.getByte(i)); + + int realCrc = Crc.crcbrother(data); + writeByte.accept(realCrc >> 16); + writeByte.accept(realCrc >> 8); + writeByte.accept(realCrc); + writeByte.accept(0x58); /* magic */ + writeByte.accept(0xd4); + while (width[0] != 0) + writeByte.accept(0); + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + int bitsPerRevolution = (int) (200000.0 / config.getClockRateUs()); + Bits bits = new Bits(bitsPerRevolution); + Bits.Cursor cursor = new Bits.Cursor(0); + + int sectorCount = 0; + for (Sector sectorData : sectors) + { + double headerMs = + config.getPostIndexGapMs() + sectorCount * config.getSectorSpacingMs(); + int headerCursor = (int) (headerMs * 1e3 / config.getClockRateUs()); + double dataMs = headerMs + config.getPostHeaderSpacingMs(); + int dataCursor = (int) (dataMs * 1e3 / config.getClockRateUs()); + + bits.fillBitmapTo(cursor, headerCursor, new boolean[]{true, false}); + writeSectorHeader( + bits, + cursor, + sectorData.location.logicalCylinder(), + sectorData.location.logicalSector()); + bits.fillBitmapTo(cursor, dataCursor, new boolean[]{true, false}); + writeSectorData(bits, cursor, sectorData.data); + + sectorCount++; + } + + if (cursor.get() >= bits.size()) + throw new FluxEngineException("track data overrun"); + bits.fillBitmapTo(cursor, bits.size(), new boolean[]{true, false}); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits(bits, (long) (config.getClockRateUs() * 1e3)); + return fluxmap; + } +} diff --git a/arch/brother/brother.h b/java/com/cowlark/fluxengine/arch/brother/brother.h similarity index 100% rename from arch/brother/brother.h rename to java/com/cowlark/fluxengine/arch/brother/brother.h diff --git a/java/com/cowlark/fluxengine/arch/brother/brother.proto b/java/com/cowlark/fluxengine/arch/brother/brother.proto new file mode 100644 index 000000000..1cf221158 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/brother/brother.proto @@ -0,0 +1,21 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.brother"; +option java_multiple_files = true; + +message BrotherDecoderProto {} + +enum BrotherFormat { + BROTHER240 = 0; + BROTHER120 = 1; +}; + +message BrotherEncoderProto { + optional double clock_rate_us = 1 [default = 3.83]; + optional double post_index_gap_ms = 2 [default = 1.0]; + optional double sector_spacing_ms = 3 [default = 16.2]; + optional double post_header_spacing_ms = 4 [default = 0.69]; + + optional BrotherFormat format = 6 [default = BROTHER240]; +} + diff --git a/arch/brother/data_gcr.h b/java/com/cowlark/fluxengine/arch/brother/data_gcr.h similarity index 100% rename from arch/brother/data_gcr.h rename to java/com/cowlark/fluxengine/arch/brother/data_gcr.h diff --git a/arch/brother/decoder.cc b/java/com/cowlark/fluxengine/arch/brother/decoder.cc similarity index 100% rename from arch/brother/decoder.cc rename to java/com/cowlark/fluxengine/arch/brother/decoder.cc diff --git a/arch/brother/encoder.cc b/java/com/cowlark/fluxengine/arch/brother/encoder.cc similarity index 100% rename from arch/brother/encoder.cc rename to java/com/cowlark/fluxengine/arch/brother/encoder.cc diff --git a/arch/brother/header_gcr.h b/java/com/cowlark/fluxengine/arch/brother/header_gcr.h similarity index 100% rename from arch/brother/header_gcr.h rename to java/com/cowlark/fluxengine/arch/brother/header_gcr.h diff --git a/arch/build.py b/java/com/cowlark/fluxengine/arch/build.py similarity index 93% rename from arch/build.py rename to java/com/cowlark/fluxengine/arch/build.py index c0ce8e051..47d6cdf82 100644 --- a/arch/build.py +++ b/java/com/cowlark/fluxengine/arch/build.py @@ -1,8 +1,9 @@ +import sys +from glob import glob +from os.path import * + from build.c import cxxlibrary from build.protobuf import proto, protocc, protolib -from os.path import * -from glob import glob -import sys archs = {basename(dirname(f)) for f in glob("arch/*/*.proto")} @@ -57,5 +58,5 @@ "arch/arch.h": "./arch.h", }, deps=cls - + ["lib/core", "lib/data", "lib/config", "lib/encoders", "lib/decoders"], + + ["lib/core", "lib/data", "lib/config", "lib/encoders", "lib/decoders"], ) diff --git a/java/com/cowlark/fluxengine/arch/c64/C64.java b/java/com/cowlark/fluxengine/arch/c64/C64.java new file mode 100644 index 000000000..6a517c136 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/c64/C64.java @@ -0,0 +1,57 @@ +package com.cowlark.fluxengine.arch.c64; + +/** + * Constants for the Commodore 64 format, ported from arch/c64/c64.h. + *

+ * Source: http://www.unusedino.de/ec64/technical/formats/g64.html + * 1. Header sync FF FF FF FF FF (40 'on' bits, not GCR) + * 2. Header info 52 54 B5 29 4B 7A 5E 95 55 55 (10 GCR bytes) + * 3. Header gap 55 55 55 55 55 55 55 55 55 (9 bytes, never read) + * 4. Data sync FF FF FF FF FF (40 'on' bits, not GCR) + * 5. Data block 55...4A (325 GCR bytes) + * 6. Inter-sector gap 55 55 55 55...55 55 (4 to 12 bytes, never read) + * 1. Header sync (SYNC for the next sector) + */ +public final class C64 +{ + public static final int C64_SECTOR_RECORD = 0xffd49; + public static final int C64_DATA_RECORD = 0xffd57; + public static final int C64_SECTOR_LENGTH = 256; + + public static final int C64_HEADER_DATA_SYNC = 0xFF; + public static final int C64_HEADER_BLOCK_ID = 0x08; + public static final int C64_DATA_BLOCK_ID = 0x07; + public static final int C64_HEADER_GAP = 0x55; + public static final int C64_INTER_SECTOR_GAP = 0x55; + public static final int C64_PADDING = 0x0F; + + public static final int C64_TRACKS_PER_DISK = 40; + public static final int C64_BAM_TRACK = 17; + + private C64() + { + } + + /* + * Track Sectors/track # Sectors Storage in Bytes Clock rate + * ----- ------------- --------- ---------------- ---------- + * 1-17 21 357 7820 3.25 + * 18-24 19 133 7170 3.5 + * 25-30 18 108 6300 3.75 + * 31-40(*) 17 85 6020 4 + * --- + * 683 (for a 35 track image) + * + * The clock rate is normalised for a 200ms drive. + */ + public static double clockRateUsForTrack(int track) + { + if (track < 17) + return 26.0 / 8.0; + if (track < 24) + return 28.0 / 8.0; + if (track < 30) + return 30.0 / 8.0; + return 32.0 / 8.0; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/c64/Commodore64Decoder.java b/java/com/cowlark/fluxengine/arch/c64/Commodore64Decoder.java new file mode 100644 index 000000000..6831a2dbc --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/c64/Commodore64Decoder.java @@ -0,0 +1,135 @@ +package com.cowlark.fluxengine.arch.c64; + +import com.cowlark.fluxengine.core.BitWriter; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.Crc; + +/** + * The Commodore 64 decoder, ported from arch/c64/decoder.cc. + */ +public class Commodore64Decoder extends Decoder +{ + private static final FluxPattern SECTOR_RECORD_PATTERN = + new FluxPattern(20, C64.C64_SECTOR_RECORD); + private static final FluxPattern DATA_RECORD_PATTERN = new FluxPattern(20, C64.C64_DATA_RECORD); + private static final FluxMatchers ANY_RECORD_PATTERN = + FluxMatchers.of(SECTOR_RECORD_PATTERN, DATA_RECORD_PATTERN); + + public Commodore64Decoder(DecoderProto config) + { + super(config); + } + + private static int decodeDataGcr(int gcr) + { + switch (gcr) + { + case 0x0a: + return 0x0; + case 0x0b: + return 0x1; + case 0x12: + return 0x2; + case 0x13: + return 0x3; + case 0x0e: + return 0x4; + case 0x0f: + return 0x5; + case 0x16: + return 0x6; + case 0x17: + return 0x7; + case 0x09: + return 0x8; + case 0x19: + return 0x9; + case 0x1a: + return 0xa; + case 0x1b: + return 0xb; + case 0x0d: + return 0xc; + case 0x1d: + return 0xd; + case 0x1e: + return 0xe; + case 0x15: + return 0xf; + default: + return -1; + } + } + + private static Bytes decode(Bits bits) + { + Bytes output = new Bytes(); + ByteWriter bw = new ByteWriter(output); + BitWriter bitw = new BitWriter(bw); + + int ii = 0; + while (ii < bits.size()) + { + int inputfifo = 0; + for (int i = 0; i < 5; i++) + { + if (ii >= bits.size()) + break; + inputfifo = (inputfifo << 1) | (bits.getBit(ii++) ? 1 : 0); + } + + bitw.push(decodeDataGcr(inputfifo), 4); + } + bitw.flush(); + + return output; + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(ANY_RECORD_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + if (readRaw20() != C64.C64_SECTOR_RECORD) + return; + + Bits bits = readRawBits(5 * 10); + Bytes bytes = decode(bits).slice(0, 5); + + int checksum = bytes.getByte(0) & 0xff; + int logicalSector = bytes.getByte(1) & 0xff; + int logicalHead = 0; + int logicalCylinder = (bytes.getByte(2) & 0xff) - 1; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + if (checksum == Crc.xorBytes(bytes.slice(1, 4))) + sector.status = Sector.Status.DATA_MISSING; /* unintuitive but correct */ + } + + @Override + protected void decodeDataRecord() + { + if (readRaw20() != C64.C64_DATA_RECORD) + return; + + Bits bits = readRawBits(259 * 10); + Bytes bytes = decode(bits).slice(0, 259); + + sector.data = bytes.slice(0, C64.C64_SECTOR_LENGTH); + int gotChecksum = Crc.xorBytes(sector.data); + int wantChecksum = bytes.getByte(256) & 0xff; + sector.status = + (wantChecksum == gotChecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/c64/Commodore64Encoder.java b/java/com/cowlark/fluxengine/arch/c64/Commodore64Encoder.java new file mode 100644 index 000000000..85c0d63ea --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/c64/Commodore64Encoder.java @@ -0,0 +1,207 @@ +package com.cowlark.fluxengine.arch.c64; + +import com.cowlark.fluxengine.c64.Commodore64EncoderProto; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.external.Crc; +import java.util.List; + +/** + * The Commodore 64 encoder, ported from arch/c64/encoder.cc. + */ +public class Commodore64Encoder extends Encoder +{ + private static final int[] ENCODE_DATA_GCR = new int[16]; + + static + { + ENCODE_DATA_GCR[0x0] = 0x0a; + ENCODE_DATA_GCR[0x1] = 0x0b; + ENCODE_DATA_GCR[0x2] = 0x12; + ENCODE_DATA_GCR[0x3] = 0x13; + ENCODE_DATA_GCR[0x4] = 0x0e; + ENCODE_DATA_GCR[0x5] = 0x0f; + ENCODE_DATA_GCR[0x6] = 0x16; + ENCODE_DATA_GCR[0x7] = 0x17; + ENCODE_DATA_GCR[0x8] = 0x09; + ENCODE_DATA_GCR[0x9] = 0x19; + ENCODE_DATA_GCR[0xa] = 0x1a; + ENCODE_DATA_GCR[0xb] = 0x1b; + ENCODE_DATA_GCR[0xc] = 0x0d; + ENCODE_DATA_GCR[0xd] = 0x1d; + ENCODE_DATA_GCR[0xe] = 0x1e; + ENCODE_DATA_GCR[0xf] = 0x15; + } + + private final Commodore64EncoderProto config; + private int formatByte1; + private int formatByte2; + + public Commodore64Encoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getC64(); + } + + private static int encodeDataGcr(int data) + { + if (data < 0 || data >= ENCODE_DATA_GCR.length) + return -1; + return ENCODE_DATA_GCR[data]; + } + + private static void writeBits(Bits bits, Bits.Cursor cursor, boolean[] src) + { + for (boolean bit : src) + { + if (cursor.get() < bits.size()) + bits.setBit(cursor.get(), bit); + cursor.advance(); + } + } + + private static void writeBits(Bits bits, Bits.Cursor cursor, long data, int width) + { + cursor.advance(width); + for (int i = 0; i < width; i++) + { + int pos = cursor.get() - i - 1; + if (pos < bits.size()) + bits.setBit(pos, (data & 1) != 0); + data >>= 1; + } + } + + /* See the big comment in the C++ file for the gory details of how 4 + * 8-bit bytes become five 8-bit GCR bytes; this encodes a single byte to + * its 10-bit GCR form. */ + private static boolean[] encodeData(int input) + { + boolean[] output = new boolean[10]; + + int lo = input >> 4; /* get the lo nibble */ + int hi = input & 15; /* get the hi nibble */ + + int loGcr = encodeDataGcr(lo); + int hiGcr = encodeDataGcr(hi); + + int b = 4; + for (int i = 0; i < 10; i++) + { + if (i < 5) + { + output[4 - i] = (loGcr & 1) != 0; + loGcr >>= 1; + } else + { + output[i + b] = (hiGcr & 1) != 0; + hiGcr >>= 1; + b -= 2; + } + } + return output; + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + /* The format ID Character # 1 and # 2 are in the .d64 image only + * present in track 18 sector zero which contains the BAM info in byte + * 162 and 163. it is written in every header of every sector and track. + * headers are not stored in a d64 disk image so we have to get it from + * track 18 which contains the BAM. + */ + + Sector sectorData = image.get(C64.C64_BAM_TRACK, 0, 0); + if (sectorData != null) + { + ByteReader br = new ByteReader(sectorData.data); + br.seek(162); /* goto position of the first Disk ID Byte */ + formatByte1 = br.read8(); + formatByte2 = br.read8(); + } else + { + formatByte1 = formatByte2 = 0; + } + + double clockRateUs = C64.clockRateUsForTrack(ltl.logicalCylinder); + int bitsPerRevolution = (int) (200000.0 / clockRateUs); + + Bits bits = new Bits(bitsPerRevolution); + Bits.Cursor cursor = new Bits.Cursor(0); + + bits.fillBitmapTo( + cursor, + (int) (config.getPostIndexGapUs() / clockRateUs), + new boolean[]{true, false}); + + for (Sector sector : sectors) + writeSector(bits, cursor, sector); + + if (cursor.get() >= bits.size()) + throw new FluxEngineException( + "track data overrun by " + (cursor.get() - bits.size()) + " bits"); + bits.fillBitmapTo(cursor, bits.size(), new boolean[]{true, false}); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits(bits, (long) calculatePhysicalClockPeriodNs(clockRateUs * 1e3, 200e6)); + return fluxmap; + } + + private void writeSector(Bits bits, Bits.Cursor cursor, Sector sector) + { + if ((sector.status == Sector.Status.OK) || (sector.status == Sector.Status.BAD_CHECKSUM)) + { + // There is data to encode to disk. + if ((sector.data.size() != C64.C64_SECTOR_LENGTH)) + throw new FluxEngineException( + "unsupported sector size " + sector.data.size() + " --- you must pick 256"); + + // 1. Write header Sync (not GCR) + for (int i = 0; i < 6; i++) + writeBits(bits, cursor, C64.C64_HEADER_DATA_SYNC, 1 * 8); /* sync */ + + // 2. Write Header info 10 GCR bytes + int encodedTrack = sector.location.logicalCylinder() + 1; + int encodedSector = sector.location.logicalSector(); + int headerChecksum = (encodedTrack ^ encodedSector ^ formatByte1 ^ formatByte2); + writeBits(bits, cursor, encodeData(C64.C64_HEADER_BLOCK_ID)); + writeBits(bits, cursor, encodeData(headerChecksum)); + writeBits(bits, cursor, encodeData(encodedSector)); + writeBits(bits, cursor, encodeData(encodedTrack)); + writeBits(bits, cursor, encodeData(formatByte2)); + writeBits(bits, cursor, encodeData(formatByte1)); + writeBits(bits, cursor, encodeData(C64.C64_PADDING)); + writeBits(bits, cursor, encodeData(C64.C64_PADDING)); + + // 3. Write header GAP not GCR + for (int i = 0; i < 9; i++) + writeBits(bits, cursor, C64.C64_HEADER_GAP, 1 * 8); /* header gap */ + + // 4. Write Data sync not GCR + for (int i = 0; i < 6; i++) + writeBits(bits, cursor, C64.C64_HEADER_DATA_SYNC, 1 * 8); /* sync */ + + // 5. Write data block 325 GCR bytes + writeBits(bits, cursor, encodeData(C64.C64_DATA_BLOCK_ID)); + int dataChecksum = Crc.xorBytes(sector.data); + ByteReader br = new ByteReader(sector.data); + for (int i = 0; i < C64.C64_SECTOR_LENGTH; i++) + writeBits(bits, cursor, encodeData(br.read8())); + writeBits(bits, cursor, encodeData(dataChecksum)); + writeBits(bits, cursor, encodeData(C64.C64_PADDING)); + writeBits(bits, cursor, encodeData(C64.C64_PADDING)); + + // 6. Write inter-sector gap 9 - 12 bytes not GCR + for (int i = 0; i < 9; i++) + writeBits(bits, cursor, C64.C64_INTER_SECTOR_GAP, 1 * 8); /* sync */ + } + } +} diff --git a/arch/c64/c64.cc b/java/com/cowlark/fluxengine/arch/c64/c64.cc similarity index 100% rename from arch/c64/c64.cc rename to java/com/cowlark/fluxengine/arch/c64/c64.cc diff --git a/arch/c64/c64.h b/java/com/cowlark/fluxengine/arch/c64/c64.h similarity index 100% rename from arch/c64/c64.h rename to java/com/cowlark/fluxengine/arch/c64/c64.h diff --git a/java/com/cowlark/fluxengine/arch/c64/c64.proto b/java/com/cowlark/fluxengine/arch/c64/c64.proto new file mode 100644 index 000000000..6ce1fd76b --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/c64/c64.proto @@ -0,0 +1,14 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.c64"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message Commodore64DecoderProto {} + +message Commodore64EncoderProto { + optional double post_index_gap_us = 1 [default = 0.0, + (help) = "post-index gap before first sector header."]; +} + diff --git a/arch/c64/data_gcr.h b/java/com/cowlark/fluxengine/arch/c64/data_gcr.h similarity index 100% rename from arch/c64/data_gcr.h rename to java/com/cowlark/fluxengine/arch/c64/data_gcr.h diff --git a/arch/c64/decoder.cc b/java/com/cowlark/fluxengine/arch/c64/decoder.cc similarity index 100% rename from arch/c64/decoder.cc rename to java/com/cowlark/fluxengine/arch/c64/decoder.cc diff --git a/arch/c64/encoder.cc b/java/com/cowlark/fluxengine/arch/c64/encoder.cc similarity index 100% rename from arch/c64/encoder.cc rename to java/com/cowlark/fluxengine/arch/c64/encoder.cc diff --git a/java/com/cowlark/fluxengine/arch/f85/DurangoF85Decoder.java b/java/com/cowlark/fluxengine/arch/f85/DurangoF85Decoder.java new file mode 100644 index 000000000..dd0f648d5 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/f85/DurangoF85Decoder.java @@ -0,0 +1,145 @@ +package com.cowlark.fluxengine.arch.f85; + +import com.cowlark.fluxengine.core.BitWriter; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.Crc; + +/** + * The Durango F85 decoder, ported from arch/f85/decoder.cc. + */ +public class DurangoF85Decoder extends Decoder +{ + private static final FluxPattern SECTOR_RECORD_PATTERN = + new FluxPattern(24, F85.F85_SECTOR_RECORD); + private static final FluxPattern DATA_RECORD_PATTERN = new FluxPattern(24, F85.F85_DATA_RECORD); + private static final FluxMatchers ANY_RECORD_PATTERN = + FluxMatchers.of(SECTOR_RECORD_PATTERN, DATA_RECORD_PATTERN); + + public DurangoF85Decoder(DecoderProto config) + { + super(config); + } + + private static int decodeDataGcr(int gcr) + { + switch (gcr) + { + case 0x19: + return 0x00; + case 0x1b: + return 0x01; + case 0x12: + return 0x02; + case 0x13: + return 0x03; + case 0x1d: + return 0x04; + case 0x15: + return 0x05; + case 0x16: + return 0x06; + case 0x17: + return 0x07; + case 0x1a: + return 0x08; + case 0x09: + return 0x09; + case 0x0a: + return 0x0a; + case 0x0b: + return 0x0b; + case 0x1e: + return 0x0c; + case 0x0d: + return 0x0d; + case 0x0e: + return 0x0e; + case 0x0f: + return 0x0f; + default: + return -1; + } + } + + private static Bytes decode(Bits bits) + { + Bytes output = new Bytes(); + ByteWriter bw = new ByteWriter(output); + BitWriter bitw = new BitWriter(bw); + + int ii = 0; + while (ii < bits.size()) + { + int inputfifo = 0; + for (int i = 0; i < 5; i++) + { + if (ii >= bits.size()) + break; + inputfifo = (inputfifo << 1) | (bits.getBit(ii++) ? 1 : 0); + } + + bitw.push(decodeDataGcr(inputfifo), 4); + } + bitw.flush(); + + return output; + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(ANY_RECORD_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + /* Skip sync bits and ID byte. */ + + if (readRaw24() != F85.F85_SECTOR_RECORD) + return; + + /* Read header. */ + + Bytes bytes = decode(readRawBits(6 * 10)); + + int logicalSector = bytes.getByte(2) & 0xff; + int logicalHead = 0; + int logicalCylinder = bytes.getByte(0) & 0xff; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + + int wantChecksum = bytes.iterator().seek(4).readBe16(); + int gotChecksum = Crc.crc16(Crc.CCITT_POLY, 0xef21, bytes.slice(0, 4)); + if (wantChecksum == gotChecksum) + sector.status = Sector.Status.DATA_MISSING; /* unintuitive but correct */ + } + + @Override + protected void decodeDataRecord() + { + /* Skip sync bits ID byte. */ + + if (readRaw24() != F85.F85_DATA_RECORD) + return; + + Bytes bytes = decode(readRawBits((F85.F85_SECTOR_LENGTH + 3) * 10)).slice( + 0, + F85.F85_SECTOR_LENGTH + 3); + ByteReader br = bytes.iterator(); + + sector.data = br.read(F85.F85_SECTOR_LENGTH); + int wantChecksum = br.readBe16(); + int gotChecksum = Crc.crc16(Crc.CCITT_POLY, 0xbf84, sector.data); + sector.status = + (wantChecksum == gotChecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/f85/F85.java b/java/com/cowlark/fluxengine/arch/f85/F85.java new file mode 100644 index 000000000..3439b3873 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/f85/F85.java @@ -0,0 +1,15 @@ +package com.cowlark.fluxengine.arch.f85; + +/** + * Constants for the Durango F85 format, ported from arch/f85/f85.h. + */ +public final class F85 +{ + public static final int F85_SECTOR_RECORD = 0xffffce; /* 1111 1111 1111 1111 1100 1110 */ + public static final int F85_DATA_RECORD = 0xffffcb; /* 1111 1111 1111 1111 1100 1101 */ + public static final int F85_SECTOR_LENGTH = 512; + + private F85() + { + } +} \ No newline at end of file diff --git a/arch/f85/data_gcr.h b/java/com/cowlark/fluxengine/arch/f85/data_gcr.h similarity index 100% rename from arch/f85/data_gcr.h rename to java/com/cowlark/fluxengine/arch/f85/data_gcr.h diff --git a/arch/f85/decoder.cc b/java/com/cowlark/fluxengine/arch/f85/decoder.cc similarity index 100% rename from arch/f85/decoder.cc rename to java/com/cowlark/fluxengine/arch/f85/decoder.cc diff --git a/arch/f85/f85.h b/java/com/cowlark/fluxengine/arch/f85/f85.h similarity index 100% rename from arch/f85/f85.h rename to java/com/cowlark/fluxengine/arch/f85/f85.h diff --git a/java/com/cowlark/fluxengine/arch/f85/f85.proto b/java/com/cowlark/fluxengine/arch/f85/f85.proto new file mode 100644 index 000000000..c9eaa17cc --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/f85/f85.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.f85"; +option java_multiple_files = true; + +message F85DecoderProto {} + diff --git a/java/com/cowlark/fluxengine/arch/fb100/Fb100.java b/java/com/cowlark/fluxengine/arch/fb100/Fb100.java new file mode 100644 index 000000000..025a601b8 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/fb100/Fb100.java @@ -0,0 +1,15 @@ +package com.cowlark.fluxengine.arch.fb100; + +/** + * Constants for the FB100 format, ported from arch/fb100/fb100.h. + */ +public final class Fb100 +{ + public static final int FB100_RECORD_SIZE = 0x516; /* bytes */ + public static final int FB100_ID_SIZE = 17; + public static final int FB100_PAYLOAD_SIZE = 0x500; + + private Fb100() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/fb100/Fb100Decoder.java b/java/com/cowlark/fluxengine/arch/fb100/Fb100Decoder.java new file mode 100644 index 000000000..d977fee66 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/fb100/Fb100Decoder.java @@ -0,0 +1,145 @@ +package com.cowlark.fluxengine.arch.fb100; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.FmMfm; + +/** + * The FB100 decoder, ported from arch/fb100/decoder.cc. + */ +public class Fb100Decoder extends Decoder +{ + private static final FluxPattern SECTOR_ID_PATTERN = new FluxPattern(16, 0xabaa); + + public Fb100Decoder(DecoderProto config) + { + super(config); + } + + /* + * Reverse engineered from a dump of the floppy drive's ROM. I have no idea + * how it works. + * + * LF8BA: + * clra + * staa X00B0 + * staa X00B1 + * ldx #$8000 + * LF8C2: ldaa $00,x + * inx + * bsr LF8CF + * cpx #$8011 + * bne LF8C2 + * ldd X00B0 + * rts + * LF8CF: + * eora X00B0 + * staa X00CF + * asla + * asla + * asla + * asla + * eora X00CF + * staa X00CF + * rola + * rola + * rola + * tab + * anda #$F8 + * eora X00B1 + * staa X00B0 + * rolb + * rolb + * andb #$0F + * eorb X00B0 + * stab X00B0 + * rolb + * eorb X00CF + * stab X00B1 + * rts + */ + private static void rol(int[] b, boolean[] c) + { + boolean newc = (b[0] & 0x80) != 0; + b[0] = ((b[0] << 1) | (c[0] ? 1 : 0)) & 0xff; + c[0] = newc; + } + + private static int checksum(Bytes bytes) + { + int crclo = 0; + int crchi = 0; + for (int i = 0; i < bytes.size(); i++) + { + int a = bytes.getByte(i) & 0xff; + a ^= crchi; + int t1 = a; + a <<= 4; + boolean[] c = {((a & 0x10) != 0)}; + a ^= t1; + t1 = a; + int[] b = {a}; + rol(b, c); + rol(b, c); + rol(b, c); + a = b[0]; + a &= 0xf8; + a ^= crclo; + crchi = a; + rol(b, c); + rol(b, c); + b[0] &= 0x0f; + b[0] ^= crchi; + crchi = b[0]; + rol(b, c); + b[0] ^= t1; + crclo = b[0]; + } + + return (crchi << 8) | crclo; + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(SECTOR_ID_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + Bits rawbits = readRawBits(Fb100.FB100_RECORD_SIZE * 16); + + Bytes bytes = FmMfm.decodeFmMfm(rawbits).slice(0, Fb100.FB100_RECORD_SIZE); + ByteReader br = bytes.iterator(); + br.seek(1); + Bytes id = br.read(Fb100.FB100_ID_SIZE); + int wantIdCrc = br.readBe16(); + int gotIdCrc = checksum(id); + Bytes payload = br.read(Fb100.FB100_PAYLOAD_SIZE); + int wantPayloadCrc = br.readBe16(); + int gotPayloadCrc = checksum(payload); + + if (wantIdCrc != gotIdCrc) + return; + + int abssector = id.getByte(2) & 0xff; + int logicalCylinder = abssector >> 1; + int logicalHead = 0; + int logicalSector = abssector & 1; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + + Bytes data = new Bytes(); + data.writer().write(id.slice(5, 12)).write(payload); + sector.data = data; + + sector.status = + (wantPayloadCrc == gotPayloadCrc) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/arch/fb100/decoder.cc b/java/com/cowlark/fluxengine/arch/fb100/decoder.cc similarity index 100% rename from arch/fb100/decoder.cc rename to java/com/cowlark/fluxengine/arch/fb100/decoder.cc diff --git a/arch/fb100/fb100.h b/java/com/cowlark/fluxengine/arch/fb100/fb100.h similarity index 100% rename from arch/fb100/fb100.h rename to java/com/cowlark/fluxengine/arch/fb100/fb100.h diff --git a/java/com/cowlark/fluxengine/arch/fb100/fb100.proto b/java/com/cowlark/fluxengine/arch/fb100/fb100.proto new file mode 100644 index 000000000..e4a2c0a00 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/fb100/fb100.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.fb100"; +option java_multiple_files = true; + +message Fb100DecoderProto {} + diff --git a/java/com/cowlark/fluxengine/arch/ibm/Ibm.java b/java/com/cowlark/fluxengine/arch/ibm/Ibm.java new file mode 100644 index 000000000..4e68d9545 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/ibm/Ibm.java @@ -0,0 +1,23 @@ +package com.cowlark.fluxengine.arch.ibm; + +/** + * Constants for the IBM format (i.e. ordinary PC floppies), ported from + * arch/ibm/ibm.h. + */ +public final class Ibm +{ + public static final int IBM_MFM_SYNC = 0xA1; /* sync byte for MFM */ + public static final int IBM_IAM = 0xFC; /* start-of-track record */ + public static final int IBM_IAM_LEN = 1; /* plus prologue */ + public static final int IBM_IDAM = 0xFE; /* sector header */ + public static final int IBM_IDAM_LEN = 7; /* plus prologue */ + public static final int IBM_DAM1 = 0xF8; /* sector data (type 1) */ + public static final int IBM_DAM2 = 0xFB; /* sector data (type 2) */ + public static final int IBM_TRS80DAM1 = 0xF9; /* sector data (TRS-80 directory) */ + public static final int IBM_TRS80DAM2 = 0xFA; /* sector data (TRS-80 directory) */ + public static final int IBM_DAM_LEN = 1; /* plus prologue and user data */ + + private Ibm() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/ibm/IbmDecoder.java b/java/com/cowlark/fluxengine/arch/ibm/IbmDecoder.java new file mode 100644 index 000000000..b1944f14c --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/ibm/IbmDecoder.java @@ -0,0 +1,242 @@ +package com.cowlark.fluxengine.arch.ibm; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.Crc; +import com.cowlark.fluxengine.external.FmMfm; +import com.cowlark.fluxengine.ibm.IbmDecoderProto; + +/** + * The IBM decoder, ported from arch/ibm/decoder.cc. + */ +public class IbmDecoder extends Decoder +{ + /* + * The markers at the beginning of records are special, and have + * missing clock pulses, allowing them to be found by the logic. + * + * IAM record: + * flux: XXXX-XXX-XXXX-X- = 0xf77a + * clock: X X - X - X X X = 0xd7 + * data: X X X X X X - - = 0xfc + * + * (We just ignore this one --- it's useless and optional.) + */ + + /* + * IDAM record: + * flux: XXXX-X-X-XXXXXX- = 0xf57e + * clock: X X - - - X X X = 0xc7 + * data: X X X X X X X - = 0xfe + */ + private static final FluxPattern FM_IDAM_PATTERN = new FluxPattern(16, 0xf57e); + + /* + * DAM1 record: + * flux: XXXX-X-X-XX-X-X- = 0xf56a + * clock: X X - - - X X X = 0xc7 + * data: X X X X X - - - = 0xf8 + */ + private static final FluxPattern FM_DAM1_PATTERN = new FluxPattern(16, 0xf56a); + + /* + * DAM2 record: + * flux: XXXX-X-X-XX-XXXX = 0xf56f + * clock: X X - - - X X X = 0xc7 + * data: X X X X X - X X = 0xfb + */ + private static final FluxPattern FM_DAM2_PATTERN = new FluxPattern(16, 0xf56f); + + /* + * TRS80DAM1 record: + * flux: XXXX-X-X-XX-X-XX = 0xf56b + * clock: X X - - - X X X = 0xc7 + * data: X X X X X - - X = 0xf9 + */ + private static final FluxPattern FM_TRS80DAM1_PATTERN = new FluxPattern(16, 0xf56b); + + /* + * TRS80DAM2 record: + * flux: XXXX-X-X-XX-XXX- = 0xf56e + * clock: X X - - - X X X = 0xc7 + * data: X X X X X - X - = 0xfa + */ + private static final FluxPattern FM_TRS80DAM2_PATTERN = new FluxPattern(16, 0xf56e); + + /* MFM record separator: + * 0xA1 is: + * data: 1 0 1 0 0 0 0 1 = 0xa1 + * mfm: 01 00 01 00 10 10 10 01 = 0x44a9 + * special: 01 00 01 00 10 00 10 01 = 0x4489 + * ^^^^^ + * When shifted out of phase, the special 0xa1 byte becomes an illegal + * encoding (you can't do 10 00). So this can't be spoofed by user data. + * + * shifted: 10 00 10 01 00 01 00 1 + * + * It's repeated three times. + */ + private static final FluxPattern MFM_PATTERN = new FluxPattern(48, 0x448944894489L); + + private static final FluxMatchers ANY_RECORD_PATTERN = FluxMatchers.of( + MFM_PATTERN, + FM_IDAM_PATTERN, + FM_DAM1_PATTERN, + FM_DAM2_PATTERN, + FM_TRS80DAM1_PATTERN, + FM_TRS80DAM2_PATTERN); + + private final IbmDecoderProto config; + private int currentSectorSize; + + public IbmDecoder(DecoderProto config) + { + super(config); + this.config = config.getIbm(); + } + + private IbmDecoderProto.TrackdataProto getTrackFormat(int track, int head) + { + IbmDecoderProto.TrackdataProto.Builder builder = + IbmDecoderProto.TrackdataProto.newBuilder(); + for (IbmDecoderProto.TrackdataProto f : config.getTrackdataList()) + { + if (f.hasTrack() && (f.getTrack() != track)) + continue; + if (f.hasHead() && (f.getHead() != head)) + continue; + + builder.mergeFrom(f); + } + return builder.build(); + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(ANY_RECORD_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + /* This is really annoying because the IBM record scheme has a + * variable-sized header _and_ the checksum covers this header too. So + * we have to read and decode a byte at a time until we know where the + * record itself starts, saving the bytes for the checksumming later. + */ + + Bytes bytes = new Bytes(); + ByteWriter bw = bytes.writer(); + + int id = readByte(bw); + if (id == 0xa1) + { + readByte(bw); + readByte(bw); + id = readByte(bw); + } + if (id != Ibm.IBM_IDAM) + return; + + ByteReader br = bytes.iterator(); + br.seek(bw.pos()); + + Bits bits = readRawBits(Ibm.IBM_IDAM_LEN * 16); + bw.write(FmMfm.decodeFmMfm(bits).slice(0, Ibm.IBM_IDAM_LEN)); + + IbmDecoderProto.TrackdataProto trackdata = + getTrackFormat(ltl.logicalCylinder, ltl.logicalHead); + + int logicalCylinder = br.read8(); + int logicalHead = br.read8(); + int logicalSector = br.read8(); + currentSectorSize = 1 << (br.read8() + 7); + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + + int gotCrc = Crc.crc16(Crc.CCITT_POLY, bytes.slice(0, br.pos())); + int wantCrc = br.readBe16(); + if (wantCrc == gotCrc) + sector.status = Sector.Status.DATA_MISSING; + + if (trackdata.getIgnoreSideByte()) + sector.location = new LogicalLocation( + sector.location.logicalCylinder(), + ltl.logicalHead, + sector.location.logicalSector()); + sector.location = new LogicalLocation( + sector.location.logicalCylinder(), + sector.location.logicalHead() ^ (trackdata.getInvertSideByte() ? 1 : 0), + sector.location.logicalSector()); + if (trackdata.getIgnoreTrackByte()) + sector.location = new LogicalLocation( + ltl.logicalCylinder, + sector.location.logicalHead(), + sector.location.logicalSector()); + + for (int s : trackdata.getIgnoreSectorList()) + if (sector.location.logicalSector() == s) + { + sector.status = Sector.Status.MISSING; + break; + } + } + + @Override + protected void decodeDataRecord() + { + /* This is the same deal as the sector record. */ + + Bytes bytes = new Bytes(); + ByteWriter bw = bytes.writer(); + + int id = readByte(bw); + if (id == 0xa1) + { + readByte(bw); + readByte(bw); + id = readByte(bw); + } + if ((id != Ibm.IBM_DAM1) && (id != Ibm.IBM_DAM2) && (id != Ibm.IBM_TRS80DAM1) && + (id != Ibm.IBM_TRS80DAM2)) + return; + + ByteReader br = bytes.iterator(); + br.seek(bw.pos()); + + Bits bits = readRawBits((currentSectorSize + 2) * 16); + bw.write(FmMfm.decodeFmMfm(bits).slice(0, currentSectorSize + 2)); + + sector.data = br.read(currentSectorSize); + int gotCrc = Crc.crc16(Crc.CCITT_POLY, bytes.slice(0, br.pos())); + int wantCrc = br.readBe16(); + sector.status = (wantCrc == gotCrc) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + + if (currentSectorSize != ltl.sectorSize) + System.err.printf( + "Warning: configured sector size for t%d.h%d.s%d is %d bytes but that seen on" + + " disk is %d bytes%n", + sector.location.logicalCylinder(), + sector.location.logicalHead(), + sector.location.logicalSector(), + ltl.sectorSize, + currentSectorSize); + } + + private int readByte(ByteWriter bw) + { + Bits bits = readRawBits(16); + Bytes bytes = FmMfm.decodeFmMfm(bits).slice(0, 1); + int byte0 = bytes.getByte(0) & 0xff; + bw.write8(byte0); + return byte0; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/ibm/IbmEncoder.java b/java/com/cowlark/fluxengine/arch/ibm/IbmEncoder.java new file mode 100644 index 000000000..2a8b0230f --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/ibm/IbmEncoder.java @@ -0,0 +1,262 @@ +package com.cowlark.fluxengine.arch.ibm; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +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.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.external.Crc; +import com.cowlark.fluxengine.external.FmMfm; +import com.cowlark.fluxengine.ibm.IbmEncoderProto; +import java.util.List; + +/** + * The IBM encoder, ported from arch/ibm/encoder.cc. + */ +public class IbmEncoder extends Encoder +{ + /* IAM record separator: + * 0xC2 is: + * data: 1 1 0 0 0 0 1 0 = 0xc2 + * mfm: 01 01 00 10 10 10 01 00 = 0x5254 + * special: 01 01 00 10 00 10 01 00 = 0x5224 + */ + private static final int MFM_IAM_SEPARATOR = 0x5224; + + /* FM IAM record: + * flux: XXXX-XXX-XXXX-X- = 0xf77a + * clock: X X - X - X X X = 0xd7 + * data: X X X X X X - - = 0xfc + */ + private static final int FM_IAM_RECORD = 0xf77a; + + /* MFM IAM record: + * data: 1 1 1 1 1 1 0 0 = 0xfc + * flux: 01 01 01 01 01 01 00 10 = 0x5552 + */ + private static final int MFM_IAM_RECORD = 0x5552; + + /* MFM record separator: + * 0xA1 is: + * data: 1 0 1 0 0 0 0 1 = 0xa1 + * mfm: 01 00 01 00 10 10 10 01 = 0x44a9 + * special: 01 00 01 00 10 00 10 01 = 0x4489 + * ^^^^^ + * When shifted out of phase, the special 0xa1 byte becomes an illegal + * encoding (you can't do 10 00). So this can't be spoofed by user data. + * + * shifted: 10 00 10 01 00 01 00 1 + * + * It's repeated three times. + */ + private static final int MFM_RECORD_SEPARATOR = 0x4489; + private static final int MFM_RECORD_SEPARATOR_BYTE = 0xa1; + private final IbmEncoderProto config; + private final boolean[] lastBit = new boolean[1]; + private Bits bits; + private Bits.Cursor cursor; + + public IbmEncoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getIbm(); + } + + private static int decodeUint16(int raw) + { + Bytes b = new Bytes(2); + b.writer().writeBe16(raw); + return FmMfm.decodeFmMfm(b.toBits()).getByte(0) & 0xff; + } + + private void writeRawBits(int data, int width) + { + cursor.advance(width); + lastBit[0] = (data & 1) != 0; + for (int i = 0; i < width; i++) + { + int pos = cursor.get() - i - 1; + if (pos < bits.size()) + bits.setBit(pos, (data & 1) != 0); + data >>= 1; + } + } + + private void writeBytes(Bytes bytes, IbmEncoderProto.TrackdataProto trackdata) + { + if (trackdata.getUseFm()) + FmMfm.encodeFm(bits, cursor, bytes); + else + FmMfm.encodeMfm(bits, cursor, bytes, lastBit); + } + + private void writeFillerRawBytes(int count, int byte_) + { + for (int i = 0; i < count; i++) + writeRawBits(byte_, 16); + } + + private void writeFillerBytes(int count, int byte_, IbmEncoderProto.TrackdataProto trackdata) + { + Bytes b = Bytes.of(byte_); + for (int i = 0; i < count; i++) + writeBytes(b, trackdata); + } + + private IbmEncoderProto.TrackdataProto getEncoderTrackData(int track, int head) + { + IbmEncoderProto.TrackdataProto.Builder builder = + IbmEncoderProto.TrackdataProto.newBuilder(); + for (IbmEncoderProto.TrackdataProto f : config.getTrackdataList()) + { + if (f.hasTrack() && (f.getTrack() != track)) + continue; + if (f.hasHead() && (f.getHead() != head)) + continue; + + builder.mergeFrom(f); + } + return builder.build(); + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + IbmEncoderProto.TrackdataProto trackdata = + getEncoderTrackData(ltl.logicalCylinder, ltl.logicalHead); + + double clockRateNs = trackdata.getTargetClockPeriodUs() * 1000.0; + if (!trackdata.getUseFm()) + clockRateNs /= 2.0; + int bitsPerRevolution = + (int) ((trackdata.getTargetRotationalPeriodMs() * 1e6) / clockRateNs); + bits = new Bits(bitsPerRevolution); + cursor = new Bits.Cursor(0); + + int idamUnencoded = decodeUint16(trackdata.getIdamByte()); + int damUnencoded = decodeUint16(trackdata.getDamByte()); + + int sectorSize = 0; + { + int s = ltl.sectorSize >> 7; + while (s > 1) + { + s >>= 1; + sectorSize += 1; + } + } + + int gapFill = trackdata.getGapFillByte(); + + writeFillerRawBytes(trackdata.getGap0(), gapFill); + if (trackdata.getEmitIam()) + { + writeFillerBytes(trackdata.getUseFm() ? 6 : 12, 0x00, trackdata); + if (!trackdata.getUseFm()) + { + for (int i = 0; i < 3; i++) + writeRawBits(MFM_IAM_SEPARATOR, 16); + } + writeRawBits(trackdata.getUseFm() ? FM_IAM_RECORD : MFM_IAM_RECORD, 16); + writeFillerRawBytes(trackdata.getGap1(), gapFill); + } + + boolean first = true; + for (Sector sectorData : sectors) + { + if (!first) + writeFillerRawBytes(trackdata.getGap3(), gapFill); + first = false; + + /* Writing the sector and data records are fantastically annoying. + * The CRC is calculated from the *very start* of the record, and + * include the malformed marker bytes. Our encoder doesn't know + * about this, of course, with the result that we have to construct + * the unencoded header, calculate the checksum, and then use the + * same logic to emit the bytes which require special encoding + * before encoding the rest of the header normally. */ + + { + Bytes header = new Bytes(0); + ByteWriter bw = header.writer(); + + writeFillerBytes(trackdata.getUseFm() ? 6 : 12, 0x00, trackdata); + if (!trackdata.getUseFm()) + { + for (int i = 0; i < 3; i++) + bw.write8(MFM_RECORD_SEPARATOR_BYTE); + } + bw.write8(idamUnencoded); + bw.write8(sectorData.location.logicalCylinder()); + bw.write8(sectorData.location.logicalHead() ^ + (trackdata.getInvertSideByte() ? 1 : 0)); + bw.write8(sectorData.location.logicalSector()); + bw.write8(sectorSize); + int crc = Crc.crc16(Crc.CCITT_POLY, header); + bw.writeBe16(crc); + + int conventionalHeaderStart = 0; + if (!trackdata.getUseFm()) + { + for (int i = 0; i < 3; i++) + writeRawBits(MFM_RECORD_SEPARATOR, 16); + conventionalHeaderStart += 3; + } + writeRawBits(trackdata.getIdamByte(), 16); + conventionalHeaderStart += 1; + + writeBytes(header.slice(conventionalHeaderStart), trackdata); + } + + writeFillerRawBytes(trackdata.getGap2(), gapFill); + + { + Bytes data = new Bytes(0); + ByteWriter bw = data.writer(); + + writeFillerBytes(trackdata.getUseFm() ? 6 : 12, 0x00, trackdata); + if (!trackdata.getUseFm()) + { + for (int i = 0; i < 3; i++) + bw.write8(MFM_RECORD_SEPARATOR_BYTE); + } + bw.write8(damUnencoded); + + Bytes truncatedData = sectorData.data.slice(0, ltl.sectorSize); + bw.write(truncatedData); + int crc = Crc.crc16(Crc.CCITT_POLY, data); + bw.writeBe16(crc); + + int conventionalHeaderStart = 0; + if (!trackdata.getUseFm()) + { + for (int i = 0; i < 3; i++) + writeRawBits(MFM_RECORD_SEPARATOR, 16); + conventionalHeaderStart += 3; + } + writeRawBits(trackdata.getDamByte(), 16); + conventionalHeaderStart += 1; + + writeBytes(data.slice(conventionalHeaderStart), trackdata); + } + } + + if (cursor.get() >= bits.size()) + throw new FluxEngineException("track data overrun"); + while (cursor.get() < bits.size()) + writeFillerRawBytes(1, gapFill); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits( + bits, + (long) calculatePhysicalClockPeriodNs( + clockRateNs, + trackdata.getTargetRotationalPeriodMs() * 1e6)); + return fluxmap; + } +} diff --git a/arch/ibm/decoder.cc b/java/com/cowlark/fluxengine/arch/ibm/decoder.cc similarity index 100% rename from arch/ibm/decoder.cc rename to java/com/cowlark/fluxengine/arch/ibm/decoder.cc diff --git a/arch/ibm/encoder.cc b/java/com/cowlark/fluxengine/arch/ibm/encoder.cc similarity index 100% rename from arch/ibm/encoder.cc rename to java/com/cowlark/fluxengine/arch/ibm/encoder.cc diff --git a/arch/ibm/ibm.h b/java/com/cowlark/fluxengine/arch/ibm/ibm.h similarity index 100% rename from arch/ibm/ibm.h rename to java/com/cowlark/fluxengine/arch/ibm/ibm.h diff --git a/java/com/cowlark/fluxengine/arch/ibm/ibm.proto b/java/com/cowlark/fluxengine/arch/ibm/ibm.proto new file mode 100644 index 000000000..32d988a0d --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/ibm/ibm.proto @@ -0,0 +1,46 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.ibm"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message IbmDecoderProto { + // Next: 11 + message TrackdataProto { + optional int32 track = 7 [(help) = "if set, the format applies only to this track"]; + optional int32 head = 8 [(help) = "if set, the format applies only to this head"]; + + optional bool ignore_side_byte = 2 [default = false, (help) = "ignore side byte in sector header"]; + optional bool ignore_track_byte = 6 [default = false, (help) = "ignore track byte in sector header"]; + optional bool invert_side_byte = 4 [default = false, (help) = "invert the side byte in the sector header"]; + + repeated int32 ignore_sector = 10 [(help) = "sectors with these IDs will not be read"]; + } + + repeated TrackdataProto trackdata = 1; +} + +message IbmEncoderProto { + // Next: 20 + message TrackdataProto { + optional int32 track = 15 [(help) = "if set, the format applies only to this track"]; + optional int32 head = 16 [(help) = "if set, the format applies only to this head"]; + + optional bool emit_iam = 3 [default = true, (help) = "whether to emit an IAM record"]; + optional double target_clock_period_us = 5 [default = 4, (help) = "data clock rate on target disk"]; + optional bool use_fm = 6 [default = false, (help) = "whether to use FM encoding rather than MFM"]; + optional int32 idam_byte = 7 [default = 0x5554, (help) = "16-bit raw bit pattern of IDAM byte"]; + optional int32 dam_byte = 8 [default = 0x5545, (help) = "16-bit raw bit pattern of DAM byte"]; + optional int32 gap0 = 9 [default = 80, (help) = "size of gap 1 (the post-index gap)"]; + optional int32 gap1 = 10 [default = 50, (help) = "size of gap 2 (the post-ID gap)"]; + optional int32 gap2 = 11 [default = 22, (help) = "size of gap 3 (the pre-data gap)"]; + optional int32 gap3 = 12 [default = 80, (help) = "size of gap 4 (the post-data or format gap)"]; + optional bool invert_side_byte = 19 [default = false, (help) = "invert the side byte before writing"]; + optional int32 gap_fill_byte = 18 [default = 0x9254, (help) = "16-bit raw bit pattern of gap fill byte"]; + optional double target_rotational_period_ms = 1 [default = 200, (help) = "rotational period of target disk"]; + } + + repeated TrackdataProto trackdata = 1; +} + diff --git a/java/com/cowlark/fluxengine/arch/macintosh/Macintosh.java b/java/com/cowlark/fluxengine/arch/macintosh/Macintosh.java new file mode 100644 index 000000000..6abd03acd --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/macintosh/Macintosh.java @@ -0,0 +1,20 @@ +package com.cowlark.fluxengine.arch.macintosh; + +/** + * Constants for the Macintosh format, ported from arch/macintosh/macintosh.h. + */ +public final class Macintosh +{ + public static final int MAC_SECTOR_RECORD = 0xd5aa96; /* 1101 0101 1010 1010 1001 0110 */ + public static final int MAC_DATA_RECORD = 0xd5aaad; /* 1101 0101 1010 1010 1010 1101 */ + + public static final int MAC_SECTOR_LENGTH = 524; /* yes, really */ + public static final int MAC_ENCODED_SECTOR_LENGTH = 703; + public static final int MAC_FORMAT_BYTE = 0x22; + + public static final int MAC_TRACKS_PER_DISK = 80; + + private Macintosh() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/macintosh/MacintoshDecoder.java b/java/com/cowlark/fluxengine/arch/macintosh/MacintoshDecoder.java new file mode 100644 index 000000000..15b576b93 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/macintosh/MacintoshDecoder.java @@ -0,0 +1,326 @@ +package com.cowlark.fluxengine.arch.macintosh; + +import static com.cowlark.fluxengine.arch.macintosh.Macintosh.MAC_DATA_RECORD; +import static com.cowlark.fluxengine.arch.macintosh.Macintosh.MAC_ENCODED_SECTOR_LENGTH; +import static com.cowlark.fluxengine.arch.macintosh.Macintosh.MAC_SECTOR_LENGTH; +import static com.cowlark.fluxengine.arch.macintosh.Macintosh.MAC_SECTOR_RECORD; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; + +/** + * The Macintosh decoder, ported from arch/macintosh/decoder.cc. + */ +public class MacintoshDecoder extends Decoder +{ + private static final FluxPattern SECTOR_RECORD_PATTERN = new FluxPattern(24, MAC_SECTOR_RECORD); + private static final FluxPattern DATA_RECORD_PATTERN = new FluxPattern(24, MAC_DATA_RECORD); + private static final FluxMatchers ANY_RECORD_PATTERN = + FluxMatchers.of(SECTOR_RECORD_PATTERN, DATA_RECORD_PATTERN); + + public MacintoshDecoder(DecoderProto config) + { + super(config); + } + + private static int decodeDataGcr(int gcr) + { + switch (gcr) + { + case 0x96: + return 0x00; + case 0x97: + return 0x01; + case 0x9a: + return 0x02; + case 0x9b: + return 0x03; + case 0x9d: + return 0x04; + case 0x9e: + return 0x05; + case 0x9f: + return 0x06; + case 0xa6: + return 0x07; + case 0xa7: + return 0x08; + case 0xab: + return 0x09; + case 0xac: + return 0x0a; + case 0xad: + return 0x0b; + case 0xae: + return 0x0c; + case 0xaf: + return 0x0d; + case 0xb2: + return 0x0e; + case 0xb3: + return 0x0f; + case 0xb4: + return 0x10; + case 0xb5: + return 0x11; + case 0xb6: + return 0x12; + case 0xb7: + return 0x13; + case 0xb9: + return 0x14; + case 0xba: + return 0x15; + case 0xbb: + return 0x16; + case 0xbc: + return 0x17; + case 0xbd: + return 0x18; + case 0xbe: + return 0x19; + case 0xbf: + return 0x1a; + case 0xcb: + return 0x1b; + case 0xcd: + return 0x1c; + case 0xce: + return 0x1d; + case 0xcf: + return 0x1e; + case 0xd3: + return 0x1f; + case 0xd6: + return 0x20; + case 0xd7: + return 0x21; + case 0xd9: + return 0x22; + case 0xda: + return 0x23; + case 0xdb: + return 0x24; + case 0xdc: + return 0x25; + case 0xdd: + return 0x26; + case 0xde: + return 0x27; + case 0xdf: + return 0x28; + case 0xe5: + return 0x29; + case 0xe6: + return 0x2a; + case 0xe7: + return 0x2b; + case 0xe9: + return 0x2c; + case 0xea: + return 0x2d; + case 0xeb: + return 0x2e; + case 0xec: + return 0x2f; + case 0xed: + return 0x30; + case 0xee: + return 0x31; + case 0xef: + return 0x32; + case 0xf2: + return 0x33; + case 0xf3: + return 0x34; + case 0xf4: + return 0x35; + case 0xf5: + return 0x36; + case 0xf6: + return 0x37; + case 0xf7: + return 0x38; + case 0xf9: + return 0x39; + case 0xfa: + return 0x3a; + case 0xfb: + return 0x3b; + case 0xfc: + return 0x3c; + case 0xfd: + return 0x3d; + case 0xfe: + return 0x3e; + case 0xff: + return 0x3f; + default: + return -1; + } + } + + /* This is extremely inspired by the MESS implementation, written by Nathan + * Woods and R. Belmont: + * https://github.com/mamedev/mame/blob/4263a71e64377db11392c458b580c5ae83556bc7/src/lib + * /formats/ap_dsk35.cpp + */ + private static Bytes decodeCrazyData(Bytes input, Sector.Status[] status) + { + Bytes output = new Bytes(); + ByteWriter bw = new ByteWriter(output); + ByteReader br = input.iterator(); + + int lookupLen = MAC_SECTOR_LENGTH / 3; + + int[] b1 = new int[lookupLen + 1]; + int[] b2 = new int[lookupLen + 1]; + int[] b3 = new int[lookupLen + 1]; + + for (int i = 0; i <= lookupLen; i++) + { + int w4 = br.read8(); + int w1 = br.read8(); + int w2 = br.read8(); + int w3 = (i != 174) ? br.read8() : 0; + + b1[i] = (w1 & 0x3F) | ((w4 << 2) & 0xC0); + b2[i] = (w2 & 0x3F) | ((w4 << 4) & 0xC0); + b3[i] = (w3 & 0x3F) | ((w4 << 6) & 0xC0); + } + + /* Copy from the user's buffer to our buffer, while computing the + * three-byte data checksum. */ + + int c1 = 0; + int c2 = 0; + int c3 = 0; + int count = 0; + for (; ; ) + { + c1 = (c1 & 0xFF) << 1; + if ((c1 & 0x0100) != 0) + c1++; + + int val = (b1[count] ^ c1) & 0xFF; + c3 += val; + if ((c1 & 0x0100) != 0) + { + c3++; + c1 &= 0xFF; + } + bw.write8(val); + + val = (b2[count] ^ c3) & 0xFF; + c2 += val; + if (c3 > 0xFF) + { + c2++; + c3 &= 0xFF; + } + bw.write8(val); + + if (output.size() == 524) + break; + + val = (b3[count] ^ c2) & 0xFF; + c1 += val; + if (c2 > 0xFF) + { + c1++; + c2 &= 0xFF; + } + bw.write8(val); + count++; + } + + int c4 = ((c1 & 0xC0) >> 6) | ((c2 & 0xC0) >> 4) | ((c3 & 0xC0) >> 2); + c1 &= 0x3f; + c2 &= 0x3f; + c3 &= 0x3f; + c4 &= 0x3f; + int g4 = br.read8(); + int g3 = br.read8(); + int g2 = br.read8(); + int g1 = br.read8(); + if ((g4 == c4) && (g3 == c3) && (g2 == c2) && (g1 == c1)) + status[0] = Sector.Status.OK; + + return output; + } + + private static int decodeSide(int side) + { + /* Mac disks, being weird, use the side byte to encode both the side + * (in bit 5) and also whether we're above track 0x3f (in bit 0). */ + + return (side & 0x20) != 0 ? 1 : 0; + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(ANY_RECORD_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + if (readRaw24() != MAC_SECTOR_RECORD) + return; + + /* Read header. */ + + Bytes header = readRawBits(7 * 8).toBytes().slice(0, 7); + + int encodedTrack = decodeDataGcr(header.getByte(0)); + if (encodedTrack != (ltl.logicalCylinder & 0x3f)) + return; + + int encodedSector = decodeDataGcr(header.getByte(1)); + int encodedSide = decodeDataGcr(header.getByte(2)); + int formatByte = decodeDataGcr(header.getByte(3)); + int wantedsum = decodeDataGcr(header.getByte(4)); + + if (encodedSector > 11) + return; + + int logicalCylinder = ltl.logicalCylinder; + int logicalHead = decodeSide(encodedSide); + int logicalSector = encodedSector; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + int gotsum = (encodedTrack ^ encodedSector ^ encodedSide ^ formatByte) & 0x3f; + if (wantedsum == gotsum) + sector.status = Sector.Status.DATA_MISSING; + } + + @Override + protected void decodeDataRecord() + { + if (readRaw24() != MAC_DATA_RECORD) + return; + + /* Read data. */ + + readRawBits(8); /* skip spare byte */ + Bytes inputbuffer = readRawBits(MAC_ENCODED_SECTOR_LENGTH * 8).toBytes() + .slice(0, MAC_ENCODED_SECTOR_LENGTH); + + for (int i = 0; i < inputbuffer.size(); i++) + inputbuffer.setByte(i, decodeDataGcr(inputbuffer.getByte(i))); + + Sector.Status[] status = {Sector.Status.BAD_CHECKSUM}; + sector.status = status[0]; + Bytes userData = decodeCrazyData(inputbuffer, status); + sector.status = status[0]; + sector.data = new Bytes(); + sector.data.writer().write(userData.slice(12, 512)).write(userData.slice(0, 12)); + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/macintosh/MacintoshEncoder.java b/java/com/cowlark/fluxengine/arch/macintosh/MacintoshEncoder.java new file mode 100644 index 000000000..57c763143 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/macintosh/MacintoshEncoder.java @@ -0,0 +1,310 @@ +package com.cowlark.fluxengine.arch.macintosh; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +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.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.macintosh.MacintoshEncoderProto; +import java.util.List; + +/** + * The Macintosh encoder, ported from arch/macintosh/encoder.cc. + */ +public class MacintoshEncoder extends Encoder +{ + private static final int[] ENCODE_DATA_GCR = new int[64]; + + static + { + ENCODE_DATA_GCR[0x00] = 0x96; + ENCODE_DATA_GCR[0x01] = 0x97; + ENCODE_DATA_GCR[0x02] = 0x9a; + ENCODE_DATA_GCR[0x03] = 0x9b; + ENCODE_DATA_GCR[0x04] = 0x9d; + ENCODE_DATA_GCR[0x05] = 0x9e; + ENCODE_DATA_GCR[0x06] = 0x9f; + ENCODE_DATA_GCR[0x07] = 0xa6; + ENCODE_DATA_GCR[0x08] = 0xa7; + ENCODE_DATA_GCR[0x09] = 0xab; + ENCODE_DATA_GCR[0x0a] = 0xac; + ENCODE_DATA_GCR[0x0b] = 0xad; + ENCODE_DATA_GCR[0x0c] = 0xae; + ENCODE_DATA_GCR[0x0d] = 0xaf; + ENCODE_DATA_GCR[0x0e] = 0xb2; + ENCODE_DATA_GCR[0x0f] = 0xb3; + ENCODE_DATA_GCR[0x10] = 0xb4; + ENCODE_DATA_GCR[0x11] = 0xb5; + ENCODE_DATA_GCR[0x12] = 0xb6; + ENCODE_DATA_GCR[0x13] = 0xb7; + ENCODE_DATA_GCR[0x14] = 0xb9; + ENCODE_DATA_GCR[0x15] = 0xba; + ENCODE_DATA_GCR[0x16] = 0xbb; + ENCODE_DATA_GCR[0x17] = 0xbc; + ENCODE_DATA_GCR[0x18] = 0xbd; + ENCODE_DATA_GCR[0x19] = 0xbe; + ENCODE_DATA_GCR[0x1a] = 0xbf; + ENCODE_DATA_GCR[0x1b] = 0xcb; + ENCODE_DATA_GCR[0x1c] = 0xcd; + ENCODE_DATA_GCR[0x1d] = 0xce; + ENCODE_DATA_GCR[0x1e] = 0xcf; + ENCODE_DATA_GCR[0x1f] = 0xd3; + ENCODE_DATA_GCR[0x20] = 0xd6; + ENCODE_DATA_GCR[0x21] = 0xd7; + ENCODE_DATA_GCR[0x22] = 0xd9; + ENCODE_DATA_GCR[0x23] = 0xda; + ENCODE_DATA_GCR[0x24] = 0xdb; + ENCODE_DATA_GCR[0x25] = 0xdc; + ENCODE_DATA_GCR[0x26] = 0xdd; + ENCODE_DATA_GCR[0x27] = 0xde; + ENCODE_DATA_GCR[0x28] = 0xdf; + ENCODE_DATA_GCR[0x29] = 0xe5; + ENCODE_DATA_GCR[0x2a] = 0xe6; + ENCODE_DATA_GCR[0x2b] = 0xe7; + ENCODE_DATA_GCR[0x2c] = 0xe9; + ENCODE_DATA_GCR[0x2d] = 0xea; + ENCODE_DATA_GCR[0x2e] = 0xeb; + ENCODE_DATA_GCR[0x2f] = 0xec; + ENCODE_DATA_GCR[0x30] = 0xed; + ENCODE_DATA_GCR[0x31] = 0xee; + ENCODE_DATA_GCR[0x32] = 0xef; + ENCODE_DATA_GCR[0x33] = 0xf2; + ENCODE_DATA_GCR[0x34] = 0xf3; + ENCODE_DATA_GCR[0x35] = 0xf4; + ENCODE_DATA_GCR[0x36] = 0xf5; + ENCODE_DATA_GCR[0x37] = 0xf6; + ENCODE_DATA_GCR[0x38] = 0xf7; + ENCODE_DATA_GCR[0x39] = 0xf9; + ENCODE_DATA_GCR[0x3a] = 0xfa; + ENCODE_DATA_GCR[0x3b] = 0xfb; + ENCODE_DATA_GCR[0x3c] = 0xfc; + ENCODE_DATA_GCR[0x3d] = 0xfd; + ENCODE_DATA_GCR[0x3e] = 0xfe; + ENCODE_DATA_GCR[0x3f] = 0xff; + } + + private final MacintoshEncoderProto config; + + public MacintoshEncoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getMacintosh(); + } + + private static int encodeDataGcr(int data) + { + if (data < 0 || data >= ENCODE_DATA_GCR.length) + return -1; + return ENCODE_DATA_GCR[data]; + } + + private static double clockRateUsForTrack(int track) + { + if (track < 16) + return 2.63; + if (track < 32) + return 2.89; + if (track < 48) + return 3.20; + if (track < 64) + return 3.57; + return 3.98; + } + + @SuppressWarnings("unused") + private static int sectorsForTrack(int track) + { + if (track < 16) + return 12; + if (track < 32) + return 11; + if (track < 48) + return 10; + if (track < 64) + return 9; + return 8; + } + + /* This is extremely inspired by the MESS implementation, written by Nathan + * Woods and R. Belmont: + * https://github.com/mamedev/mame/blob/4263a71e64377db11392c458b580c5ae83556bc7/src/lib + * /formats/ap_dsk35.cpp + */ + private static Bytes encodeCrazyData(Bytes input) + { + Bytes output = new Bytes(0); + ByteWriter bw = output.writer(); + ByteReader br = new ByteReader(input); + + final int LOOKUP_LEN = Macintosh.MAC_SECTOR_LENGTH / 3; + + int[] b1 = new int[LOOKUP_LEN + 1]; + int[] b2 = new int[LOOKUP_LEN + 1]; + int[] b3 = new int[LOOKUP_LEN + 1]; + + int c1 = 0; + int c2 = 0; + int c3 = 0; + for (int j = 0; ; j++) + { + c1 = (c1 & 0xff) << 1; + if ((c1 & 0x0100) != 0) + c1++; + + int val = br.read8(); + c3 += val; + if ((c1 & 0x0100) != 0) + { + c3++; + c1 &= 0xff; + } + b1[j] = (val ^ c1) & 0xff; + + val = br.read8(); + c2 += val; + if (c3 > 0xff) + { + c2++; + c3 &= 0xff; + } + b2[j] = (val ^ c3) & 0xff; + + if (br.pos() == 524) + break; + + val = br.read8(); + c1 += val; + if (c2 > 0xff) + { + c1++; + c2 &= 0xff; + } + b3[j] = (val ^ c2) & 0xff; + } + int c4 = ((c1 & 0xc0) >> 6) | ((c2 & 0xc0) >> 4) | ((c3 & 0xc0) >> 2); + b3[LOOKUP_LEN] = 0; + + for (int i = 0; i <= LOOKUP_LEN; i++) + { + int w1 = b1[i] & 0x3f; + int w2 = b2[i] & 0x3f; + int w3 = b3[i] & 0x3f; + int w4 = (b1[i] & 0xc0) >> 2; + w4 |= (b2[i] & 0xc0) >> 4; + w4 |= (b3[i] & 0xc0) >> 6; + + bw.write8(w4); + bw.write8(w1); + bw.write8(w2); + + if (i != LOOKUP_LEN) + bw.write8(w3); + } + + bw.write8(c4 & 0x3f); + bw.write8(c3 & 0x3f); + bw.write8(c2 & 0x3f); + bw.write8(c1 & 0x3f); + + return output; + } + + private static void writeBits(Bits bits, Bits.Cursor cursor, boolean[] src) + { + for (boolean bit : src) + { + if (cursor.get() < bits.size()) + bits.setBit(cursor.get(), bit); + cursor.advance(); + } + } + + private static void writeBits(Bits bits, Bits.Cursor cursor, long data, int width) + { + cursor.advance(width); + for (int i = 0; i < width; i++) + { + int pos = cursor.get() - i - 1; + if (pos < bits.size()) + bits.setBit(pos, (data & 1) != 0); + data >>= 1; + } + } + + private static int encodeSide(int track, int side) + { + /* Mac disks, being weird, use the side byte to encode both the side (in + * bit 5) and also whether we're above track 0x3f (in bit 0). + */ + + return (side != 0 ? 0x20 : 0x00) | ((track > 0x3f) ? 0x01 : 0x00); + } + + private static void writeSector(Bits bits, Bits.Cursor cursor, Sector sector) + { + if ((sector.data.size() != 512) && (sector.data.size() != 524)) + throw new FluxEngineException("unsupported sector size --- you must pick 512 or 524"); + + writeBits(bits, cursor, 0xff, 1 * 8); /* pad byte */ + for (int i = 0; i < 7; i++) + writeBits(bits, cursor, 0xff3fcff3fcffL, 6 * 8); /* sync */ + writeBits(bits, cursor, Macintosh.MAC_SECTOR_RECORD, 3 * 8); + + int encodedTrack = sector.location.logicalCylinder() & 0x3f; + int encodedSector = sector.location.logicalSector(); + int encodedSide = + encodeSide(sector.location.logicalCylinder(), sector.location.logicalHead()); + int formatByte = Macintosh.MAC_FORMAT_BYTE; + int headerChecksum = (encodedTrack ^ encodedSector ^ encodedSide ^ formatByte) & 0x3f; + + writeBits(bits, cursor, encodeDataGcr(encodedTrack), 1 * 8); + writeBits(bits, cursor, encodeDataGcr(encodedSector), 1 * 8); + writeBits(bits, cursor, encodeDataGcr(encodedSide), 1 * 8); + writeBits(bits, cursor, encodeDataGcr(formatByte), 1 * 8); + writeBits(bits, cursor, encodeDataGcr(headerChecksum), 1 * 8); + + writeBits(bits, cursor, 0xdeaaff, 3 * 8); + writeBits(bits, cursor, 0xff3fcff3fcffL, 6 * 8); /* sync */ + writeBits(bits, cursor, Macintosh.MAC_DATA_RECORD, 3 * 8); + writeBits(bits, cursor, encodeDataGcr(sector.location.logicalSector()), 1 * 8); + + Bytes wireData = sector.data.slice(512, 12).concat(sector.data.slice(0, 512)); + Bytes crazy = encodeCrazyData(wireData); + for (int i = 0; i < crazy.size(); i++) + writeBits(bits, cursor, encodeDataGcr(crazy.getByte(i) & 0xff), 1 * 8); + + writeBits(bits, cursor, 0xdeaaff, 3 * 8); + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + double clockRateUs = clockRateUsForTrack(ltl.logicalCylinder); + int bitsPerRevolution = (int) (200000.0 / clockRateUs); + Bits bits = new Bits(bitsPerRevolution); + Bits.Cursor cursor = new Bits.Cursor(0); + + bits.fillBitmapTo( + cursor, + (int) (config.getPostIndexGapUs() / clockRateUs), + new boolean[]{true, false}); + + for (Sector sector : sectors) + writeSector(bits, cursor, sector); + + if (cursor.get() >= bits.size()) + throw new FluxEngineException( + "track data overrun by " + (cursor.get() - bits.size()) + " bits"); + bits.fillBitmapTo(cursor, bits.size(), new boolean[]{true, false}); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits(bits, (long) calculatePhysicalClockPeriodNs(clockRateUs * 1e3, 200e6)); + return fluxmap; + } +} diff --git a/arch/macintosh/data_gcr.h b/java/com/cowlark/fluxengine/arch/macintosh/data_gcr.h similarity index 100% rename from arch/macintosh/data_gcr.h rename to java/com/cowlark/fluxengine/arch/macintosh/data_gcr.h diff --git a/arch/macintosh/decoder.cc b/java/com/cowlark/fluxengine/arch/macintosh/decoder.cc similarity index 100% rename from arch/macintosh/decoder.cc rename to java/com/cowlark/fluxengine/arch/macintosh/decoder.cc diff --git a/arch/macintosh/encoder.cc b/java/com/cowlark/fluxengine/arch/macintosh/encoder.cc similarity index 100% rename from arch/macintosh/encoder.cc rename to java/com/cowlark/fluxengine/arch/macintosh/encoder.cc diff --git a/arch/macintosh/macintosh.h b/java/com/cowlark/fluxengine/arch/macintosh/macintosh.h similarity index 100% rename from arch/macintosh/macintosh.h rename to java/com/cowlark/fluxengine/arch/macintosh/macintosh.h diff --git a/java/com/cowlark/fluxengine/arch/macintosh/macintosh.proto b/java/com/cowlark/fluxengine/arch/macintosh/macintosh.proto new file mode 100644 index 000000000..395254b8a --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/macintosh/macintosh.proto @@ -0,0 +1,14 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.macintosh"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message MacintoshDecoderProto {} + +message MacintoshEncoderProto { + optional double post_index_gap_us = 1 [default = 0.0, + (help) = "post-index gap before first sector header (microseconds)."]; +} + diff --git a/java/com/cowlark/fluxengine/arch/micropolis/Micropolis.java b/java/com/cowlark/fluxengine/arch/micropolis/Micropolis.java new file mode 100644 index 000000000..18fb291b1 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/micropolis/Micropolis.java @@ -0,0 +1,16 @@ +package com.cowlark.fluxengine.arch.micropolis; + +/** + * Constants for the Micropolis format, ported from arch/micropolis/micropolis.h. + */ +public final class Micropolis +{ + public static final int MICROPOLIS_PAYLOAD_SIZE = (256); + public static final int MICROPOLIS_HEADER_SIZE = (1 + 2 + 10); + public static final int MICROPOLIS_ENCODED_SECTOR_SIZE = + (MICROPOLIS_HEADER_SIZE + MICROPOLIS_PAYLOAD_SIZE + 6); + + private Micropolis() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/micropolis/MicropolisDecoder.java b/java/com/cowlark/fluxengine/arch/micropolis/MicropolisDecoder.java new file mode 100644 index 000000000..e77502d71 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/micropolis/MicropolisDecoder.java @@ -0,0 +1,270 @@ +package com.cowlark.fluxengine.arch.micropolis; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.FmMfm; +import com.cowlark.fluxengine.micropolis.MicropolisDecoderProto; + +/** + * The Micropolis decoder, ported from arch/micropolis/decoder.cc. + */ +public class MicropolisDecoder extends Decoder +{ + /* The sector has a preamble of MFM 0x00s and uses 0xFF as a sync pattern. + * + * 00 00 00 F F + * 0000 0000 0000 0000 0000 0000 0101 0101 0101 0101 + * A A A A A A 5 5 5 5 + */ + private static final FluxPattern SECTOR_SYNC_PATTERN = new FluxPattern(64, 0xAAAAAAAAAAAA5555L); + + /* Pattern to skip past current SYNC. */ + private static final FluxPattern SECTOR_ADVANCE_PATTERN = + new FluxPattern(64, 0xAAAAAAAAAAAAAAAAL); + private final MicropolisDecoderProto config; + private MicropolisDecoderProto.ChecksumType checksumType; + + public MicropolisDecoder(DecoderProto config) + { + super(config); + this.config = config.getMicropolis(); + checksumType = this.config.getChecksumType(); + } + + /* Standard Micropolis checksum. Adds all bytes, with carry. */ + public static int micropolisChecksum(Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + int sum = 0; + while (!br.eof()) + { + if (sum > 0xFF) + { + sum -= 0x100 - 1; + } + sum += br.read8(); + } + /* The last carry is ignored. */ + return sum & 0xFF; + } + + /* Vector MZOS does not use the standard Micropolis checksum. */ + public static int mzosChecksum(Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + int checksum = 0; + + while (!br.eof()) + { + int databyte = br.read8(); + checksum ^= ((databyte << 1) | (databyte >>> 7)) & 0xff; + } + + return checksum; + } + + private static int b(int field, int pos) + { + return (field >>> pos) & 1; + } + + private static int eccNextBit(int ecc, int dataBit) + { + /* This is 0x81932080 which is 0x0104C981 with reversed bits. */ + return b(ecc, 7) ^ b(ecc, 13) ^ b(ecc, 16) ^ b(ecc, 17) ^ b(ecc, 20) ^ b(ecc, 23) ^ + b(ecc, 24) ^ b(ecc, 31) ^ dataBit; + } + + public static int vectorGraphicEcc(Bytes bytes) + { + int e = 0; + Bytes payloadBytes = bytes.slice(0, bytes.size() - 4); + ByteReader payload = new ByteReader(payloadBytes); + while (!payload.eof()) + { + int byte0 = payload.read8(); + for (int i = 0; i < 8; i++) + { + e = (e << 1) | eccNextBit(e, byte0 >>> 7); + byte0 <<= 1; + } + } + Bytes trailerBytes = bytes.slice(bytes.size() - 4); + ByteReader trailer = new ByteReader(trailerBytes); + int res = e; + while (!trailer.eof()) + { + int byte0 = trailer.read8(); + for (int i = 0; i < 8; i++) + { + res = (res << 1) | eccNextBit(e, byte0 >>> 7); + e <<= 1; + byte0 <<= 1; + } + } + return res; + } + + /* Fixes bytes when possible, returning true if changed. */ + private static boolean vectorGraphicEccFix(Bytes bytes, int syndrome) + { + int ecc = syndrome; + int pos = (Micropolis.MICROPOLIS_ENCODED_SECTOR_SIZE - 5) * 8 + 7; + boolean aligned = false; + while ((ecc & 0xff000000) == 0) + { + pos += 8; + ecc <<= 8; + } + for (; pos >= 0; pos--) + { + boolean bit = (ecc & 1) != 0; + ecc >>>= 1; + if (bit) + ecc ^= 0x808264c0; + if ((ecc & 0xff07ffff) == 0) + aligned = true; + if (aligned && pos % 8 == 0) + break; + } + if (pos < 0) + return false; + bytes.setByte(pos / 8, (byte) (bytes.getByte(pos / 8) ^ (ecc >>> 16))); + return true; + } + + @Override + protected double advanceToNextRecord() + { + double now = tell().getDurationNs(); + + /* For all but the first sector, seek to the next sector pulse. The + * first sector does not contain the sector pulse in the fluxmap. */ + if (now != 0) + { + seekToIndexMark(); + now = tell().getDurationNs(); + } + + /* Discard a possible partial sector at the end of the track. */ + if (now > (getFluxmapDuration() - 12.0e6)) + { + seekToIndexMark(); + return 0; + } + + double clock = seekToPattern(SECTOR_SYNC_PATTERN); + + double syncDelta = tell().getDurationNs() - now; + /* Due to the weak nature of the Micropolis SYNC pattern, it's possible + * to detect a false SYNC during the gap between the sector pulse and + * the write gate. */ + if ((syncDelta > 0) && (syncDelta < 100e3)) + { + seekToPattern(SECTOR_ADVANCE_PATTERN); + clock = seekToPattern(SECTOR_SYNC_PATTERN); + } + + sector.headerStartTimeNs = tell().getDurationNs(); + + /* seekToPattern() can skip past the index hole, if this happens too + * close to the end of the Fluxmap, discard the sector. */ + if (sector.headerStartTimeNs > (getFluxmapDuration() - 11.3e6)) + { + return 0; + } + + return clock; + } + + @Override + protected void decodeSectorRecord() + { + readRawBits(48); + com.cowlark.fluxengine.core.Bits rawbits = + readRawBits(Micropolis.MICROPOLIS_ENCODED_SECTOR_SIZE * 16); + Bytes bytes = + FmMfm.decodeFmMfm(rawbits).slice(0, Micropolis.MICROPOLIS_ENCODED_SECTOR_SIZE); + + boolean eccPresent = (bytes.getByte(274) & 0xff) == 0xaa; + int ecc = 0; + if (config.getEccType() == MicropolisDecoderProto.EccType.VECTOR && eccPresent) + { + ecc = vectorGraphicEcc(bytes.slice(0, 274)); + if (ecc != 0) + { + vectorGraphicEccFix(bytes, ecc); + ecc = vectorGraphicEcc(bytes.slice(0, 274)); + } + } + + ByteReader br = bytes.iterator(); + + int syncByte = br.read8(); /* sync */ + if (syncByte != 0xFF) + return; + + int logicalCylinder = br.read8(); + int logicalHead = ltl.logicalHead; + int logicalSector = br.read8(); + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + if (logicalSector > 15) + return; + if (logicalCylinder > 76) + return; + if (logicalCylinder != ltl.logicalCylinder) + return; + + br.read(10); /* OS data or padding */ + Bytes data = br.read(Micropolis.MICROPOLIS_PAYLOAD_SIZE); + int wantChecksum = br.read8(); + + /* If not specified, automatically determine the checksum type. */ + if (checksumType == MicropolisDecoderProto.ChecksumType.AUTO) + { + /* Calculate both standard Micropolis (MDOS, CP/M, OASIS) and MZOS + * checksums. */ + if (wantChecksum == micropolisChecksum(bytes.slice(1, 2 + 266))) + { + checksumType = MicropolisDecoderProto.ChecksumType.MICROPOLIS; + } else if (wantChecksum == mzosChecksum(bytes.slice( + Micropolis.MICROPOLIS_HEADER_SIZE, + Micropolis.MICROPOLIS_PAYLOAD_SIZE))) + { + checksumType = MicropolisDecoderProto.ChecksumType.MZOS; + System.out.println("Note: MZOS checksum detected."); + } + } + + int gotChecksum; + + if (checksumType == MicropolisDecoderProto.ChecksumType.MZOS) + { + gotChecksum = mzosChecksum(bytes.slice( + Micropolis.MICROPOLIS_HEADER_SIZE, + Micropolis.MICROPOLIS_PAYLOAD_SIZE)); + } else + { + gotChecksum = micropolisChecksum(bytes.slice(1, 2 + 266)); + } + + br.read(5); /* 4 byte ECC and ECC-present flag */ + + if (config.getSectorOutputSize() == Micropolis.MICROPOLIS_PAYLOAD_SIZE) + sector.data = data; + else if (config.getSectorOutputSize() == Micropolis.MICROPOLIS_ENCODED_SECTOR_SIZE) + sector.data = bytes; + else + throw new FluxEngineException("Sector output size may only be 256 or 275"); + if (wantChecksum == gotChecksum && (!eccPresent || ecc == 0)) + sector.status = Sector.Status.OK; + else + sector.status = Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/micropolis/MicropolisEncoder.java b/java/com/cowlark/fluxengine/arch/micropolis/MicropolisEncoder.java new file mode 100644 index 000000000..c7a269ae7 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/micropolis/MicropolisEncoder.java @@ -0,0 +1,144 @@ +package com.cowlark.fluxengine.arch.micropolis; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +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.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.external.FmMfm; +import com.cowlark.fluxengine.micropolis.MicropolisEncoderProto; +import java.util.ArrayList; +import java.util.List; + +/** + * The Micropolis encoder, ported from arch/micropolis/encoder.cc. + */ +public class MicropolisEncoder extends Encoder +{ + private final MicropolisEncoderProto config; + + public MicropolisEncoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getMicropolis(); + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + int bitsPerRevolution = + (int) ((config.getRotationalPeriodMs() * 1e3) / config.getClockPeriodUs()); + + Bits bits = new Bits(bitsPerRevolution); + List indexes = new ArrayList<>(); + int prevCursor = 0; + Bits.Cursor cursor = new Bits.Cursor(0); + + for (Sector sectorData : sectors) + { + indexes.add(cursor.get()); + prevCursor = cursor.get(); + writeSector(bits, cursor, sectorData, config.getEccType()); + } + indexes.add(prevCursor + (cursor.get() - prevCursor) / 2); + indexes.add(cursor.get()); + + if (cursor.get() != bits.size()) + throw new FluxEngineException("track data mismatched length"); + + Fluxmap fluxmap = new Fluxmap(); + long clockPeriod = (long) calculatePhysicalClockPeriodNs( + config.getClockPeriodUs() * 1e3, + config.getRotationalPeriodMs() * 1e6); + int pos = 0; + for (int i = 1; i < indexes.size(); i++) + { + int end = indexes.get(i); + fluxmap.appendBits(bits.subBits(pos, end), clockPeriod); + fluxmap.appendIndex(); + pos = end; + } + return fluxmap; + } + + private void writeSector(Bits bits, + Bits.Cursor cursor, + Sector sector, + MicropolisEncoderProto.EccType eccType) + { + if ((sector.data.size() != 256) && + (sector.data.size() != Micropolis.MICROPOLIS_ENCODED_SECTOR_SIZE)) + throw new FluxEngineException("unsupported sector size --- you must pick 256 or 275"); + + int fullSectorSize = 40 + Micropolis.MICROPOLIS_ENCODED_SECTOR_SIZE + 40 + 35; + Bytes fullSector = new Bytes(0); + ByteWriter fullSectorWriter = fullSector.writer(); + + /* sector preamble */ + for (int i = 0; i < 40; i++) + fullSectorWriter.write8(0); + + Bytes sectorData; + if (sector.data.size() == Micropolis.MICROPOLIS_ENCODED_SECTOR_SIZE) + { + if ((sector.data.getByte(0) & 0xff) != 0xFF) + throw new FluxEngineException( + "275 byte sector doesn't start with sync byte 0xFF. Corrupted sector"); + int wantChecksum = sector.data.getByte(1 + 2 + 266) & 0xff; + int gotChecksum = MicropolisDecoder.micropolisChecksum(sector.data.slice(1, 2 + 266)); + if (wantChecksum != gotChecksum) + System.err.println( + "Warning: checksum incorrect. Sector: " + sector.location.logicalSector()); + sectorData = sector.data; + } else + { + sectorData = new Bytes(0); + ByteWriter writer = sectorData.writer(); + writer.write8(0xff); /* Sync */ + writer.write8(sector.location.logicalCylinder()); + writer.write8(sector.location.logicalSector()); + for (int i = 0; i < 10; i++) + writer.write8(0); /* Padding */ + writer.write(sector.data); + writer.write8(MicropolisDecoder.micropolisChecksum(sectorData.slice(1))); + + int eccPresent = 0; + int ecc = 0; + if (eccType == MicropolisEncoderProto.EccType.VECTOR) + { + eccPresent = 0xaa; + ecc = MicropolisDecoder.vectorGraphicEcc(sectorData.concat(new Bytes(4))); + } + writer.writeBe32(ecc); + writer.write8(eccPresent); + } + + fullSectorWriter.write(sectorData); + + /* sector postamble */ + for (int i = 0; i < 40; i++) + fullSectorWriter.write8(0); + /* filler */ + for (int i = 0; i < 35; i++) + fullSectorWriter.write8(0); + + if (fullSector.size() != fullSectorSize) + throw new FluxEngineException("sector mismatched length"); + + boolean[] lastBit = {false}; + FmMfm.encodeMfm(bits, cursor, fullSector, lastBit); + /* filler */ + for (int i = 0; i < 5; i++) + { + bits.setBit(cursor.get(), true); + cursor.advance(); + bits.setBit(cursor.get(), false); + cursor.advance(); + } + } +} diff --git a/arch/micropolis/decoder.cc b/java/com/cowlark/fluxengine/arch/micropolis/decoder.cc similarity index 100% rename from arch/micropolis/decoder.cc rename to java/com/cowlark/fluxengine/arch/micropolis/decoder.cc diff --git a/arch/micropolis/encoder.cc b/java/com/cowlark/fluxengine/arch/micropolis/encoder.cc similarity index 100% rename from arch/micropolis/encoder.cc rename to java/com/cowlark/fluxengine/arch/micropolis/encoder.cc diff --git a/arch/micropolis/micropolis.h b/java/com/cowlark/fluxengine/arch/micropolis/micropolis.h similarity index 100% rename from arch/micropolis/micropolis.h rename to java/com/cowlark/fluxengine/arch/micropolis/micropolis.h diff --git a/java/com/cowlark/fluxengine/arch/micropolis/micropolis.proto b/java/com/cowlark/fluxengine/arch/micropolis/micropolis.proto new file mode 100644 index 000000000..e43523203 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/micropolis/micropolis.proto @@ -0,0 +1,40 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.micropolis"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message MicropolisDecoderProto { + enum ChecksumType { + AUTO = 0; + MICROPOLIS = 1; + MZOS = 2; + } + enum EccType { + NONE = 0; + VECTOR = 1; + } + + optional int32 sector_output_size = 1 [default = 256, + (help) = "How much of the raw sector should be saved. Must be 256 or 275"]; + optional ChecksumType checksum_type = 2 [default = AUTO, + (help) = "Checksum type to use: AUTO, MICROPOLIS, MZOS"]; + optional EccType ecc_type = 3 [default = NONE, + (help) = "ECC type to use: NONE, VECTOR"]; +} + +message MicropolisEncoderProto { + enum EccType { + NONE = 0; + VECTOR = 1; + } + + optional double clock_period_us = 1 + [default = 2.0, (help) = "clock rate on the real device"]; + optional double rotational_period_ms = 2 + [default = 200.0, (help) = "rotational period on the real device"]; + optional EccType ecc_type = 3 [default = NONE, + (help) = "ECC type to use for IMG data: NONE, VECTOR"]; +} + diff --git a/java/com/cowlark/fluxengine/arch/mx/Mx.java b/java/com/cowlark/fluxengine/arch/mx/Mx.java new file mode 100644 index 000000000..bc25f7e9d --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/mx/Mx.java @@ -0,0 +1,13 @@ +package com.cowlark.fluxengine.arch.mx; + +/** + * Constants for the MX format, ported from arch/mx/mx.h. + */ +public final class Mx +{ + public static final int SECTOR_SIZE = 256; + + private Mx() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/mx/MxDecoder.java b/java/com/cowlark/fluxengine/arch/mx/MxDecoder.java new file mode 100644 index 000000000..a1d63020c --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/mx/MxDecoder.java @@ -0,0 +1,88 @@ +package com.cowlark.fluxengine.arch.mx; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.FmMfm; + +/** + * The MX decoder, ported from arch/mx/decoder.cc. + */ +public class MxDecoder extends Decoder +{ + /* + * MX disks are a bunch of sectors glued together with no gaps or sync + * markers, following a single beginning-of-track synchronisation and + * identification sequence. + */ + + /* FM beginning of track marker: + * 0 0 f 3 decoded nibbles + * 0 0 0 0 0 0 0 0 1 1 1 1 0 0 1 1 + * 1010 1010 1010 1010 1111 1111 1010 1111 + * a a a a f f a f encoded nibbles + */ + private static final FluxPattern ID_PATTERN = new FluxPattern(32, 0xaaaaffaf); + + private double clock; + private int currentSector; + + public MxDecoder(DecoderProto config) + { + super(config); + } + + @Override + protected void beginTrack() + { + clock = sector.clockNs = seekToPattern(ID_PATTERN); + currentSector = 0; + } + + @Override + protected double advanceToNextRecord() + { + if (currentSector == 11) + { + /* That was the last sector on the disk. */ + return 0; + } else + { + return clock; + } + } + + @Override + protected void decodeSectorRecord() + { + /* Skip the ID pattern and track word, which is only present on the + * first sector. We don't trust the track word because some drivers + * don't write it correctly. */ + + if (currentSector == 0) + readRawBits(64); + + Bits bits = readRawBits((Mx.SECTOR_SIZE + 2) * 16); + Bytes bytes = FmMfm.decodeFmMfm(bits).slice(0, Mx.SECTOR_SIZE + 2); + + int gotChecksum = 0; + ByteReader br = bytes.iterator(); + for (int i = 0; i < (Mx.SECTOR_SIZE / 2); i++) + gotChecksum += br.readBe16(); + int wantChecksum = br.readBe16(); + + int logicalCylinder = ltl.logicalCylinder; + int logicalHead = ltl.logicalHead; + int logicalSector = currentSector; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + sector.data = bytes.slice(0, Mx.SECTOR_SIZE).swab(); + sector.status = + (gotChecksum == wantChecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + currentSector++; + } +} \ No newline at end of file diff --git a/arch/mx/decoder.cc b/java/com/cowlark/fluxengine/arch/mx/decoder.cc similarity index 100% rename from arch/mx/decoder.cc rename to java/com/cowlark/fluxengine/arch/mx/decoder.cc diff --git a/arch/mx/mx.h b/java/com/cowlark/fluxengine/arch/mx/mx.h similarity index 100% rename from arch/mx/mx.h rename to java/com/cowlark/fluxengine/arch/mx/mx.h diff --git a/java/com/cowlark/fluxengine/arch/mx/mx.proto b/java/com/cowlark/fluxengine/arch/mx/mx.proto new file mode 100644 index 000000000..9deec4819 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/mx/mx.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.mx"; +option java_multiple_files = true; + +message MxDecoderProto {} + diff --git a/java/com/cowlark/fluxengine/arch/northstar/Northstar.java b/java/com/cowlark/fluxengine/arch/northstar/Northstar.java new file mode 100644 index 000000000..777e012d8 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/northstar/Northstar.java @@ -0,0 +1,33 @@ +package com.cowlark.fluxengine.arch.northstar; + +/** + * Constants for the North Star format, ported from arch/northstar/northstar.h. + *

+ * Northstar floppies are 10-hard sectored disks with a sector format as + * follows: + *

+ * |----------------------------------| + * | SYNC Byte | Payload | Checksum | + * |------------+----------+----------| + * | 1 (0xFB) | 256 (SD) | 1 | + * | 2 (0xFBFB) | 512 (DD) | | + * |----------------------------------| + */ +public final class Northstar +{ + public static final int NORTHSTAR_PREAMBLE_SIZE_SD = (16); + public static final int NORTHSTAR_PREAMBLE_SIZE_DD = (32); + public static final int NORTHSTAR_HEADER_SIZE_SD = (1); + public static final int NORTHSTAR_HEADER_SIZE_DD = (2); + public static final int NORTHSTAR_PAYLOAD_SIZE_SD = (256); + public static final int NORTHSTAR_PAYLOAD_SIZE_DD = (512); + public static final int NORTHSTAR_CHECKSUM_SIZE = (1); + public static final int NORTHSTAR_ENCODED_SECTOR_SIZE_SD = + (NORTHSTAR_HEADER_SIZE_SD + NORTHSTAR_PAYLOAD_SIZE_SD + NORTHSTAR_CHECKSUM_SIZE); + public static final int NORTHSTAR_ENCODED_SECTOR_SIZE_DD = + (NORTHSTAR_HEADER_SIZE_DD + NORTHSTAR_PAYLOAD_SIZE_DD + NORTHSTAR_CHECKSUM_SIZE); + + private Northstar() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/northstar/NorthstarDecoder.java b/java/com/cowlark/fluxengine/arch/northstar/NorthstarDecoder.java new file mode 100644 index 000000000..fa20797fe --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/northstar/NorthstarDecoder.java @@ -0,0 +1,166 @@ +package com.cowlark.fluxengine.arch.northstar; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.FmMfm; + +/** + * Decoder for North Star 10-sector hard-sectored disks, ported from + * arch/northstar/decoder.cc. + *

+ * Supports both single- and double-density. For the sector format and + * checksum algorithm, see pp. 33 of the North Star Double Density Controller + * manual: + *

+ * http://bitsavers.org/pdf/northstar/boards/Northstar_MDS-A-D_1978.pdf + *

+ * North Star disks do not contain any track/head/sector information encoded in + * the sector record. For this reason, we have to be absolutely sure that the + * hardSectorId is correct. + */ +public class NorthstarDecoder extends Decoder +{ + private static final long MFM_ID = 0xaaaaaaaaaaaa5545L; + private static final long FM_ID = 0xaaaaaaaaaaaaffefL; + + /* + * MFM sectors have 32 bytes of 00's followed by two sync characters, + * specified in the North Star MDS manual as 0xFBFB. + * + * This is true for most disks; however, I found a few disks, including an + * original North Star DOS/BASIC v2.2.1 DQ disk) that uses 0xFBnn, where + * nn is an incrementing pattern. + * + * 00 00 00 F B + * 0000 0000 0000 0000 0000 0000 0101 0101 0100 0101 + * A A A A A A 5 5 4 5 + */ + private static final FluxPattern MFM_PATTERN = new FluxPattern(64, MFM_ID); + + /* FM sectors have 16 bytes of 00's followed by 0xFB. + * 00 FB + * 0000 0000 1111 1111 1110 1111 + * A A F F E F + */ + private static final FluxPattern FM_PATTERN = new FluxPattern(64, FM_ID); + + private static final FluxMatchers ANY_SECTOR_PATTERN = FluxMatchers.of(MFM_PATTERN, FM_PATTERN); + private int hardSectorId; + + public NorthstarDecoder(DecoderProto config) + { + super(config); + } + + /* Checksum is initially 0. For each data byte, XOR with the current + * checksum. Rotate checksum left, carrying bit 7 to bit 0. */ + public static int northstarChecksum(Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + int checksum = 0; + + while (!br.eof()) + { + checksum ^= br.read8(); + checksum = ((checksum << 1) | (checksum >>> 7)) & 0xff; + } + + return checksum; + } + + /* Search for FM or MFM sector record. */ + @Override + protected double advanceToNextRecord() + { + double now = tell().getDurationNs(); + + /* For all but the first sector, seek to the next sector pulse. The + * first sector does not contain the sector pulse in the fluxmap. */ + if (now != 0) + { + seekToIndexMark(); + now = tell().getDurationNs(); + } + + /* Discard a possible partial sector at the end of the track. */ + if (now > (getFluxmapDuration() - 21e6)) + { + seekToIndexMark(); + return 0; + } + + double clock = seekToPattern(ANY_SECTOR_PATTERN); + sector.headerStartTimeNs = tell().getDurationNs(); + + /* Discard a possible partial sector. */ + if (sector.headerStartTimeNs > (getFluxmapDuration() - 21e6)) + { + return 0; + } + + double sectorFoundTimeRaw = Math.round(sector.headerStartTimeNs / 1e6); + double sectorFoundTime; + + /* Round time to the nearest 20ms. */ + if ((sectorFoundTimeRaw % 20) < 10) + { + sectorFoundTime = (sectorFoundTimeRaw / 20) * 20; + } else + { + sectorFoundTime = ((sectorFoundTimeRaw + 20) / 20) * 20; + } + + /* Calculate the sector ID based on time since the index. */ + hardSectorId = (int) ((sectorFoundTime / 20) % 10); + + return clock; + } + + @Override + protected void decodeSectorRecord() + { + long id = readRawBits(64).toBytes().iterator().readBe64(); + int recordSize; + int payloadSize; + int headerSize; + + if (id == MFM_ID) + { + recordSize = Northstar.NORTHSTAR_ENCODED_SECTOR_SIZE_DD; + payloadSize = Northstar.NORTHSTAR_PAYLOAD_SIZE_DD; + headerSize = Northstar.NORTHSTAR_HEADER_SIZE_DD; + } else + { + recordSize = Northstar.NORTHSTAR_ENCODED_SECTOR_SIZE_SD; + payloadSize = Northstar.NORTHSTAR_PAYLOAD_SIZE_SD; + headerSize = Northstar.NORTHSTAR_HEADER_SIZE_SD; + } + + Bits rawbits = readRawBits(recordSize * 16); + Bytes bytes = FmMfm.decodeFmMfm(rawbits).slice(0, recordSize); + ByteReader br = bytes.iterator(); + + int logicalHead = ltl.logicalHead; + int logicalSector = hardSectorId; + int logicalCylinder = ltl.logicalCylinder; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + + if (headerSize == Northstar.NORTHSTAR_HEADER_SIZE_DD) + { + br.read8(); /* MFM second Sync char, usually 0xFB */ + } + + sector.data = br.read(payloadSize); + int wantChecksum = br.read8(); + int gotChecksum = northstarChecksum(bytes.slice(headerSize - 1, payloadSize)); + sector.status = + (wantChecksum == gotChecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/northstar/NorthstarEncoder.java b/java/com/cowlark/fluxengine/arch/northstar/NorthstarEncoder.java new file mode 100644 index 000000000..f224803de --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/northstar/NorthstarEncoder.java @@ -0,0 +1,162 @@ +package com.cowlark.fluxengine.arch.northstar; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +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.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.external.FmMfm; +import com.cowlark.fluxengine.northstar.NorthstarEncoderProto; +import java.util.List; + +/** + * The North Star encoder, ported from arch/northstar/encoder.cc. + */ +public class NorthstarEncoder extends Encoder +{ + private static final int GAP_FILL_SIZE_SD = 30; + private static final int PRE_HEADER_GAP_FILL_SIZE_SD = 9; + private static final int GAP_FILL_SIZE_DD = 62; + private static final int PRE_HEADER_GAP_FILL_SIZE_DD = 16; + + private static final int GAP1_FILL_BYTE = 0x4F; + private static final int GAP2_FILL_BYTE = 0x4F; + + private final NorthstarEncoderProto config; + + public NorthstarEncoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getNorthstar(); + } + + private void writeSector(Bits bits, Bits.Cursor cursor, Sector sector) + { + int preambleSize = 0; + int encodedSectorSize = 0; + int gapFillSize = 0; + int preHeaderGapFillSize = 0; + + boolean doubleDensity; + + switch (sector.data.size()) + { + case Northstar.NORTHSTAR_PAYLOAD_SIZE_SD: + preambleSize = Northstar.NORTHSTAR_PREAMBLE_SIZE_SD; + encodedSectorSize = + PRE_HEADER_GAP_FILL_SIZE_SD + Northstar.NORTHSTAR_ENCODED_SECTOR_SIZE_SD + + GAP_FILL_SIZE_SD; + gapFillSize = GAP_FILL_SIZE_SD; + preHeaderGapFillSize = PRE_HEADER_GAP_FILL_SIZE_SD; + doubleDensity = false; + break; + case Northstar.NORTHSTAR_PAYLOAD_SIZE_DD: + preambleSize = Northstar.NORTHSTAR_PREAMBLE_SIZE_DD; + encodedSectorSize = + PRE_HEADER_GAP_FILL_SIZE_DD + Northstar.NORTHSTAR_ENCODED_SECTOR_SIZE_DD + + GAP_FILL_SIZE_DD; + gapFillSize = GAP_FILL_SIZE_DD; + preHeaderGapFillSize = PRE_HEADER_GAP_FILL_SIZE_DD; + doubleDensity = true; + break; + default: + throw new FluxEngineException( + "unsupported sector size --- you must pick 256 or " + "512"); + } + + int fullSectorSize = preambleSize + encodedSectorSize; + Bytes fullSector = new Bytes(0); + ByteWriter fw = fullSector.writer(); + + /* sector gap after index pulse */ + for (int i = 0; i < preHeaderGapFillSize; i++) + fw.write8(GAP1_FILL_BYTE); + + /* sector preamble */ + for (int i = 0; i < preambleSize; i++) + fw.write8(0); + + Bytes sectorData; + if (sector.data.size() == encodedSectorSize) + sectorData = sector.data; + else + { + sectorData = new Bytes(0); + ByteWriter writer = sectorData.writer(); + writer.write8(0xFB); /* sync character */ + if (doubleDensity) + { + writer.write8(0xFB); /* Double-density has two sync characters */ + } + writer.write(sector.data); + if (doubleDensity) + { + writer.write8(NorthstarDecoder.northstarChecksum(sectorData.slice(2))); + } else + { + writer.write8(NorthstarDecoder.northstarChecksum(sectorData.slice(1))); + } + } + + fw.write(sectorData); + + /* sector postamble */ + for (int i = 0; i < gapFillSize; i++) + fw.write8(GAP2_FILL_BYTE); + + if (sector.location.logicalSector() != 9) + { + if (fullSector.size() != fullSectorSize) + throw new FluxEngineException(String.format( + "sector mismatched length (%d); expected %d, got %d", + sector.data.size(), + fullSector.size(), + fullSectorSize)); + } + + boolean[] lastBit = {false}; + + if (doubleDensity) + { + FmMfm.encodeMfm(bits, cursor, fullSector, lastBit); + } else + { + FmMfm.encodeFm(bits, cursor, fullSector); + } + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + int bitsPerRevolution = 100000; + double clockRateUs = config.getClockPeriodUs(); + + Sector sector = sectors.get(0); + if (sector.data.size() == Northstar.NORTHSTAR_PAYLOAD_SIZE_SD) + bitsPerRevolution /= 2; /* FM */ + else + clockRateUs /= 2.00; + + Bits bits = new Bits(bitsPerRevolution); + Bits.Cursor cursor = new Bits.Cursor(0); + + for (Sector sectorData : sectors) + writeSector(bits, cursor, sectorData); + + if (cursor.get() > bits.size()) + throw new FluxEngineException("track data overrun"); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits( + bits, + (long) calculatePhysicalClockPeriodNs( + clockRateUs * 1e3, + config.getRotationalPeriodMs() * 1e6)); + return fluxmap; + } +} diff --git a/arch/northstar/decoder.cc b/java/com/cowlark/fluxengine/arch/northstar/decoder.cc similarity index 100% rename from arch/northstar/decoder.cc rename to java/com/cowlark/fluxengine/arch/northstar/decoder.cc diff --git a/arch/northstar/encoder.cc b/java/com/cowlark/fluxengine/arch/northstar/encoder.cc similarity index 100% rename from arch/northstar/encoder.cc rename to java/com/cowlark/fluxengine/arch/northstar/encoder.cc diff --git a/arch/northstar/northstar.h b/java/com/cowlark/fluxengine/arch/northstar/northstar.h similarity index 100% rename from arch/northstar/northstar.h rename to java/com/cowlark/fluxengine/arch/northstar/northstar.h diff --git a/java/com/cowlark/fluxengine/arch/northstar/northstar.proto b/java/com/cowlark/fluxengine/arch/northstar/northstar.proto new file mode 100644 index 000000000..3cbca249e --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/northstar/northstar.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.northstar"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message NorthstarDecoderProto {} + +message NorthstarEncoderProto { + optional double clock_period_us = 1 + [default = 4.0, (help) = "clock rate on the real device (for FM)"]; + optional double rotational_period_ms = 2 + [default = 166.0, (help) = "rotational period on the real device"]; +} + diff --git a/java/com/cowlark/fluxengine/arch/rolandd20/RolandD20Decoder.java b/java/com/cowlark/fluxengine/arch/rolandd20/RolandD20Decoder.java new file mode 100644 index 000000000..0e5140791 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/rolandd20/RolandD20Decoder.java @@ -0,0 +1,81 @@ +package com.cowlark.fluxengine.arch.rolandd20; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.FmMfm; + +/** + * The Roland D20 decoder, ported from arch/rolandd20/decoder.cc. + */ +public class RolandD20Decoder extends Decoder +{ + /* Sector header record: + * + * BF FF FF FF FF FF FE AB + * + * This encodes to: + * + * e d 5 5 5 5 5 5 + * 1110 1101 0101 0101 0101 0101 0101 0101 + * 5 5 5 5 5 5 5 5 + * 0101 0101 0101 0101 0101 0101 0101 0101 + * 5 5 5 5 5 5 5 5 + * 0101 0101 0101 0101 0101 0101 0101 0101 + * 5 5 5 4 4 4 4 5 + * 0101 0101 0101 0100 0100 0100 0100 0101 + */ + private static final FluxPattern SECTOR_PATTERN = new FluxPattern(64, 0xed55555555555555L); + + public RolandD20Decoder(DecoderProto config) + { + super(config); + } + + private static void hexdump(Bytes buffer) + { + int pos = 0; + + while (pos < buffer.size()) + { + System.out.printf("%05x : ", pos); + for (int i = 0; i < 16; i++) + { + if ((pos + i) < buffer.size()) + System.out.printf("%02x ", buffer.getByte(pos + i)); + else + System.out.print("-- "); + } + System.out.print(" : "); + for (int i = 0; i < 16; i++) + { + if ((pos + i) >= buffer.size()) + break; + + int c = buffer.getByte(pos + i) & 0xff; + if ((c >= 32) && (c <= 126)) + System.out.print((char) c); + else + System.out.print('.'); + } + System.out.println(); + + pos += 16; + } + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(SECTOR_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + Bytes bytes = FmMfm.decodeFmMfm(readRawBits(256)); + System.out.printf("%.3f ", sector.clockNs); + hexdump(bytes); + } +} \ No newline at end of file diff --git a/arch/rolandd20/decoder.cc b/java/com/cowlark/fluxengine/arch/rolandd20/decoder.cc similarity index 100% rename from arch/rolandd20/decoder.cc rename to java/com/cowlark/fluxengine/arch/rolandd20/decoder.cc diff --git a/arch/rolandd20/rolandd20.h b/java/com/cowlark/fluxengine/arch/rolandd20/rolandd20.h similarity index 100% rename from arch/rolandd20/rolandd20.h rename to java/com/cowlark/fluxengine/arch/rolandd20/rolandd20.h diff --git a/java/com/cowlark/fluxengine/arch/rolandd20/rolandd20.proto b/java/com/cowlark/fluxengine/arch/rolandd20/rolandd20.proto new file mode 100644 index 000000000..e16cd8293 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/rolandd20/rolandd20.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.rolandd20"; +option java_multiple_files = true; + +message RolandD20DecoderProto {} + + diff --git a/java/com/cowlark/fluxengine/arch/smaky6/Smaky6.java b/java/com/cowlark/fluxengine/arch/smaky6/Smaky6.java new file mode 100644 index 000000000..d6bfb626d --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/smaky6/Smaky6.java @@ -0,0 +1,14 @@ +package com.cowlark.fluxengine.arch.smaky6; + +/** + * Constants for the Smaky6 format, ported from arch/smaky6/smaky6.h. + */ +public final class Smaky6 +{ + public static final int SMAKY6_SECTOR_SIZE = 256; + public static final int SMAKY6_RECORD_SIZE = (1 + SMAKY6_SECTOR_SIZE + 1); + + private Smaky6() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/smaky6/Smaky6Decoder.java b/java/com/cowlark/fluxengine/arch/smaky6/Smaky6Decoder.java new file mode 100644 index 000000000..d511dd491 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/smaky6/Smaky6Decoder.java @@ -0,0 +1,150 @@ +package com.cowlark.fluxengine.arch.smaky6; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.FluxPosition; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.Crc; +import com.cowlark.fluxengine.external.FmMfm; +import java.util.ArrayList; +import java.util.List; + +/** + * The Smaky6 decoder, ported from arch/smaky6/decoder.cc. + */ +public class Smaky6Decoder extends Decoder +{ + private static final FluxPattern SECTOR_PATTERN = new FluxPattern(32, 0x54892aaa); + private final List sectorStarts = new ArrayList<>(); + private int sectorId; + private int sectorIndex; + + public Smaky6Decoder(DecoderProto config) + { + super(config); + } + + /* Returns the sector ID of the _current_ sector. */ + private int advanceToNextSector() + { + FluxPosition previous = tell(); + seekToIndexMark(); + FluxPosition now = tell(); + if ((now.getDurationNs() - previous.getDurationNs()) < 9e6) + { + seekToIndexMark(); + FluxPosition next = tell(); + if ((next.getDurationNs() - now.getDurationNs()) < 9e6) + { + /* We just found sector 0. */ + + sectorId = 0; + } else + { + /* Spurious... */ + + seek(now); + } + } + + return sectorId++; + } + + @Override + protected void beginTrack() + { + /* Find the start-of-track index marks, which will be an interval of + * about 6ms. */ + + seekToIndexMark(); + sectorId = 99; + for (; ; ) + { + FluxPosition pos = tell(); + advanceToNextSector(); + if (sectorId < 99) + { + seek(pos); + break; + } + + if (eof()) + return; + } + + /* Now we know where to start counting, start finding sectors. */ + + sectorStarts.clear(); + for (; ; ) + { + FluxPosition now = tell(); + if (eof()) + break; + + int id = advanceToNextSector(); + if (id < 16) + sectorStarts.add(new SectorStart(id, now)); + } + + sectorIndex = 0; + } + + @Override + protected double advanceToNextRecord() + { + if (sectorIndex == sectorStarts.size()) + { + seekToIndexMark(); + return 0; + } + + SectorStart p = sectorStarts.get(sectorIndex++); + sectorId = p.id(); + seek(p.pos()); + + double clock = seekToPattern(SECTOR_PATTERN); + sector.headerStartTimeNs = tell().getDurationNs(); + + return clock; + } + + @Override + protected void decodeSectorRecord() + { + readRawBits(33); + Bits rawbits = readRawBits(Smaky6.SMAKY6_RECORD_SIZE * 16); + if (rawbits.size() < Smaky6.SMAKY6_SECTOR_SIZE) + return; + + /* The Smaky bytes are stored backwards! Backwards! */ + + Bytes bytes = FmMfm.decodeFmMfm(rawbits).slice(0, Smaky6.SMAKY6_RECORD_SIZE).reverseBits(); + ByteReader br = bytes.iterator(); + + int track = br.read8(); + Bytes data = br.read(Smaky6.SMAKY6_SECTOR_SIZE); + int wantedChecksum = br.read8(); + int gotChecksum = Crc.sumBytes(data) & 0xff; + + if (track != ltl.logicalCylinder) + return; + + int logicalCylinder = ltl.physicalCylinder; + int logicalHead = ltl.logicalHead; + int logicalSector = sectorId; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + + sector.data = data; + sector.status = + (wantedChecksum == gotChecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } + + private record SectorStart(int id, FluxPosition pos) + { + } +} \ No newline at end of file diff --git a/arch/smaky6/decoder.cc b/java/com/cowlark/fluxengine/arch/smaky6/decoder.cc similarity index 100% rename from arch/smaky6/decoder.cc rename to java/com/cowlark/fluxengine/arch/smaky6/decoder.cc diff --git a/arch/smaky6/smaky6.h b/java/com/cowlark/fluxengine/arch/smaky6/smaky6.h similarity index 100% rename from arch/smaky6/smaky6.h rename to java/com/cowlark/fluxengine/arch/smaky6/smaky6.h diff --git a/java/com/cowlark/fluxengine/arch/smaky6/smaky6.proto b/java/com/cowlark/fluxengine/arch/smaky6/smaky6.proto new file mode 100644 index 000000000..13c60ec83 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/smaky6/smaky6.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.smaky6"; +option java_multiple_files = true; + +message Smaky6DecoderProto {} + diff --git a/java/com/cowlark/fluxengine/arch/tartu/Tartu.java b/java/com/cowlark/fluxengine/arch/tartu/Tartu.java new file mode 100644 index 000000000..376212cd3 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/tartu/Tartu.java @@ -0,0 +1,14 @@ +package com.cowlark.fluxengine.arch.tartu; + +/** + * Constants for the Tartu format, ported from arch/tartu/tartu.h. + */ +public final class Tartu +{ + public static final long HEADER_BITS = 0xaaaaaaaa44895554L; + public static final long DATA_BITS = 0xaaaaaaaa44895545L; + + private Tartu() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/tartu/TartuDecoder.java b/java/com/cowlark/fluxengine/arch/tartu/TartuDecoder.java new file mode 100644 index 000000000..f4d9a8abf --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/tartu/TartuDecoder.java @@ -0,0 +1,77 @@ +package com.cowlark.fluxengine.arch.tartu; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.Crc; +import com.cowlark.fluxengine.external.FmMfm; + +/** + * The Tartu decoder, ported from arch/tartu/decoder.cc. + */ +public class TartuDecoder extends Decoder +{ + private static final FluxPattern HEADER_PATTERN = new FluxPattern(64, Tartu.HEADER_BITS); + private static final FluxPattern DATA_PATTERN = new FluxPattern(64, Tartu.DATA_BITS); + + private static final FluxMatchers ANY_RECORD_PATTERN = + FluxMatchers.of(HEADER_PATTERN, DATA_PATTERN); + + public TartuDecoder(DecoderProto config) + { + super(config); + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(ANY_RECORD_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + if (readRaw64() != Tartu.HEADER_BITS) + return; + + Bits bits = readRawBits(16 * 4); + Bytes bytes = FmMfm.decodeFmMfm(bits).slice(0, 4); + + ByteReader br = bytes.iterator(); + int track = br.read8(); + int logicalCylinder = track >> 1; + int logicalHead = track & 1; + br.skip(1); /* seems always to be 1 */ + int logicalSector = br.read8(); + int wantChecksum = br.read8(); + int gotChecksum = ~Crc.sumBytes(bytes.slice(0, 3)) & 0xff; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + + if (wantChecksum == gotChecksum) + sector.status = Sector.Status.DATA_MISSING; + + sector.status = Sector.Status.DATA_MISSING; + } + + @Override + protected void decodeDataRecord() + { + if (readRaw64() != Tartu.DATA_BITS) + return; + + Bits bits = readRawBits(129 * 16); + Bytes bytes = FmMfm.decodeFmMfm(bits).slice(0, 129); + sector.data = bytes.slice(0, 128); + + int wantChecksum = bytes.iterator().seek(128).read8(); + int gotChecksum = ~Crc.sumBytes(sector.data) & 0xff; + sector.status = + (wantChecksum == gotChecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/tartu/TartuEncoder.java b/java/com/cowlark/fluxengine/arch/tartu/TartuEncoder.java new file mode 100644 index 000000000..192c8b951 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/tartu/TartuEncoder.java @@ -0,0 +1,117 @@ +package com.cowlark.fluxengine.arch.tartu; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +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.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.external.Crc; +import com.cowlark.fluxengine.external.FmMfm; +import com.cowlark.fluxengine.tartu.TartuEncoderProto; +import java.util.List; + +/** + * The Tartu encoder, ported from arch/tartu/encoder.cc. + */ +public class TartuEncoder extends Encoder +{ + private final TartuEncoderProto config; + private final boolean[] lastBit = new boolean[1]; + private double clockRateUs; + private Bits bits; + private Bits.Cursor cursor; + + public TartuEncoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getTartu(); + } + + private void writeBytes(Bytes bytes) + { + FmMfm.encodeMfm(bits, cursor, bytes, lastBit); + } + + private void writeRawBits(long data, int width) + { + cursor.advance(width); + lastBit[0] = (data & 1) != 0; + for (int i = 0; i < width; i++) + { + int pos = cursor.get() - i - 1; + if (pos < bits.size()) + bits.setBit(pos, (data & 1) != 0); + data >>= 1; + } + } + + private void writeFillerRawBitsUs(double us) + { + int count = (int) ((us / clockRateUs) / 2); + for (int i = 0; i < count; i++) + writeRawBits(0b10, 2); + } + + private void writeSector(Sector sectorData) + { + writeRawBits(config.getHeaderMarker(), 64); + { + Bytes bytes = new Bytes(0); + ByteWriter bw = bytes.writer(); + bw.write8((sectorData.location.logicalCylinder() << 1) | + sectorData.location.logicalHead()); + bw.write8(1); + bw.write8(sectorData.location.logicalSector()); + bw.write8(~Crc.sumBytes(bytes.slice(0, 3))); + writeBytes(bytes); + } + + writeFillerRawBitsUs(config.getGap3Us()); + writeRawBits(config.getDataMarker(), 64); + { + Bytes bytes = new Bytes(0); + ByteWriter bw = bytes.writer(); + bw.write(sectorData.data); + bw.write8(~Crc.sumBytes(bytes.slice(0, sectorData.data.size()))); + writeBytes(bytes); + } + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + clockRateUs = config.getClockPeriodUs(); + int bitsPerRevolution = + (int) ((config.getTargetRotationalPeriodMs() * 1000.0) / clockRateUs); + + bits = new Bits(bitsPerRevolution); + cursor = new Bits.Cursor(0); + + writeFillerRawBitsUs(config.getGap1Us()); + boolean first = true; + for (Sector sectorData : sectors) + { + if (!first) + writeFillerRawBitsUs(config.getGap4Us()); + first = false; + writeSector(sectorData); + } + + if (cursor.get() > bits.size()) + throw new FluxEngineException("track data overrun"); + writeFillerRawBitsUs(config.getTargetRotationalPeriodMs() * 1000.0); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits( + bits, + (long) calculatePhysicalClockPeriodNs( + clockRateUs * 1e3, + config.getTargetRotationalPeriodMs() * 1e6)); + return fluxmap; + } +} diff --git a/arch/tartu/decoder.cc b/java/com/cowlark/fluxengine/arch/tartu/decoder.cc similarity index 100% rename from arch/tartu/decoder.cc rename to java/com/cowlark/fluxengine/arch/tartu/decoder.cc diff --git a/arch/tartu/encoder.cc b/java/com/cowlark/fluxengine/arch/tartu/encoder.cc similarity index 100% rename from arch/tartu/encoder.cc rename to java/com/cowlark/fluxengine/arch/tartu/encoder.cc diff --git a/arch/tartu/tartu.h b/java/com/cowlark/fluxengine/arch/tartu/tartu.h similarity index 100% rename from arch/tartu/tartu.h rename to java/com/cowlark/fluxengine/arch/tartu/tartu.h diff --git a/java/com/cowlark/fluxengine/arch/tartu/tartu.proto b/java/com/cowlark/fluxengine/arch/tartu/tartu.proto new file mode 100644 index 000000000..a99112a86 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/tartu/tartu.proto @@ -0,0 +1,30 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.tartu"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message TartuDecoderProto {} + +message TartuEncoderProto { + optional double clock_period_us = 1 + [default = 2.0, (help) = "clock rate on the real device (for MFM)"]; + optional double target_rotational_period_ms = 2 + [default = 200, (help) = "rotational period of target disk"]; + optional double gap1_us = 3 + [default = 1200, + (help) = "size of gap 1 (the post-index gap)"]; + optional double gap3_us = 4 + [default = 150, + (help) = "size of gap 3 (the pre-data gap)"]; + optional double gap4_us = 5 + [default = 180, + (help) = "size of gap 4 (the post-data or format gap)"]; + optional uint64 header_marker = 6 + [default = 0xaaaaaaaa44895554, + (help) = "64-bit raw bit pattern of header record marker"]; + optional uint64 data_marker = 7 + [default = 0xaaaaaaaa44895545, + (help) = "64-bit raw bit pattern of data record marker"]; +} diff --git a/java/com/cowlark/fluxengine/arch/tids990/Tids990.java b/java/com/cowlark/fluxengine/arch/tids990/Tids990.java new file mode 100644 index 000000000..ce54f4192 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/tids990/Tids990.java @@ -0,0 +1,15 @@ +package com.cowlark.fluxengine.arch.tids990; + +/** + * Constants for the TI DS990 format, ported from arch/tids990/tids990.h. + */ +public final class Tids990 +{ + public static final int TIDS990_PAYLOAD_SIZE = 288; /* bytes */ + public static final int TIDS990_SECTOR_RECORD_SIZE = 10; /* bytes */ + public static final int TIDS990_DATA_RECORD_SIZE = (TIDS990_PAYLOAD_SIZE + 4); /* bytes */ + + private Tids990() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/tids990/Tids990Decoder.java b/java/com/cowlark/fluxengine/arch/tids990/Tids990Decoder.java new file mode 100644 index 000000000..b55f440c6 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/tids990/Tids990Decoder.java @@ -0,0 +1,107 @@ +package com.cowlark.fluxengine.arch.tids990; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.Crc; +import com.cowlark.fluxengine.external.FmMfm; + +/** + * The Texas Instruments DS990 decoder, ported from arch/tids990/decoder.cc. + */ +public class Tids990Decoder extends Decoder +{ + /* + * The Texas Instruments DS990 uses MFM with a scheme similar to a + * simplified version of the IBM record scheme (it's actually easier to + * parse than IBM). There are 26 sectors per track, each holding a rather + * weird 288 bytes. + */ + + /* + * Sector record: + * data: 0 1 0 1 0 1 0 1 .0 0 0 0 1 0 1 0 = 0x550a + * mfm: 00 01 00 01 00 01 00 01.00 10 10 10 01 00 01 00 = 0x11112a44 + * special: 00 01 00 01 00 01 00 01.00 10 00 10 01 00 01 00 = 0x11112244 + * ^^ + * When shifted out of phase, the special 0xa1 byte becomes an illegal + * encoding (you can't do 10 00). So this can't be spoofed by user data. + */ + private static final int SECTOR_ID = 0x550a; + private static final FluxPattern SECTOR_RECORD_PATTERN = new FluxPattern(32, 0x11112244); + + /* + * Data record: + * data: 0 1 0 1 0 1 0 1 .0 0 0 0 1 0 1 1 = 0x550b + * mfm: 00 01 00 01 00 01 00 01.00 10 10 10 01 00 01 01 = 0x11112a45 + * special: 00 01 00 01 00 01 00 01.00 10 00 10 01 00 01 01 = 0x11112245 + * ^^ + * When shifted out of phase, the special 0xa1 byte becomes an illegal + * encoding (you can't do 10 00). So this can't be spoofed by user data. + */ + private static final int DATA_ID = 0x550b; + private static final FluxPattern DATA_RECORD_PATTERN = new FluxPattern(32, 0x11112245); + private static final FluxMatchers ANY_RECORD_PATTERN = + FluxMatchers.of(SECTOR_RECORD_PATTERN, DATA_RECORD_PATTERN); + + public Tids990Decoder(DecoderProto config) + { + super(config); + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(ANY_RECORD_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + Bits bits = readRawBits(Tids990.TIDS990_SECTOR_RECORD_SIZE * 16); + Bytes bytes = FmMfm.decodeFmMfm(bits).slice(0, Tids990.TIDS990_SECTOR_RECORD_SIZE); + + ByteReader br = bytes.iterator(); + if (br.readBe16() != SECTOR_ID) + return; + + int gotChecksum = + Crc.crc16(Crc.CCITT_POLY, bytes.slice(1, Tids990.TIDS990_SECTOR_RECORD_SIZE - 3)); + + int logicalHead = br.read8() >> 3; + int logicalCylinder = br.read8(); + br.read8(); /* number of sectors per track */ + int logicalSector = br.read8(); + br.readBe16(); /* sector size */ + int wantChecksum = br.readBe16(); + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + + if (wantChecksum == gotChecksum) + sector.status = Sector.Status.DATA_MISSING; + } + + @Override + protected void decodeDataRecord() + { + Bits bits = readRawBits(Tids990.TIDS990_DATA_RECORD_SIZE * 16); + Bytes bytes = FmMfm.decodeFmMfm(bits).slice(0, Tids990.TIDS990_DATA_RECORD_SIZE); + + ByteReader br = bytes.iterator(); + if (br.readBe16() != DATA_ID) + return; + + int gotChecksum = + Crc.crc16(Crc.CCITT_POLY, bytes.slice(1, Tids990.TIDS990_DATA_RECORD_SIZE - 3)); + + sector.data = br.read(Tids990.TIDS990_PAYLOAD_SIZE); + int wantChecksum = br.readBe16(); + sector.status = + (wantChecksum == gotChecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/tids990/Tids990Encoder.java b/java/com/cowlark/fluxengine/arch/tids990/Tids990Encoder.java new file mode 100644 index 000000000..48432960e --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/tids990/Tids990Encoder.java @@ -0,0 +1,143 @@ +package com.cowlark.fluxengine.arch.tids990; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +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.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.external.Crc; +import com.cowlark.fluxengine.external.FmMfm; +import com.cowlark.fluxengine.tids990.Tids990EncoderProto; +import java.util.List; + +/** + * The TI DS990 encoder, ported from arch/tids990/encoder.cc. + */ +public class Tids990Encoder extends Encoder +{ + private final Tids990EncoderProto config; + private final boolean[] lastBit = new boolean[1]; + private Bits bits; + private Bits.Cursor cursor; + + public Tids990Encoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getTids990(); + } + + private static int decodeUint16(int raw) + { + Bytes b = new Bytes(2); + b.writer().writeBe16(raw); + return FmMfm.decodeFmMfm(b.toBits()).getByte(0) & 0xff; + } + + private void writeRawBits(int data, int width) + { + cursor.advance(width); + lastBit[0] = (data & 1) != 0; + for (int i = 0; i < width; i++) + { + int pos = cursor.get() - i - 1; + if (pos < bits.size()) + bits.setBit(pos, (data & 1) != 0); + data >>= 1; + } + } + + private void writeBytes(Bytes bytes) + { + FmMfm.encodeMfm(bits, cursor, bytes, lastBit); + } + + private void writeBytes(int count, int byte_) + { + Bytes bytes = Bytes.of(byte_); + for (int i = 0; i < count; i++) + writeBytes(bytes); + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + double clockRateUs = config.getClockPeriodUs() / 2.0; + int bitsPerRevolution = (int) ((config.getRotationalPeriodMs() * 1000.0) / clockRateUs); + bits = new Bits(bitsPerRevolution); + cursor = new Bits.Cursor(0); + + int am1Unencoded = decodeUint16(config.getAm1Byte()); + int am2Unencoded = decodeUint16(config.getAm2Byte()); + + writeBytes(config.getGap1Bytes(), 0x55); + + boolean first = true; + for (Sector sectorData : sectors) + { + if (!first) + writeBytes(config.getGap3Bytes(), 0x55); + first = false; + + /* Writing the sector and data records are fantastically annoying. + * The CRC is calculated from the *very start* of the record, and + * include the malformed marker bytes. Our encoder doesn't know + * about this, of course, with the result that we have to construct + * the unencoded header, calculate the checksum, and then use the + * same logic to emit the bytes which require special encoding + * before encoding the rest of the header normally. */ + + { + Bytes header = new Bytes(0); + ByteWriter bw = header.writer(); + + writeBytes(12, 0x55); + bw.write8(am1Unencoded); + bw.write8(sectorData.location.logicalHead() << 3); + bw.write8(sectorData.location.logicalCylinder()); + bw.write8(config.getSectorCount()); + bw.write8(sectorData.location.logicalSector()); + bw.writeBe16(sectorData.data.size()); + int crc = Crc.crc16(Crc.CCITT_POLY, header); + bw.writeBe16(crc); + + writeRawBits(config.getAm1Byte(), 16); + writeBytes(header.slice(1)); + } + + writeBytes(config.getGap2Bytes(), 0x55); + + { + Bytes data = new Bytes(0); + ByteWriter bw = data.writer(); + + writeBytes(12, 0x55); + bw.write8(am2Unencoded); + + bw.write(sectorData.data); + int crc = Crc.crc16(Crc.CCITT_POLY, data); + bw.writeBe16(crc); + + writeRawBits(config.getAm2Byte(), 16); + writeBytes(data.slice(1)); + } + } + + if (cursor.get() >= bits.size()) + throw new FluxEngineException("track data overrun"); + while (cursor.get() < bits.size()) + writeBytes(1, 0x55); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits( + bits, + (long) calculatePhysicalClockPeriodNs( + clockRateUs * 1e3, + config.getRotationalPeriodMs() * 1e6)); + return fluxmap; + } +} diff --git a/arch/tids990/decoder.cc b/java/com/cowlark/fluxengine/arch/tids990/decoder.cc similarity index 100% rename from arch/tids990/decoder.cc rename to java/com/cowlark/fluxengine/arch/tids990/decoder.cc diff --git a/arch/tids990/encoder.cc b/java/com/cowlark/fluxengine/arch/tids990/encoder.cc similarity index 100% rename from arch/tids990/encoder.cc rename to java/com/cowlark/fluxengine/arch/tids990/encoder.cc diff --git a/arch/tids990/tids990.h b/java/com/cowlark/fluxengine/arch/tids990/tids990.h similarity index 100% rename from arch/tids990/tids990.h rename to java/com/cowlark/fluxengine/arch/tids990/tids990.h diff --git a/java/com/cowlark/fluxengine/arch/tids990/tids990.proto b/java/com/cowlark/fluxengine/arch/tids990/tids990.proto new file mode 100644 index 000000000..0f69e98c9 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/tids990/tids990.proto @@ -0,0 +1,28 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.tids990"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message Tids990DecoderProto {} + +message Tids990EncoderProto { + optional double rotational_period_ms = 1 [default = 166, + (help) = "length of a track"]; + optional int32 sector_count = 2 [default = 26, + (help) = "number of sectors per track"]; + optional double clock_period_us = 3 [default = 2, + (help) = "clock rate of data to write"]; + optional int32 am1_byte = 4 [default = 0x2244, + (help) = "16-bit RAW bit pattern to use for the AM1 ID byte"]; + optional int32 am2_byte = 5 [default = 0x2245, + (help) = "16-bit RAW bit pattern to use for the AM2 ID byte"]; + optional int32 gap1_bytes = 6 [default = 80, + (help) = "size of gap 1 (the post-index gap)"]; + optional int32 gap2_bytes = 7 [default = 21, + (help) = "size of gap 2 (the post-ID gap)"]; + optional int32 gap3_bytes = 8 [default = 51, + (help) = "size of gap 3 (the post-data or format gap)"]; +} + diff --git a/java/com/cowlark/fluxengine/arch/victor9k/Victor9k.java b/java/com/cowlark/fluxengine/arch/victor9k/Victor9k.java new file mode 100644 index 000000000..808645510 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/victor9k/Victor9k.java @@ -0,0 +1,23 @@ +package com.cowlark.fluxengine.arch.victor9k; + +/** + * Constants for the Victor 9k format, ported from arch/victor9k/victor9k.h. + */ +public final class Victor9k +{ + /* ... 1101 0101 0111 + * ^^ ^^^^ ^^^^ ten bit IO byte */ + public static final int VICTOR9K_SECTOR_RECORD = 0xfffffd57; + public static final int VICTOR9K_HEADER_ID = 0x7; + + /* ... 1101 0100 1001 + * ^^ ^^^^ ^^^^ ten bit IO byte */ + public static final int VICTOR9K_DATA_RECORD = 0xfffffd49; + public static final int VICTOR9K_DATA_ID = 0x8; + + public static final int VICTOR9K_SECTOR_LENGTH = 512; + + private Victor9k() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/victor9k/Victor9kDecoder.java b/java/com/cowlark/fluxengine/arch/victor9k/Victor9kDecoder.java new file mode 100644 index 000000000..d69bc362d --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/victor9k/Victor9kDecoder.java @@ -0,0 +1,153 @@ +package com.cowlark.fluxengine.arch.victor9k; + +import com.cowlark.fluxengine.core.BitWriter; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxMatchers; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.Crc; + +/** + * The Victor 9k decoder, ported from arch/victor9k/decoder.cc. + */ +public class Victor9kDecoder extends Decoder +{ + private static final FluxPattern SECTOR_RECORD_PATTERN = + new FluxPattern(32, Victor9k.VICTOR9K_SECTOR_RECORD); + private static final FluxPattern DATA_RECORD_PATTERN = + new FluxPattern(32, Victor9k.VICTOR9K_DATA_RECORD); + private static final FluxMatchers ANY_RECORD_PATTERN = + FluxMatchers.of(SECTOR_RECORD_PATTERN, DATA_RECORD_PATTERN); + + public Victor9kDecoder(DecoderProto config) + { + super(config); + } + + private static int decodeDataGcr(int gcr) + { + switch (gcr) + { + case 0x0a: + return 0x0; + case 0x0b: + return 0x1; + case 0x12: + return 0x2; + case 0x13: + return 0x3; + case 0x0e: + return 0x4; + case 0x0f: + return 0x5; + case 0x16: + return 0x6; + case 0x17: + return 0x7; + case 0x09: + return 0x8; + case 0x19: + return 0x9; + case 0x1a: + return 0xa; + case 0x1b: + return 0xb; + case 0x0d: + return 0xc; + case 0x1d: + return 0xd; + case 0x1e: + return 0xe; + case 0x15: + return 0xf; + default: + return -1; + } + } + + private static Bytes decode(Bits bits) + { + Bytes output = new Bytes(); + ByteWriter bw = new ByteWriter(output); + BitWriter bitw = new BitWriter(bw); + + int ii = 0; + while (ii < bits.size()) + { + int inputfifo = 0; + for (int i = 0; i < 5; i++) + { + if (ii >= bits.size()) + break; + inputfifo = (inputfifo << 1) | (bits.getBit(ii++) ? 1 : 0); + } + + int decoded = decodeDataGcr(inputfifo); + bitw.push(decoded, 4); + } + bitw.flush(); + + return output; + } + + @Override + protected double advanceToNextRecord() + { + return seekToPattern(ANY_RECORD_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + /* Check the ID. */ + + if (readRaw32() != Victor9k.VICTOR9K_SECTOR_RECORD) + return; + + /* Read header. */ + + Bytes bytes = decode(readRawBits(3 * 10)).slice(0, 3); + + int rawTrack = bytes.getByte(0) & 0xff; + int logicalSector = bytes.getByte(1) & 0xff; + int gotChecksum = bytes.getByte(2) & 0xff; + + int logicalCylinder = rawTrack & 0x7f; + int logicalHead = rawTrack >> 7; + int wantChecksum = (bytes.getByte(0) & 0xff) + (bytes.getByte(1) & 0xff); + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + if ((logicalSector > 20) || (logicalCylinder > 85) || (logicalHead > 1)) + return; + + if (wantChecksum == gotChecksum) + sector.status = Sector.Status.DATA_MISSING; /* unintuitive but correct */ + } + + @Override + protected void decodeDataRecord() + { + /* Check the ID. */ + + if (readRaw32() != Victor9k.VICTOR9K_DATA_RECORD) + return; + + /* Read data. */ + + Bytes bytes = decode(readRawBits((Victor9k.VICTOR9K_SECTOR_LENGTH + 4) * 10)).slice( + 0, + Victor9k.VICTOR9K_SECTOR_LENGTH + 4); + ByteReader br = bytes.iterator(); + + sector.data = br.read(Victor9k.VICTOR9K_SECTOR_LENGTH); + int gotChecksum = Crc.sumBytes(sector.data); + int wantChecksum = br.readLe16(); + sector.status = + (gotChecksum == wantChecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/arch/victor9k/Victor9kEncoder.java b/java/com/cowlark/fluxengine/arch/victor9k/Victor9kEncoder.java new file mode 100644 index 000000000..f084e212a --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/victor9k/Victor9kEncoder.java @@ -0,0 +1,217 @@ +package com.cowlark.fluxengine.arch.victor9k; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.Encoder; +import com.cowlark.fluxengine.external.Crc; +import com.cowlark.fluxengine.victor9k.Victor9kEncoderProto; +import java.util.List; + +/** + * The Victor 9k encoder, ported from arch/victor9k/encoder.cc. + */ +public class Victor9kEncoder extends Encoder +{ + private static final int[] ENCODE_DATA_GCR = new int[16]; + + static + { + ENCODE_DATA_GCR[0x0] = 0x0a; + ENCODE_DATA_GCR[0x1] = 0x0b; + ENCODE_DATA_GCR[0x2] = 0x12; + ENCODE_DATA_GCR[0x3] = 0x13; + ENCODE_DATA_GCR[0x4] = 0x0e; + ENCODE_DATA_GCR[0x5] = 0x0f; + ENCODE_DATA_GCR[0x6] = 0x16; + ENCODE_DATA_GCR[0x7] = 0x17; + ENCODE_DATA_GCR[0x8] = 0x09; + ENCODE_DATA_GCR[0x9] = 0x19; + ENCODE_DATA_GCR[0xa] = 0x1a; + ENCODE_DATA_GCR[0xb] = 0x1b; + ENCODE_DATA_GCR[0xc] = 0x0d; + ENCODE_DATA_GCR[0xd] = 0x1d; + ENCODE_DATA_GCR[0xe] = 0x1e; + ENCODE_DATA_GCR[0xf] = 0x15; + } + + private final Victor9kEncoderProto config; + private final boolean[] lastBit = new boolean[1]; + + public Victor9kEncoder(ConfigProto config, double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + this.config = config.getEncoder().getVictor9K(); + } + + private static int encodeDataGcr(int data) + { + data &= 0x0f; + return ENCODE_DATA_GCR[data]; + } + + private void writeZeroBits(Bits bits, Bits.Cursor cursor, int count) + { + while (count-- != 0) + { + if (cursor.get() < bits.size()) + { + lastBit[0] = false; + bits.setBit(cursor.get(), false); + } + cursor.advance(); + } + } + + private void writeOneBits(Bits bits, Bits.Cursor cursor, int count) + { + while (count-- != 0) + { + if (cursor.get() < bits.size()) + { + lastBit[0] = true; + bits.setBit(cursor.get(), true); + } + cursor.advance(); + } + } + + private void writeBits(Bits bits, Bits.Cursor cursor, boolean[] src) + { + for (boolean bit : src) + { + if (cursor.get() < bits.size()) + { + lastBit[0] = bit; + bits.setBit(cursor.get(), bit); + } + cursor.advance(); + } + } + + private void writeBits(Bits bits, Bits.Cursor cursor, long data, int width) + { + cursor.advance(width); + lastBit[0] = (data & 1) != 0; + for (int i = 0; i < width; i++) + { + int pos = cursor.get() - i - 1; + if (pos < bits.size()) + bits.setBit(pos, (data & 1) != 0); + data >>= 1; + } + } + + private void writeBits(Bits bits, Bits.Cursor cursor, Bytes bytes) + { + Bits bitr = bytes.toBits(); + for (int i = 0; i < bitr.size(); i++) + { + if (cursor.get() < bits.size()) + bits.setBit(cursor.get(), bitr.getBit(i)); + cursor.advance(); + } + } + + private void writeByte(Bits bits, Bits.Cursor cursor, int b) + { + writeBits(bits, cursor, encodeDataGcr(b >> 4), 5); + writeBits(bits, cursor, encodeDataGcr(b), 5); + } + + private void writeBytes(Bits bits, Bits.Cursor cursor, Bytes bytes) + { + for (int i = 0; i < bytes.size(); i++) + writeByte(bits, cursor, bytes.getByte(i) & 0xff); + } + + private void writeGap(Bits bits, Bits.Cursor cursor, int length) + { + for (int i = 0; i < length / 10; i++) + writeByte(bits, cursor, '0'); + } + + private void writeSector(Bits bits, + Bits.Cursor cursor, + Victor9kEncoderProto.TrackdataProto trackdata, + Sector sector) + { + writeOneBits(bits, cursor, trackdata.getPreHeaderSyncBits()); + writeBits(bits, cursor, Victor9k.VICTOR9K_SECTOR_RECORD, 10); + + int encodedTrack = sector.location.logicalCylinder() | (sector.location.logicalHead() << 7); + int encodedSector = sector.location.logicalSector(); + writeBytes( + bits, + cursor, + Bytes.of(encodedTrack, encodedSector, (encodedTrack + encodedSector) & 0xff)); + + writeGap(bits, cursor, trackdata.getPostHeaderGapBits()); + + writeOneBits(bits, cursor, trackdata.getPreDataSyncBits()); + writeBits(bits, cursor, Victor9k.VICTOR9K_DATA_RECORD, 10); + + writeBytes(bits, cursor, sector.data); + + Bytes checksum = new Bytes(2); + checksum.writer().writeLe16(Crc.sumBytes(sector.data)); + writeBytes(bits, cursor, checksum); + writeGap(bits, cursor, trackdata.getPostDataGapBits()); + } + + private Victor9kEncoderProto.TrackdataProto getTrackFormat(int track, int head) + { + Victor9kEncoderProto.TrackdataProto.Builder builder = + Victor9kEncoderProto.TrackdataProto.newBuilder(); + for (Victor9kEncoderProto.TrackdataProto f : config.getTrackdataList()) + { + if (f.hasMinTrack() && (track < f.getMinTrack())) + continue; + if (f.hasMaxTrack() && (track > f.getMaxTrack())) + continue; + if (f.hasHead() && (head != f.getHead())) + continue; + + builder.mergeFrom(f); + } + return builder.build(); + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + Victor9kEncoderProto.TrackdataProto trackdata = + getTrackFormat(ltl.logicalCylinder, ltl.logicalHead); + + int bitsPerRevolution = + (int) ((trackdata.getRotationalPeriodMs() * 1e3) / trackdata.getClockPeriodUs()); + Bits bits = new Bits(bitsPerRevolution); + long clockPeriod = (long) calculatePhysicalClockPeriodNs( + trackdata.getClockPeriodUs() * 1e3, + trackdata.getRotationalPeriodMs() * 1e6); + Bits.Cursor cursor = new Bits.Cursor(0); + + bits.fillBitmapTo( + cursor, + (int) (trackdata.getPostIndexGapUs() * 1e3 / clockPeriod), + new boolean[]{true, false}); + lastBit[0] = false; + + for (Sector sector : sectors) + writeSector(bits, cursor, trackdata, sector); + + if (cursor.get() >= bits.size()) + throw new FluxEngineException( + "track data overrun by " + (cursor.get() - bits.size()) + " bits"); + bits.fillBitmapTo(cursor, bits.size(), new boolean[]{true, false}); + + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBits(bits, clockPeriod); + return fluxmap; + } +} diff --git a/arch/victor9k/data_gcr.h b/java/com/cowlark/fluxengine/arch/victor9k/data_gcr.h similarity index 100% rename from arch/victor9k/data_gcr.h rename to java/com/cowlark/fluxengine/arch/victor9k/data_gcr.h diff --git a/arch/victor9k/decoder.cc b/java/com/cowlark/fluxengine/arch/victor9k/decoder.cc similarity index 100% rename from arch/victor9k/decoder.cc rename to java/com/cowlark/fluxengine/arch/victor9k/decoder.cc diff --git a/arch/victor9k/encoder.cc b/java/com/cowlark/fluxengine/arch/victor9k/encoder.cc similarity index 100% rename from arch/victor9k/encoder.cc rename to java/com/cowlark/fluxengine/arch/victor9k/encoder.cc diff --git a/arch/victor9k/victor9k.h b/java/com/cowlark/fluxengine/arch/victor9k/victor9k.h similarity index 100% rename from arch/victor9k/victor9k.h rename to java/com/cowlark/fluxengine/arch/victor9k/victor9k.h diff --git a/java/com/cowlark/fluxengine/arch/victor9k/victor9k.proto b/java/com/cowlark/fluxengine/arch/victor9k/victor9k.proto new file mode 100644 index 000000000..4cd1105a5 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/victor9k/victor9k.proto @@ -0,0 +1,39 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.victor9k"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message Victor9kDecoderProto {} + +// NEXT: 12 +message Victor9kEncoderProto +{ + message TrackdataProto + { + optional int32 min_track = 1 + [(help) = "minimum track this format applies to"]; + optional int32 max_track = 2 + [(help) = "maximum track this format applies to"]; + optional int32 head = 3 + [(help) = "which head this format applies to"]; + + optional double rotational_period_ms = 4 + [(help) = "original rotational period of this track"]; + optional double clock_period_us = 5 + [(help) = "original data rate of this track"]; + optional double post_index_gap_us = 6 + [(help) = "size of post-index gap"]; + optional int32 pre_header_sync_bits = 10 + [(help) = "number of sync bits before the sector header"]; + optional int32 pre_data_sync_bits = 8 + [(help) = "number of sync bits before the sector data"]; + optional int32 post_data_gap_bits = 9 + [(help) = "size of gap between data and the next header"]; + optional int32 post_header_gap_bits = 11 + [(help) = "size of gap between header and the data"]; + } + + repeated TrackdataProto trackdata = 1; +} diff --git a/java/com/cowlark/fluxengine/arch/zilogmcz/ZilogMczDecoder.java b/java/com/cowlark/fluxengine/arch/zilogmcz/ZilogMczDecoder.java new file mode 100644 index 000000000..6ce873ccf --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/zilogmcz/ZilogMczDecoder.java @@ -0,0 +1,58 @@ +package com.cowlark.fluxengine.arch.zilogmcz; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.FluxPattern; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.decoders.Decoder; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.Crc; +import com.cowlark.fluxengine.external.FmMfm; + +/** + * The Zilog MCZ decoder, ported from arch/zilogmcz/decoder.cc. + */ +public class ZilogMczDecoder extends Decoder +{ + private static final FluxPattern SECTOR_START_PATTERN = new FluxPattern(16, 0xaaab); + + public ZilogMczDecoder(DecoderProto config) + { + super(config); + } + + @Override + protected double advanceToNextRecord() + { + seekToIndexMark(); + return seekToPattern(SECTOR_START_PATTERN); + } + + @Override + protected void decodeSectorRecord() + { + readRawBits(14); + + Bits rawbits = readRawBits(140 * 16); + Bytes bytes = FmMfm.decodeFmMfm(rawbits).slice(0, 140); + ByteReader br = bytes.iterator(); + + int logicalSector = br.read8() & 0x1f; + int logicalHead = 0; + int logicalCylinder = br.read8() & 0x7f; + sector.location = new LogicalLocation(logicalCylinder, logicalHead, logicalSector); + if (logicalSector > 31) + return; + if (logicalCylinder > 80) + return; + + sector.data = br.read(132); + int wantChecksum = br.readBe16(); + int gotChecksum = Crc.crc16(Crc.MODBUS_POLY, 0x0000, bytes.slice(0, 134)); + + sector.status = + (wantChecksum == gotChecksum) ? Sector.Status.OK : Sector.Status.BAD_CHECKSUM; + } +} \ No newline at end of file diff --git a/arch/zilogmcz/decoder.cc b/java/com/cowlark/fluxengine/arch/zilogmcz/decoder.cc similarity index 100% rename from arch/zilogmcz/decoder.cc rename to java/com/cowlark/fluxengine/arch/zilogmcz/decoder.cc diff --git a/arch/zilogmcz/zilogmcz.h b/java/com/cowlark/fluxengine/arch/zilogmcz/zilogmcz.h similarity index 100% rename from arch/zilogmcz/zilogmcz.h rename to java/com/cowlark/fluxengine/arch/zilogmcz/zilogmcz.h diff --git a/java/com/cowlark/fluxengine/arch/zilogmcz/zilogmcz.proto b/java/com/cowlark/fluxengine/arch/zilogmcz/zilogmcz.proto new file mode 100644 index 000000000..3a9bda3b0 --- /dev/null +++ b/java/com/cowlark/fluxengine/arch/zilogmcz/zilogmcz.proto @@ -0,0 +1,7 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.zilogmcz"; +option java_multiple_files = true; + +message ZilogMczDecoderProto {} + diff --git a/java/com/cowlark/fluxengine/buildtools/BUILD.bazel b/java/com/cowlark/fluxengine/buildtools/BUILD.bazel new file mode 100644 index 000000000..929d6bde0 --- /dev/null +++ b/java/com/cowlark/fluxengine/buildtools/BUILD.bazel @@ -0,0 +1,37 @@ +load("@rules_java//java:defs.bzl", "java_binary", "java_library") + +package(default_visibility = ["//visibility:public"]) + +java_library( + name = "buildtools", + srcs = glob( + ["*.java"], + exclude = ["EncodeDecodeTest.java"], + ), + deps = [ + "//java/com/cowlark/fluxengine/config:config_java_proto", + "@com_google_protobuf//java/core", + ], +) + +java_binary( + name = "protoencode", + main_class = "com.cowlark.fluxengine.buildtools.ProtoEncode", + runtime_deps = [":buildtools"], +) + +java_library( + name = "encodedecodetest", + srcs = ["EncodeDecodeTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/cli", + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_guava_guava", + ], +) + +java_binary( + name = "encodedecodetest_bin", + main_class = "com.cowlark.fluxengine.buildtools.EncodeDecodeTest", + runtime_deps = [":encodedecodetest"], +) diff --git a/java/com/cowlark/fluxengine/buildtools/EncodeDecodeTest.java b/java/com/cowlark/fluxengine/buildtools/EncodeDecodeTest.java new file mode 100644 index 000000000..3830cd031 --- /dev/null +++ b/java/com/cowlark/fluxengine/buildtools/EncodeDecodeTest.java @@ -0,0 +1,103 @@ +package com.cowlark.fluxengine.buildtools; + +import com.cowlark.fluxengine.cli.Command; +import com.cowlark.fluxengine.cli.ReadCommand; +import com.cowlark.fluxengine.cli.WriteCommand; +import com.cowlark.fluxengine.core.LogRenderer; +import com.cowlark.fluxengine.core.Logger; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; + +/** + * A round-trip encode/decode test for a single format, ported from + * scripts/encodedecodetest.sh. Generates a random sector image, writes it out + * as flux with the WriteCommand, reads it back with the ReadCommand, and checks + * that the two images match. + * + *

Arguments: {@code format ext [flags...]}, where {@code ext} is the flux + * file extension ({@code scp} or {@code flux}) and the flags are the extra + * format-specific options (e.g. {@code --360}). The {@code -c} config flag, + * {@code --drive.rotational_period_ms=200}, and the file names are supplied by + * this program. + */ +public class EncodeDecodeTest +{ + public static void main(String[] args) throws Exception + { + Logger.setLogger(LogRenderer.create(System.out)::add); + + String format = args[0]; + String ext = args[1]; + ImmutableList flags = + ImmutableList.copyOf(java.util.Arrays.asList(args).subList(2, args.length)); + + Path dir = Files.createTempDirectory("encodedecodetest"); + Path srcFile = dir.resolve("src.img"); + Path fluxFile = dir.resolve("flux." + ext); + Path destFile = dir.resolve("dest.img"); + + writeRandomImage(srcFile); + + run( + new WriteCommand(), ImmutableList.builder() + .add("-c", format, "-i", srcFile.toString(), "-d", fluxFile.toString()) + .add("--drive.rotational_period_ms=200") + .add("--no-verify") + .addAll(flags) + .build()); + + run( + new ReadCommand(), ImmutableList.builder() + .add("-c", format, "-s", fluxFile.toString(), "-o", destFile.toString()) + .add("--drive.rotational_period_ms=200") + .addAll(flags) + .build()); + + long destSize = Files.size(destFile); + if (destSize == 0) + { + System.err.println("Zero length output file!"); + System.exit(1); + } + + /* Make the source file the same length as the destination, ported from + * the script's `truncate -r $destfile $srcfile`. */ + try (RandomAccessFile raf = new RandomAccessFile(srcFile.toFile(), "rw")) + { + raf.setLength(destSize); + } + + long firstDifference = Files.mismatch(srcFile, destFile); + if (firstDifference != -1) + { + System.err.printf("Comparison failed at offset %d!\n", firstDifference); + System.err.println("Run this to repeat:"); + System.err.println( + "bazel run //java/com/cowlark/fluxengine/buildtools:encodedecodetest_bin -- " + + String.join(" ", args)); + System.exit(1); + } + } + + private static void run(Command command, ImmutableList args) throws Exception + { + System.out.printf( + "fluxengine %s %s%n", + command instanceof WriteCommand ? "write" : "read", + String.join(" ", args)); + command.run(args); + } + + private static void writeRandomImage(Path path) throws IOException + { + /* The data is of no value, so a cheap PRNG is fine. */ + Random random = new Random(); + byte[] data = new byte[2 * 1024 * 1024]; + random.nextBytes(data); + Files.write(path, data); + } +} diff --git a/java/com/cowlark/fluxengine/buildtools/ProtoEncode.java b/java/com/cowlark/fluxengine/buildtools/ProtoEncode.java new file mode 100644 index 000000000..a5269f775 --- /dev/null +++ b/java/com/cowlark/fluxengine/buildtools/ProtoEncode.java @@ -0,0 +1,138 @@ +package com.cowlark.fluxengine.buildtools; + +import com.google.protobuf.Message; +import com.google.protobuf.TextFormat; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Reads a textpb file (with the {@code <<<}...{@code >>>} multiline string + * extension) and writes out the binary representation of the encoded protobuf, + * ported from scripts/protoencode.cc. + *

+ * Usage: ProtoEncode <input.textpb> <output.bin> + * [<proto-class-fqn>] + */ +public final class ProtoEncode +{ + private ProtoEncode() + { + } + + public static void main(String[] args) + { + if (args.length < 2) + { + System.err.println( + "Usage: ProtoEncode " + "[]"); + System.exit(1); + } + + String protoClass = args.length > 2 ? args[2] : "com.cowlark.fluxengine.config.ConfigProto"; + + try + { + byte[] data = encodeToBytes(readFile(args[0]), protoClass); + Files.write(Path.of(args[1]), data); + } catch (IOException e) + { + System.err.println("couldn't open file: " + e.getMessage()); + System.exit(1); + } catch (RuntimeException e) + { + System.err.println(e.getMessage()); + System.exit(1); + } + } + + /* Reads the textpb file, handling the multiline string extension, and + * returns the serialized protobuf bytes. */ + public static byte[] encodeToBytes(String contents, String protoClass) + { + String processed = processMultilineStrings(contents); + Message.Builder builder = newBuilder(protoClass); + try + { + TextFormat.merge(processed, builder); + } catch (TextFormat.ParseException e) + { + throw new RuntimeException("cannot parse text proto: " + e.getMessage()); + } + return builder.build().toByteArray(); + } + + /* Encodes the textpb and writes the serialized protobuf bytes to a file. */ + public static void encodeToFile(String contents, String output, String protoClass) + throws IOException + { + Files.write(Path.of(output), encodeToBytes(contents, protoClass)); + } + + private static String readFile(String filename) throws IOException + { + return Files.readString(Path.of(filename), StandardCharsets.UTF_8); + } + + private static String processMultilineStrings(String contents) + { + StringBuilder result = new StringBuilder(); + List lines = new ArrayList<>(); + Iterator it = contents.lines().iterator(); + while (it.hasNext()) + lines.add(it.next()); + int i = 0; + while (i < lines.size()) + { + String line = lines.get(i); + if (line.equals("<<<")) + { + i++; + while (i < lines.size()) + { + String s = lines.get(i++); + if (s.equals(">>>")) + break; + + result.append('"'); + int offset = 0; + while (offset < s.length()) + { + int codePoint = s.codePointAt(offset); + offset += Character.charCount(codePoint); + if (codePoint <= 0xffff) + result.append(String.format("\\u%04x", codePoint)); + else + result.append(String.format("\\U%08x", codePoint)); + } + result.append("\\n\"\n"); + } + } else + { + result.append(line).append('\n'); + i++; + } + } + return result.toString(); + } + + private static Message.Builder newBuilder(String protoClass) + { + try + { + Class clazz = Class.forName(protoClass); + Method method = clazz.getMethod("newBuilder"); + return (Message.Builder) method.invoke(null); + } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | + InvocationTargetException | ClassCastException e) + { + throw new RuntimeException("cannot create builder for " + protoClass + ": " + e); + } + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/cli/BUILD.bazel b/java/com/cowlark/fluxengine/cli/BUILD.bazel new file mode 100644 index 000000000..c76fb27bf --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/BUILD.bazel @@ -0,0 +1,30 @@ +load("@rules_java//java:defs.bzl", "java_library") + +package(default_visibility = ["//visibility:public"]) + +java_library( + name = "cli", + srcs = glob(["*.java"]), + deps = [ + "//java/com/cowlark/fluxengine/algorithms", + "//java/com/cowlark/fluxengine/arch", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:common_java_proto", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/core/flags", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/decoders", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "//java/com/cowlark/fluxengine/encoders", + "//java/com/cowlark/fluxengine/external", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "//java/com/cowlark/fluxengine/fluxsink", + "//java/com/cowlark/fluxengine/fluxsource", + "//java/com/cowlark/fluxengine/gui", + "//java/com/cowlark/fluxengine/imagereader", + "//java/com/cowlark/fluxengine/imagewriter", + "//java/com/cowlark/fluxengine/usb", + "@maven//:com_google_guava_guava", + ], +) diff --git a/java/com/cowlark/fluxengine/cli/Command.java b/java/com/cowlark/fluxengine/cli/Command.java new file mode 100644 index 000000000..42983b910 --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/Command.java @@ -0,0 +1,107 @@ +package com.cowlark.fluxengine.cli; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import java.util.function.Supplier; + +public interface Command +{ + ImmutableMap> ANALYSABLES = + ImmutableMap.>builder().put( + "driveresponse", stub( + "driveresponse", + "Measures the drive's ability to read and write pulses.")).put( + "layout", + stub("layout", "Produces a visualisation of the track/sector layout.")).build(); + + ImmutableMap> FLUXFILEABLES = + ImmutableMap.>builder() + .put("ls", FluxfileLsCommand::new) + .put("rm", FluxfileRmCommand::new) + .put("cp", FluxfileCpCommand::new) + .build(); + + ImmutableMap> TESTABLES = + ImmutableMap.>builder() + .put("bandwidth", TestBandwidthCommand::new) + .put("voltages", TestVoltagesCommand::new) + .build(); + + + ImmutableMap> VFSABLES = + ImmutableMap.>builder() + .put("ls", stub("ls", "Show files on disk (or image).")) + .put("mv", stub("mv", "Rename a file on a disk (or image).")) + .put("rm", stub("rm", "Deletes a file (or directory) off a disk (or image).")) + .put("getfile", stub("getfile", "Read a file off a disk (or image).")) + .put( + "getfileinfo", + stub("getfileinfo", "Read file metadata off a disk (or image).")) + .put("putfile", stub("putfile", "Write a file to disk (or image).")) + .put("mkdir", stub("mkdir", "Create a directory on disk (or image).")) + .put( + "getdiskinfo", + stub("getdiskinfo", "Read volume metadata off a disk (or image).")) + .put("format", stub("format", "Format a disk and make a file system on it.")) + .build(); + + ImmutableMap> COMMANDS = + ImmutableMap.>builder() + .put( + "analyse", + () -> new CommandGroup(ANALYSABLES, "Disk and drive analysis tools.")) + .put("test", () -> new CommandGroup(TESTABLES, "Various testing commands.")) + .put( + "fluxfile", + () -> new CommandGroup( + FLUXFILEABLES, + "Flux file manipulation operations.")) + .put( + "vfs", + () -> new CommandGroup(VFSABLES, "File system manipulation commands.")) + .put("read", ReadCommand::new) + .put("write", WriteCommand::new) + .put("rawwrite", RawwriteCommand::new) + .put("convert", ConvertCommand::new) + .put("rpm", RpmCommand::new) + .put("seek", SeekCommand::new) + .put("devices", DevicesCommand::new) + .put("inspect", InspectCommand::new) + .put("gui", GuiCommand::new) + .build(); + + /* Consume arguments until we reach a real command, instantiate it, and + * run it with the tail of the argv array. */ + static boolean dispatch(Map> commands, + ImmutableList args) + { + for (int index = 0; index < args.size(); index++) + { + Supplier supplier = commands.get(args.get(index)); + if (supplier != null) + { + try + { + supplier.get().run(ImmutableList.copyOf(args.subList(index + 1, args.size()))); + } catch (Exception e) + { + throw new RuntimeException(e); + } + return true; + } + } + + return false; + } + + static Supplier stub(String name, String help) + { + return () -> new StubCommand(name, help); + } + + String getHelp(); + + void run(ImmutableList args) throws Exception; + +} diff --git a/java/com/cowlark/fluxengine/cli/CommandGroup.java b/java/com/cowlark/fluxengine/cli/CommandGroup.java new file mode 100644 index 000000000..68438ce30 --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/CommandGroup.java @@ -0,0 +1,34 @@ +package com.cowlark.fluxengine.cli; + +import com.google.common.collect.ImmutableList; +import java.util.Map; +import java.util.function.Supplier; + +/** + * A command which dispatches to a table of subcommands, modelled on the + * mainExtended() helper in src/fluxengine.cc. + */ +public class CommandGroup implements Command +{ + private final Map> subcommands; + private final String help; + + public CommandGroup(Map> subcommands, String help) + { + this.subcommands = subcommands; + this.help = help; + } + + @Override + public String getHelp() + { + return help; + } + + @Override + public void run(ImmutableList args) + { + if (!Command.dispatch(subcommands, args)) + Main.help(subcommands, " [...]"); + } +} diff --git a/java/com/cowlark/fluxengine/cli/ConvertCommand.java b/java/com/cowlark/fluxengine/cli/ConvertCommand.java new file mode 100644 index 000000000..b261c1564 --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/ConvertCommand.java @@ -0,0 +1,95 @@ +package com.cowlark.fluxengine.cli; + +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_DRIVE; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_NOT_SET; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.Logger; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.StringFlag; +import com.cowlark.fluxengine.core.flags.ValueFlag; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.DiskLayout; +import com.cowlark.fluxengine.fluxsink.FluxSink; +import com.cowlark.fluxengine.fluxsink.FluxSinkFactory; +import com.cowlark.fluxengine.fluxsource.FluxReadParameters; +import com.cowlark.fluxengine.fluxsource.FluxSource; +import com.cowlark.fluxengine.fluxsource.FluxSourceIterator; +import com.google.common.collect.ImmutableList; + +/** + * Converts a flux file from one format to another, modelled after + * src/fe-convert.cc. + */ +public class ConvertCommand implements Command +{ + private FlagGroup flags = new FlagGroup(); + private ValueFlag sourceFluxFlag = StringFlag.builder() + .setGroup(flags) + .setName("--source") + .setName("-s") + .setHelpText("flux file to read from") + .build(); + private ValueFlag destImageFlag = StringFlag.builder() + .setGroup(flags) + .setName("--dest") + .setName("-d") + .setHelpText("flux file to write to") + .build(); + + @Override + public String getHelp() + { + return "Converts a flux file from one format to another."; + } + + @Override + public void run(ImmutableList args) + { + ConfigBuilder builder = new ConfigBuilder().fromFlags(args, flags); + if (sourceFluxFlag.isSet()) + builder.withFluxSource(sourceFluxFlag.get()); + if (destImageFlag.isSet()) + builder.withFluxSink(destImageFlag.get()); + ConfigProto config = builder.build(); + + if ((config.getFluxSink().getType() == FLUXTYPE_DRIVE) || + (config.getFluxSource().getType() == FLUXTYPE_DRIVE)) + throw new FluxEngineException("you cannot read or write flux to a hardware device"); + if ((config.getFluxSink().getType() == FLUXTYPE_NOT_SET) || + (config.getFluxSource().getType() == FLUXTYPE_NOT_SET)) + throw new FluxEngineException( + "you must specify both a source and destination flux filename"); + + FluxSource fluxSource = FluxSource.create(config); + + DiskLayout diskLayout = new DiskLayout(config); + int minCylinder = diskLayout.minPhysicalCylinder; + int maxCylinder = diskLayout.maxPhysicalCylinder; + int minHead = diskLayout.minPhysicalHead; + int maxHead = diskLayout.maxPhysicalHead; + Logger.logf( + "CONVERT: seen cylinders %d..%d, heads %d..%d", + minCylinder, + maxCylinder, + minHead, + maxHead); + + FluxSinkFactory fluxSinkFactory = FluxSinkFactory.create(config); + try (FluxSink fluxSink = fluxSinkFactory.create()) + { + for (CylinderHead physicalLocation : diskLayout.physicalLocations) + { + FluxSourceIterator fi = + fluxSource.readFlux(FluxReadParameters.builder() + .setCylinder(physicalLocation.cylinder()) + .setHead(physicalLocation.head()) + .build()); + while (fi.hasNext()) + fluxSink.addFlux(physicalLocation, fi.next()); + } + } + } +} diff --git a/java/com/cowlark/fluxengine/cli/DevicesCommand.java b/java/com/cowlark/fluxengine/cli/DevicesCommand.java new file mode 100644 index 000000000..847c6cca7 --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/DevicesCommand.java @@ -0,0 +1,52 @@ +package com.cowlark.fluxengine.cli; + +import static com.google.common.base.Strings.nullToEmpty; + +import com.cowlark.fluxengine.config.UsbFinder; +import com.cowlark.fluxengine.config.UsbFinder.CandidateDevice; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.google.common.collect.ImmutableList; +import java.util.List; + +public class DevicesCommand implements Command +{ + private static final FlagGroup EMPTY = new FlagGroup(); + + @Override + public String getHelp() + { + return "Displays all detected devices."; + } + + @Override + public void run(ImmutableList args) + { + List candidates = UsbFinder.findUsbDevices(); + switch (candidates.size()) + { + case 0: + System.out.println("Detected no devices."); + break; + + case 1: + System.out.println("Detected one device:"); + break; + + default: + System.out.printf("Detected %d devices:\n", candidates.size()); + } + + if (!candidates.isEmpty()) + { + System.out.printf("%-15s %-30s %s\n", "Type", "Serial number", "Port (if any)"); + for (CandidateDevice candidate : candidates) + { + System.out.printf( + "%-15s %-30s %s\n", + candidate.type.getDeviceName(), + candidate.serial, + nullToEmpty(candidate.serialPort)); + } + } + } +} diff --git a/java/com/cowlark/fluxengine/cli/FluxfileCpCommand.java b/java/com/cowlark/fluxengine/cli/FluxfileCpCommand.java new file mode 100644 index 000000000..ce96775d0 --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/FluxfileCpCommand.java @@ -0,0 +1,112 @@ +package com.cowlark.fluxengine.cli; + +import static com.cowlark.fluxengine.core.flags.Flags.parse; + +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.StringFlag; +import com.cowlark.fluxengine.core.flags.ValueFlag; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.Locations; +import com.cowlark.fluxengine.external.FluxFileProto; +import com.cowlark.fluxengine.external.TrackFluxProto; +import com.cowlark.fluxengine.fluxsink.Fl2FluxSink; +import com.cowlark.fluxengine.fluxsource.Fl2FluxSource; +import com.google.common.collect.ImmutableList; + +/** + * Copies flux from one flux file to another, modelled after + * src/fe-fluxfilecp.cc. + */ +public class FluxfileCpCommand implements Command +{ + private FlagGroup flags = new FlagGroup(); + private ValueFlag inputFilenameFlag = StringFlag.builder() + .setGroup(flags) + .setName("--input") + .setName("-i") + .setHelpText("input flux file") + .build(); + private ValueFlag outputFilenameFlag = StringFlag.builder() + .setGroup(flags) + .setName("--output") + .setName("-o") + .setHelpText("output flux file (must exist)") + .build(); + private ValueFlag tracksFlag = StringFlag.builder() + .setGroup(flags) + .setName("--tracks") + .setName("-t") + .setHelpText("tracks to copy") + .build(); + + private static TrackFluxProto findTrack(FluxFileProto f, int cylinder, int head) + { + for (TrackFluxProto trackFlux : f.getTrackList()) + if ((trackFlux.getTrack() == cylinder) && (trackFlux.getHead() == head)) + return trackFlux; + + return null; + } + + private static TrackFluxProto.Builder findOrMakeTrack(FluxFileProto.Builder f, + int cylinder, + int head) + { + for (TrackFluxProto.Builder trackFlux : f.getTrackBuilderList()) + if ((trackFlux.getTrack() == cylinder) && (trackFlux.getHead() == head)) + return trackFlux; + + TrackFluxProto.Builder tf = f.addTrackBuilder(); + tf.setTrack(cylinder); + tf.setHead(head); + return tf; + } + + @Override + public String getHelp() + { + return "Copies flux from one flux file to another."; + } + + @Override + public void run(ImmutableList args) + { + parse(args, flags); + if (!inputFilenameFlag.isSet()) + throw new FluxEngineException("you must specify an input filename with -i"); + if (!outputFilenameFlag.isSet()) + throw new FluxEngineException("you must specify an output filename with -o"); + + System.out.println(inputFilenameFlag.get() + " -> " + outputFilenameFlag.get() + ":"); + FluxFileProto inf = Fl2FluxSource.loadFl2File(inputFilenameFlag.get()); + FluxFileProto outf = Fl2FluxSource.loadFl2File(outputFilenameFlag.get()); + + boolean changed = false; + FluxFileProto.Builder outBuilder = outf.toBuilder(); + for (CylinderHead location : Locations.parseCylinderHeadsString(tracksFlag.get())) + { + TrackFluxProto intrack = findTrack(inf, location.cylinder(), location.head()); + if (intrack == null) + { + System.out.println(" location c" + location.cylinder() + "h" + location.head() + + " not found"); + continue; + } + + TrackFluxProto.Builder outtrack = + findOrMakeTrack(outBuilder, location.cylinder(), location.head()); + System.out.println(" copying c" + location.cylinder() + "h" + location.head()); + for (int i = 0; i < intrack.getFluxCount(); i++) + outtrack.addFlux(intrack.getFlux(i)); + changed = true; + } + + if (changed) + { + System.out.println("writing back output file"); + Fl2FluxSink.saveFl2File(outputFilenameFlag.get(), outBuilder); + } else + System.out.println("output file not modified"); + } +} diff --git a/java/com/cowlark/fluxengine/cli/FluxfileLsCommand.java b/java/com/cowlark/fluxengine/cli/FluxfileLsCommand.java new file mode 100644 index 000000000..e4e19b2a4 --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/FluxfileLsCommand.java @@ -0,0 +1,87 @@ +package com.cowlark.fluxengine.cli; + +import static com.cowlark.fluxengine.core.flags.Flags.parse; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.StringFlag; +import com.cowlark.fluxengine.core.flags.ValueFlag; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.external.FluxFileProto; +import com.cowlark.fluxengine.external.TrackFluxProto; +import com.cowlark.fluxengine.fluxsource.Fl2FluxSource; +import com.google.common.collect.ImmutableList; + +/** + * Lists the contents of a flux file, modelled after src/fe-fluxfilels.cc. + */ +public class FluxfileLsCommand implements Command +{ + private FlagGroup flags = new FlagGroup(); + private ValueFlag fluxFilename = StringFlag.builder() + .setGroup(flags) + .setName("--fluxfile") + .setName("-f") + .setHelpText("flux file to show") + .build(); + + @Override + public String getHelp() + { + return "Lists the contents of a flux file."; + } + + @Override + public void run(ImmutableList args) + { + parse(args, flags); + if (!fluxFilename.isSet()) + throw new FluxEngineException("you must specify a filename with -f"); + + System.out.println(fluxFilename.get() + ":"); + FluxFileProto f = Fl2FluxSource.loadFl2File(fluxFilename.get()); + + String[] fields = {"version", "rotational_period_ms", "drive_type", "format_type"}; + for (String field : fields) + { + String value; + switch (field) + { + case "version": + value = f.getVersion().name(); + break; + case "rotational_period_ms": + value = Double.toString(f.getRotationalPeriodMs()); + break; + case "drive_type": + value = f.getDriveType().name(); + break; + case "format_type": + value = f.getFormatType().name(); + break; + default: + throw new IllegalStateException(); + } + System.out.println(" " + field + ": " + value); + } + + for (TrackFluxProto trackFlux : f.getTrackList()) + { + System.out.print( + " flux for c" + trackFlux.getTrack() + "h" + trackFlux.getHead() + ":"); + + boolean first = true; + for (int i = 0; i < trackFlux.getFluxCount(); i++) + { + Fluxmap fluxmap = new Fluxmap(new Bytes(trackFlux.getFlux(i).toByteArray())); + if (!first) + System.out.print(","); + System.out.printf(" %.3fms", fluxmap.durationNs() / 1000000.0); + first = false; + } + + System.out.println(); + } + } +} diff --git a/java/com/cowlark/fluxengine/cli/FluxfileRmCommand.java b/java/com/cowlark/fluxengine/cli/FluxfileRmCommand.java new file mode 100644 index 000000000..9ea709dc8 --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/FluxfileRmCommand.java @@ -0,0 +1,82 @@ +package com.cowlark.fluxengine.cli; + +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.Flags; +import com.cowlark.fluxengine.core.flags.StringFlag; +import com.cowlark.fluxengine.core.flags.ValueFlag; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.Locations; +import com.cowlark.fluxengine.external.FluxFileProto; +import com.cowlark.fluxengine.external.TrackFluxProto; +import com.cowlark.fluxengine.fluxsink.Fl2FluxSink; +import com.cowlark.fluxengine.fluxsource.Fl2FluxSource; +import com.google.common.collect.ImmutableList; + +/** + * Removes flux from a flux file, modelled after src/fe-fluxfilerm.cc. + */ +public class FluxfileRmCommand implements Command +{ + private FlagGroup flags = new FlagGroup(); + private ValueFlag fluxFilename = StringFlag.builder() + .setGroup(flags) + .setName("--fluxfile") + .setName("-f") + .setHelpText("flux file to remove from") + .build(); + private ValueFlag tracksFlag = StringFlag.builder() + .setGroup(flags) + .setName("--tracks") + .setName("-t") + .setHelpText("tracks to remove") + .build(); + + @Override + public String getHelp() + { + return "Removes flux from a flux file."; + } + + @Override + public void run(ImmutableList args) + { + Flags.parse(args, flags); + if (!fluxFilename.isSet()) + throw new FluxEngineException("you must specify a filename with -f"); + + System.out.println(fluxFilename.get() + ":"); + FluxFileProto f = Fl2FluxSource.loadFl2File(fluxFilename.get()); + + boolean changed = false; + FluxFileProto.Builder builder = f.toBuilder(); + for (CylinderHead location : Locations.parseCylinderHeadsString(tracksFlag.get())) + { + boolean found = false; + for (int i = 0; i < builder.getTrackCount(); i++) + { + TrackFluxProto trackFlux = builder.getTrack(i); + if ((trackFlux.getTrack() == location.cylinder()) && + (trackFlux.getHead() == location.head())) + { + System.out.println( + " removing c" + location.cylinder() + "h" + location.head()); + builder.removeTrack(i); + found = changed = true; + i--; + } + } + + if (!found) + System.out.println(" location c" + location.cylinder() + "h" + location.head() + + " not found"); + } + + if (changed) + { + System.out.println("writing back file"); + Fl2FluxSink.saveFl2File(fluxFilename.get(), builder); + } else + System.out.println("file not modified"); + } +} diff --git a/java/com/cowlark/fluxengine/cli/GuiCommand.java b/java/com/cowlark/fluxengine/cli/GuiCommand.java new file mode 100644 index 000000000..17ca2e00c --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/GuiCommand.java @@ -0,0 +1,19 @@ +package com.cowlark.fluxengine.cli; + +import com.cowlark.fluxengine.gui.Gui; +import com.google.common.collect.ImmutableList; + +public class GuiCommand implements Command +{ + @Override + public String getHelp() + { + return "Launch the GUI."; + } + + @Override + public void run(ImmutableList args) throws Exception + { + new Gui().run(args); + } +} diff --git a/java/com/cowlark/fluxengine/cli/InspectCommand.java b/java/com/cowlark/fluxengine/cli/InspectCommand.java new file mode 100644 index 000000000..1eb1e281a --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/InspectCommand.java @@ -0,0 +1,331 @@ +package com.cowlark.fluxengine.cli; + +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_PULSE; +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; +import static com.cowlark.fluxengine.external.FluxEngine.US_PER_TICK; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.Utils; +import com.cowlark.fluxengine.core.flags.DoubleFlag; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.IntFlag; +import com.cowlark.fluxengine.core.flags.SettableFlag; +import com.cowlark.fluxengine.core.flags.StringFlag; +import com.cowlark.fluxengine.core.flags.ValueFlag; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.FluxmapReader; +import com.cowlark.fluxengine.data.Locations; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.decoders.FluxDecoder; +import com.cowlark.fluxengine.external.FmMfm; +import com.cowlark.fluxengine.fluxsource.FluxReadParameters; +import com.cowlark.fluxengine.fluxsource.FluxSource; +import com.cowlark.fluxengine.fluxsource.FluxSourceIterator; +import com.google.common.collect.ImmutableList; + +/** + * Low-level analysis and inspection of a disk, modelled after + * src/fe-inspect.cc. + */ +public class InspectCommand implements Command +{ + private static final String[] BLOCK_ELEMENTS = {" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"}; + + private FlagGroup flags = new FlagGroup(); + private ValueFlag sourceFluxFlag = StringFlag.builder() + .setGroup(flags) + .setName("--source") + .setName("-s") + .setHelpText("'drive:' flux source to use") + .build(); + private ValueFlag destTracksFlag = StringFlag.builder() + .setGroup(flags) + .setName("--tracks") + .setName("-t") + .setHelpText("tracks to write to") + .setDefaultValue("c0h0") + .build(); + private SettableFlag dumpFluxFlag = SettableFlag.builder() + .setGroup(flags) + .setName("--dump-flux") + .setName("-F") + .setHelpText("Dump raw magnetic disk flux.") + .build(); + private SettableFlag dumpBitstreamFlag = SettableFlag.builder() + .setGroup(flags) + .setName("--dump-bitstream") + .setName("-B") + .setHelpText("Dump aligned bitstream.") + .build(); + private ValueFlag dumpRawFlag = IntFlag.builder() + .setGroup(flags) + .setName("--dump-raw") + .setName("-R") + .setHelpText("Dump raw binary with offset.") + .build(); + private SettableFlag dumpMfmFmFlag = SettableFlag.builder() + .setGroup(flags) + .setName("--mfmfm") + .setHelpText("When dumping raw binary, do MFM/FM decoding first.") + .build(); + private SettableFlag dumpBytecodesFlag = SettableFlag.builder() + .setGroup(flags) + .setName("--dump-bytecodes") + .setName("-H") + .setHelpText("Dump the raw FluxEngine bytecodes.") + .build(); + private ValueFlag fluxmapResolutionFlag = IntFlag.builder() + .setGroup(flags) + .setName("--fluxmap-resolution") + .setHelpText("Resolution of flux visualisation (nanoseconds). 0 to autoscale") + .build(); + private ValueFlag seekFlag = DoubleFlag.builder() + .setGroup(flags) + .setName("--seek") + .setName("-S") + .setHelpText("Seek this many milliseconds into the track before displaying it.") + .build(); + private ValueFlag manualClockRateFlag = DoubleFlag.builder() + .setGroup(flags) + .setName("--manual-clock-rate-us") + .setName("-u") + .setHelpText("If not zero, force this clock rate; if zero, try to autodetect it.") + .setDefaultValue(0.0) + .build(); + private ValueFlag noiseFloorFactorFlag = DoubleFlag.builder() + .setGroup(flags) + .setName("--noise-floor-factor") + .setHelpText("Clock detection noise floor (min + (max-min)*factor).") + .setDefaultValue(0.01) + .build(); + private ValueFlag signalLevelFactorFlag = DoubleFlag.builder() + .setGroup(flags) + .setName("--signal-level-factor") + .setHelpText("Clock detection signal level (min + (max-min)*factor).") + .setDefaultValue(0.05) + .build(); + + @Override + public String getHelp() + { + return "Low-level analysis and inspection of a disk."; + } + + private double guessClock(Fluxmap fluxmap, FluxmapReader fmr) + { + double manualClockRate = manualClockRateFlag.get(); + if (manualClockRate != 0.0) + return manualClockRate * 1000.0; + + FluxmapReader.ClockData data = + fmr.guessClock(noiseFloorFactorFlag.get(), signalLevelFactorFlag.get()); + + System.out.println("\nClock detection histogram:"); + + int max = Integer.MIN_VALUE; + for (int b : data.buckets) + max = Math.max(max, b); + if (max == 0) + max = 1; + + boolean skipping = true; + for (int i = 0; i < 256; i++) + { + int value = data.buckets[i]; + if (value < data.noiseFloor / 2) + { + if (!skipping) + System.out.println("..."); + skipping = true; + } else + { + skipping = false; + + int bar = 320 * value / max; + int fullblocks = bar / 8; + + StringBuilder s = new StringBuilder(); + for (int j = 0; j < fullblocks; j++) + s.append(BLOCK_ELEMENTS[8]); + s.append(BLOCK_ELEMENTS[bar & 7]); + + System.out.printf("%3d %.2f %7d %s%n", i, i * US_PER_TICK, value, s); + } + } + + System.out.printf("Noise floor: %d%n", data.noiseFloor); + System.out.printf("Signal level: %d%n", data.signalLevel); + System.out.printf("Peak start: %.2f us%n", data.peakStartTicks * US_PER_TICK); + System.out.printf("Peak end: %.2f us%n", data.peakEndTicks * US_PER_TICK); + System.out.printf("Median: %.2f us%n", data.medianTicks * US_PER_TICK); + + return data.medianTicks * NS_PER_TICK; + } + + @Override + public void run(ImmutableList args) + { + ConfigBuilder builder = new ConfigBuilder().fromFlags(args, flags); + if (sourceFluxFlag.isSet()) + builder.withFluxSource(sourceFluxFlag.get()); + ConfigProto config = builder.build(); + + FluxSource fluxSource = FluxSource.create(config); + ImmutableList tracks = + Locations.parseCylinderHeadsString(destTracksFlag.get()); + if (tracks.size() != 1) + throw new FluxEngineException("you must specify exactly one track"); + CylinderHead ch = tracks.get(0); + FluxSourceIterator iterator = fluxSource.readFlux(FluxReadParameters.builder() + .setCylinder(ch.cylinder()) + .setHead(ch.head()) + .build()); + Fluxmap fluxmap = iterator.next(); + + System.out.printf( + "0x%x bytes of data in %.3fms%n", + fluxmap.bytes(), + fluxmap.durationNs() / 1e6); + System.out.printf( + "Required USB bandwidth: %dkB/s%n", + (int) (fluxmap.bytes() / 1024.0 / (fluxmap.durationNs() / 1e9))); + + FluxmapReader fmr = new FluxmapReader(fluxmap, DecoderProto.getDefaultInstance()); + double clockPeriod = guessClock(fluxmap, fmr); + System.out.printf("%.2f us clock detected.", clockPeriod / 1000.0); + System.out.flush(); + + fmr.seek((long) (seekFlag.get() * 1000000.0 / NS_PER_TICK)); + + if (dumpFluxFlag.get()) + { + System.out.println("\n\nMagnetic flux follows (times in us):"); + + int resolution = fluxmapResolutionFlag.get(); + if (resolution == 0) + resolution = (int) (clockPeriod / 4); + + double nextclock = clockPeriod; + + double now = fmr.tell().getDurationNs(); + long ticks = (long) (now / NS_PER_TICK); + + System.out.printf("%10.3f:-", ticks * US_PER_TICK); + double lasttransition = 0; + while (!fmr.eof()) + { + FluxmapReader.EventResult r = fmr.findEvent(F_BIT_PULSE); + long thisTicks = r.ticks(); + ticks += thisTicks; + + double transition = ticks * NS_PER_TICK; + double next; + + boolean clocked = false; + + boolean bannered = false; + for (; ; ) + { + next = now + resolution; + clocked = now >= nextclock; + if (clocked) + nextclock += clockPeriod; + if (next >= transition) + break; + if (!bannered) + { + System.out.printf("%n%10.3f:%c", next / 1000.0, clocked ? '-' : ' '); + bannered = true; + } + now = next; + } + + double length = transition - lasttransition; + if (!bannered) + { + System.out.printf("%n%10.3f:%c", next / 1000.0, clocked ? '-' : ' '); + bannered = true; + } + System.out.printf( + "==== %06x %10.3f +%.3f = %.1f clocks", + fmr.tell().bytes(), + transition / 1000.0, + length / 1000.0, + length / clockPeriod); + lasttransition = transition; + } + } + + if (dumpBitstreamFlag.get()) + { + System.out.printf( + "\n\nAligned bitstream from %.3fms follows:%n", + fmr.tell().getDurationNs() / 1000000.0); + + FluxDecoder decoder = new FluxDecoder(fmr, clockPeriod, config.getDecoder()); + while (!fmr.eof()) + { + System.out.printf( + "%06x %10.3f : ", + fmr.tell().bytes(), + fmr.tell().getDurationNs() / 1000000.0); + for (int i = 0; i < 50; i++) + { + if (fmr.eof()) + break; + boolean b = decoder.readBit(); + System.out.print(b ? 'X' : '-'); + } + + System.out.println(); + } + } + + if (dumpRawFlag.isSet()) + { + System.out.printf( + "\n\nRaw binary with offset %d from %.3fms follows:%n", + dumpRawFlag.get(), + fmr.tell().getDurationNs() / 1000000.0); + + FluxDecoder decoder = new FluxDecoder(fmr, clockPeriod, config.getDecoder()); + for (int i = 0; i < dumpRawFlag.get(); i++) + decoder.readBit(); + + while (!fmr.eof()) + { + System.out.printf( + "%06x %10.3f : ", + fmr.tell().bytes(), + fmr.tell().getDurationNs() / 1000000.0); + + Bytes bytes; + if (dumpMfmFmFlag.get()) + bytes = FmMfm.decodeFmMfm(decoder.readBits(32 * 8)); + else + bytes = decoder.readBits(16 * 8).toBytes(); + + for (int i = 0; i < 16; i++) + { + if (i >= bytes.size()) + break; + System.out.printf("%02x ", bytes.getByte(i) & 0xff); + } + + System.out.println(); + } + } + System.out.println(); + + if (dumpBytecodesFlag.get()) + { + System.out.println("Raw FluxEngine bytecodes follow:"); + + Utils.hexdump(System.out, fluxmap.rawBytes()); + } + } +} diff --git a/java/com/cowlark/fluxengine/cli/Main.java b/java/com/cowlark/fluxengine/cli/Main.java new file mode 100644 index 000000000..2799659cb --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/Main.java @@ -0,0 +1,45 @@ +package com.cowlark.fluxengine.cli; + +import com.cowlark.fluxengine.core.LogRenderer; +import com.cowlark.fluxengine.core.Logger; +import com.google.common.collect.ImmutableList; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Command-line entry point, ported from src/fluxengine.cc. The command and + * subcommand tables live here; main() consumes arguments until it reaches a + * real command, instantiates it, and runs it with the tail of the argv array. + */ +public class Main +{ + + private Main() + { + } + + public static void main(String[] args) + { + Logger.setLogger(LogRenderer.create(System.out)::add); + + if (args.length == 0 || args[0].equals("--help")) + { + help(Command.COMMANDS, " [...]"); + return; + } + + if (!Command.dispatch(Command.COMMANDS, ImmutableList.copyOf(args))) + { + System.err.println("fluxengine: unrecognised command (try --help)"); + System.exit(1); + } + } + + static void help(Map> commands, String syntax) + { + System.out.printf("fluxengine: syntax: fluxengine %s\n", syntax); + System.out.println("Try one of these commands:"); + for (Map.Entry> entry : commands.entrySet()) + System.out.printf(" %s: %s\n", entry.getKey(), entry.getValue().get().getHelp()); + } +} diff --git a/java/com/cowlark/fluxengine/cli/RawwriteCommand.java b/java/com/cowlark/fluxengine/cli/RawwriteCommand.java new file mode 100644 index 000000000..13a2f703c --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/RawwriteCommand.java @@ -0,0 +1,68 @@ +package com.cowlark.fluxengine.cli; + +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_DRIVE; + +import com.cowlark.fluxengine.algorithms.ReadWriteFluxOperation; +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.LogRenderer; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.StringFlag; +import com.cowlark.fluxengine.core.flags.ValueFlag; +import com.google.common.collect.ImmutableList; + +/** + * Write a flux file to a disk, modelled after src/fe-rawwrite.cc. + */ +public class RawwriteCommand implements Command +{ + private FlagGroup flags = new FlagGroup(); + private ValueFlag sourceFluxFlag = StringFlag.builder() + .setGroup(flags) + .setName("--source") + .setName("-s") + .setHelpText("source flux file to read from") + .build(); + private ValueFlag destFluxFlag = StringFlag.builder() + .setGroup(flags) + .setName("--dest") + .setName("-d") + .setHelpText("flux destination to write to") + .build(); + + @Override + public String getHelp() + { + return "Writes a flux file to a disk. Warning: you can't use this to copy disks."; + } + + private class RawwriteOperation extends ReadWriteFluxOperation + { + @Override + public void run() + { + rawWrite(); + } + } + + @Override + public void run(ImmutableList args) + { + ConfigProto config = new ConfigBuilder().fromFlags(args, flags) + .withFluxSource(sourceFluxFlag.get()) + .withFluxSink(destFluxFlag.get()) + .build(); + + if (config.getFluxSource().getType() == FLUXTYPE_DRIVE) + throw new FluxEngineException("you can't use rawwrite to read from hardware"); + + LogRenderer renderer = LogRenderer.create(System.out); + new RawwriteOperation().setConfig(config).create().blockingSubscribe( + renderer::add, e -> { + System.err.println("Failed!"); + e.printStackTrace(); + }); + System.out.println("done."); + } +} diff --git a/java/com/cowlark/fluxengine/cli/ReadCommand.java b/java/com/cowlark/fluxengine/cli/ReadCommand.java new file mode 100644 index 000000000..42f3d8908 --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/ReadCommand.java @@ -0,0 +1,77 @@ +package com.cowlark.fluxengine.cli; + +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_DRIVE; + +import com.cowlark.fluxengine.algorithms.ReadWriteFluxOperation; +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.LogRenderer; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.StringFlag; +import com.cowlark.fluxengine.core.flags.ValueFlag; +import com.google.common.collect.ImmutableList; + +/** + * Read a disk, producing a sector image, modelled after src/fe-read.cc. + */ +public class ReadCommand implements Command +{ + private FlagGroup flags = new FlagGroup(); + private ValueFlag sourceFlag = StringFlag.builder() + .setGroup(flags) + .setName("--source") + .setName("-s") + .setHelpText("flux file to read from") + .build(); + private ValueFlag outputFlag = StringFlag.builder() + .setGroup(flags) + .setName("--output") + .setName("-o") + .setHelpText("destination image to write") + .build(); + private ValueFlag copyFluxToFlag = StringFlag.builder() + .setGroup(flags) + .setName("--copy-flux-to") + .setHelpText("while reading, copy the read flux to this file") + .build(); + + @Override + public String getHelp() + { + return "Reads a disk, producing a sector image."; + } + + private class ReadOperation extends ReadWriteFluxOperation + { + @Override + public void run() + { + readDisk(); + } + } + + @Override + public void run(ImmutableList args) + { + ConfigBuilder builder = new ConfigBuilder().fromFlags(args, flags); + if (sourceFlag.isSet()) + builder.withFluxSource(sourceFlag.get()); + if (outputFlag.isSet()) + builder.withImageWriter(outputFlag.get()); + if (copyFluxToFlag.isSet()) + builder.withCopyFluxTo(copyFluxToFlag.get()); + ConfigProto config = builder.build(); + + if (config.getDecoder().getCopyFluxTo().getType() == FLUXTYPE_DRIVE) + throw new FluxEngineException("you cannot copy flux to a hardware device"); + + LogRenderer renderer = LogRenderer.create(System.out); + new ReadOperation().setConfig(config).create().blockingSubscribe( + renderer::add, e -> { + System.err.println("Failed!"); + e.printStackTrace(); + }); + System.out.println("done."); + } +} diff --git a/java/com/cowlark/fluxengine/cli/RpmCommand.java b/java/com/cowlark/fluxengine/cli/RpmCommand.java new file mode 100644 index 000000000..185201afd --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/RpmCommand.java @@ -0,0 +1,59 @@ +package com.cowlark.fluxengine.cli; + +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_DRIVE; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.StringFlag; +import com.cowlark.fluxengine.core.flags.ValueFlag; +import com.cowlark.fluxengine.usb.UsbDevice; +import com.cowlark.fluxengine.usb.UsbFactory; +import com.google.common.collect.ImmutableList; + +/** + * Measure the disk rotational speed, modelled after src/fe-rpm.cc. + */ +public class RpmCommand implements Command +{ + private FlagGroup flags = new FlagGroup(); + private ValueFlag sourceFlag = StringFlag.builder() + .setGroup(flags) + .setName("--source") + .setName("-s") + .setHelpText("flux file to read from") + .build(); + + @Override + public String getHelp() + { + return "Measures the disk rotational speed."; + } + + @Override + public void run(ImmutableList args) + { + ConfigProto config = + new ConfigBuilder().fromFlags(args, flags).withFluxSource(sourceFlag.get()).build(); + + if (config.getFluxSource().getType() != FLUXTYPE_DRIVE) + throw new FluxEngineException("this only makes sense with a real disk drive"); + + UsbDevice device = UsbFactory.reconnect(config); + + double periodNs = device.getRotationalPeriod(config.getDrive().getHardSectorCount()); + if (periodNs != 0.0) + System.out.printf( + "Rotational period is %.0f ms (%.0f rpm)\n", + periodNs / 1e6, + 60e9 / periodNs); + else + System.out.println(""" + No index pulses detected from the disk. Common causes of this are: + - no drive is connected + - the drive doesn't have an index sensor (e.g. BBC Micro drives) + - the disk has no index holes (e.g. reversed flippy disks) + - (most common) no disk is inserted in the drive!"""); + } +} diff --git a/java/com/cowlark/fluxengine/cli/SeekCommand.java b/java/com/cowlark/fluxengine/cli/SeekCommand.java new file mode 100644 index 000000000..880210674 --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/SeekCommand.java @@ -0,0 +1,53 @@ +package com.cowlark.fluxengine.cli; + +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_DRIVE; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.IntFlag; +import com.cowlark.fluxengine.core.flags.StringFlag; +import com.cowlark.fluxengine.core.flags.ValueFlag; +import com.cowlark.fluxengine.usb.UsbDevice; +import com.cowlark.fluxengine.usb.UsbFactory; +import com.google.common.collect.ImmutableList; + +/** + * Seek to a given track, modelled after src/fe-seek.cc. + */ +public class SeekCommand implements Command +{ + private static FlagGroup flags = new FlagGroup(); + private ValueFlag sourceFlag = StringFlag.builder() + .setGroup(flags) + .setName("--source") + .setName("-s") + .setHelpText("flux file to read from") + .build(); + private static IntFlag track = IntFlag.builder() + .setGroup(flags) + .setName("--cylinder") + .setName("-t") + .setHelpText("track to seek to") + .build(); + + @Override + public String getHelp() + { + return "Moves the disk head."; + } + + @Override + public void run(ImmutableList args) + { + ConfigProto config = + new ConfigBuilder().fromFlags(args, flags).withFluxSource(sourceFlag.get()).build(); + + if (config.getFluxSource().getType() != FLUXTYPE_DRIVE) + throw new FluxEngineException("this only makes sense with a real disk drive"); + + UsbDevice device = UsbFactory.reconnect(config); + device.seek(track.get()); + } +} diff --git a/java/com/cowlark/fluxengine/cli/StubCommand.java b/java/com/cowlark/fluxengine/cli/StubCommand.java new file mode 100644 index 000000000..933e9f760 --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/StubCommand.java @@ -0,0 +1,27 @@ +package com.cowlark.fluxengine.cli; + +import com.google.common.collect.ImmutableList; + +public class StubCommand implements Command +{ + private final String name; + private final String help; + + public StubCommand(String name, String help) + { + this.name = name; + this.help = help; + } + + @Override + public String getHelp() + { + return help; + } + + @Override + public void run(ImmutableList args) + { + System.err.printf("fluxengine: '%s' is not implemented yet.\n", name); + } +} diff --git a/java/com/cowlark/fluxengine/cli/TestBandwidthCommand.java b/java/com/cowlark/fluxengine/cli/TestBandwidthCommand.java new file mode 100644 index 000000000..2f816f21b --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/TestBandwidthCommand.java @@ -0,0 +1,29 @@ +package com.cowlark.fluxengine.cli; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.usb.UsbDevice; +import com.cowlark.fluxengine.usb.UsbFactory; +import com.google.common.collect.ImmutableList; + +/** + * Test USB bulk transfer bandwidth, modelled after src/fe-testbandwidth.cc. + */ +public class TestBandwidthCommand implements Command +{ + @Override + public String getHelp() + { + return "Measures your USB bandwidth."; + } + + @Override + public void run(ImmutableList args) + { + ConfigProto config = new ConfigBuilder().fromFlags(args).build(); + + UsbDevice device = UsbFactory.reconnect(config); + device.testBulkWrite(); + device.testBulkRead(); + } +} diff --git a/java/com/cowlark/fluxengine/cli/TestVoltagesCommand.java b/java/com/cowlark/fluxengine/cli/TestVoltagesCommand.java new file mode 100644 index 000000000..2689587bc --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/TestVoltagesCommand.java @@ -0,0 +1,64 @@ +package com.cowlark.fluxengine.cli; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.usb.UsbDevice; +import com.cowlark.fluxengine.usb.UsbFactory; +import com.cowlark.fluxengine.usb.VoltageMeasurements; +import com.cowlark.fluxengine.usb.Voltages; +import com.google.common.collect.ImmutableList; + +/** + * Measure the FDD bus voltages, modelled after src/fe-testvoltages.cc. + */ +public class TestVoltagesCommand implements Command +{ + private static String displayVoltages(Voltages v) + { + return String.format( + " Logic 1 / 0: %.2fV / %.2fV\n", + v.logic0Mv() / 1000.0, + v.logic1Mv() / 1000.0); + } + + @Override + public String getHelp() + { + return "Measures the FDD bus voltages."; + } + + @Override + public void run(ImmutableList args) + { + ConfigProto config = new ConfigBuilder().fromFlags(args).build(); + + UsbDevice device = UsbFactory.reconnect(config); + VoltageMeasurements voltages = device.measureVoltages(); + + System.out.printf( + """ + Output voltages: + Both drives deselected + %s Drive 0 selected + %s Drive 1 selected + %s Drive 0 running + %s Drive 1 running + %sInput voltages: + Both drives deselected + %s Drive 0 selected + %s Drive 1 selected + %s Drive 0 running + %s Drive 1 running + %s""", + displayVoltages(voltages.outputBothOff), + displayVoltages(voltages.outputDrive0Selected), + displayVoltages(voltages.outputDrive1Selected), + displayVoltages(voltages.outputDrive0Running), + displayVoltages(voltages.outputDrive1Running), + displayVoltages(voltages.inputBothOff), + displayVoltages(voltages.inputDrive0Selected), + displayVoltages(voltages.inputDrive1Selected), + displayVoltages(voltages.inputDrive0Running), + displayVoltages(voltages.inputDrive1Running)); + } +} diff --git a/java/com/cowlark/fluxengine/cli/WriteCommand.java b/java/com/cowlark/fluxengine/cli/WriteCommand.java new file mode 100644 index 000000000..3f8b601bf --- /dev/null +++ b/java/com/cowlark/fluxengine/cli/WriteCommand.java @@ -0,0 +1,76 @@ +package com.cowlark.fluxengine.cli; + +import com.cowlark.fluxengine.algorithms.ReadWriteFluxOperation; +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.LogRenderer; +import com.cowlark.fluxengine.core.flags.ActionFlag; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.StringFlag; +import com.cowlark.fluxengine.core.flags.ValueFlag; +import com.cowlark.fluxengine.data.Image; +import com.google.common.collect.ImmutableList; + +/** + * Write a sector image to a disk, modelled after src/fe-write.cc. + */ +public class WriteCommand implements Command +{ + private FlagGroup flags = new FlagGroup(); + private ValueFlag sourceImageFlag = StringFlag.builder() + .setGroup(flags) + .setName("--input") + .setName("-i") + .setHelpText("source image to read from") + .build(); + private ValueFlag destFluxFlag = StringFlag.builder() + .setGroup(flags) + .setName("--dest") + .setName("-d") + .setHelpText("flux destination to write to") + .build(); + private boolean verify = true; + private ActionFlag noVerifyFlag = ActionFlag.builder() + .setGroup(flags) + .setName("--no-verify") + .setName("-n") + .setHelpText("skip verification of write") + .setVoidCallback(() -> verify = false) + .build(); + + @Override + public String getHelp() + { + return "Writes a sector image to a disk."; + } + + private class WriteOperation extends ReadWriteFluxOperation + { + @Override + public void run() + { + Image image = getImageReader().readImage(); + writeDisk(image); + } + } + + @Override + public void run(ImmutableList args) + { + ConfigProto config = new ConfigBuilder().fromFlags(args, flags) + .withImageReader(sourceImageFlag.get()) + .withFluxSink(destFluxFlag.get()) + .withFluxSource(destFluxFlag.get()) /* for verification */.set( + "verify_writes", + Boolean.toString(verify)) + .build(); + + LogRenderer renderer = LogRenderer.create(System.out); + new WriteOperation().setConfig(config).create().blockingSubscribe( + renderer::add, e -> { + System.err.println("Failed!"); + e.printStackTrace(); + }); + System.out.println("done."); + } +} diff --git a/java/com/cowlark/fluxengine/config/BUILD.bazel b/java/com/cowlark/fluxengine/config/BUILD.bazel new file mode 100644 index 000000000..9f2688dd8 --- /dev/null +++ b/java/com/cowlark/fluxengine/config/BUILD.bazel @@ -0,0 +1,92 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "common_proto", + srcs = ["common.proto"], + strip_import_prefix = "/java/", + deps = ["@com_google_protobuf//:descriptor_proto"], +) + +java_proto_library( + name = "common_java_proto", + deps = [":common_proto"], +) + +proto_library( + name = "layout_proto", + srcs = ["layout.proto"], + strip_import_prefix = "/java/", + deps = [ + ":common_proto", + "//java/com/cowlark/fluxengine/external:fl2_proto", + ], +) + +java_proto_library( + name = "layout_java_proto", + deps = [":layout_proto"], +) + +proto_library( + name = "drive_proto", + srcs = ["drive.proto"], + strip_import_prefix = "/java/", + deps = [ + ":common_proto", + "//java/com/cowlark/fluxengine/external:fl2_proto", + ], +) + +java_proto_library( + name = "drive_java_proto", + deps = [":drive_proto"], +) + +proto_library( + name = "config_proto", + srcs = ["config.proto"], + strip_import_prefix = "/java/", + deps = [ + ":common_proto", + ":drive_proto", + ":layout_proto", + "//java/com/cowlark/fluxengine/decoders:decoders_proto", + "//java/com/cowlark/fluxengine/encoders:encoders_proto", + "//java/com/cowlark/fluxengine/fluxsink:fluxsink_proto", + "//java/com/cowlark/fluxengine/fluxsource:fluxsource_proto", + "//java/com/cowlark/fluxengine/imagereader:imagereader_proto", + "//java/com/cowlark/fluxengine/imagewriter:imagewriter_proto", + "//java/com/cowlark/fluxengine/usb:usb_proto", + "//java/com/cowlark/fluxengine/vfs:vfs_proto", + ], +) + +java_proto_library( + name = "config_java_proto", + deps = [":config_proto"], +) + +java_library( + name = "config", + srcs = glob(["*.java"]), + deps = [ + ":common_java_proto", + ":config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/core/flags", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/fluxsink:fluxsink_java_proto", + "//java/com/cowlark/fluxengine/fluxsource:fluxsource_java_proto", + "//java/com/cowlark/fluxengine/imagewriter:imagewriter_java_proto", + "@com_google_protobuf//java/core", + "@maven//:com_fazecast_jSerialComm", + "@maven//:com_google_guava_guava", + "@maven//:com_jayway_jsonpath_json_path", + "@maven//:javax_usb_usb_api", + "@maven//:org_usb4java_usb4java_javax", + ], +) diff --git a/java/com/cowlark/fluxengine/config/ConfigBuilder.java b/java/com/cowlark/fluxengine/config/ConfigBuilder.java new file mode 100644 index 000000000..58e8e800d --- /dev/null +++ b/java/com/cowlark/fluxengine/config/ConfigBuilder.java @@ -0,0 +1,455 @@ +package com.cowlark.fluxengine.config; + +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_A2R; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_AU; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_CWF; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_DMK; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_DRIVE; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_ERASE; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_FLUX; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_FLX; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_KRYOFLUX; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_SCP; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_TEST_PATTERN; +import static com.cowlark.fluxengine.config.FluxSourceSinkType.FLUXTYPE_VCD; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_D64; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_D88; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_DIM; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_DISKCOPY; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_FDI; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_IMD; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_IMG; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_JV3; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_NFD; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_NSI; +import static com.cowlark.fluxengine.config.ImageReaderWriterType.IMAGETYPE_TD0; + +import com.cowlark.fluxengine.core.Logger; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.cowlark.fluxengine.core.flags.Flags; +import com.cowlark.fluxengine.data.Formats; +import com.cowlark.fluxengine.fluxsink.FluxSinkProto; +import com.cowlark.fluxengine.fluxsource.FluxSourceProto; +import com.google.common.collect.ImmutableList; +import com.google.protobuf.TextFormat; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; + +/** + * The assembled configuration, built from the unmatched command-line + * arguments. + */ +public class ConfigBuilder +{ + /* The groups which have had an option applied, so that applyDefaultOptions + * knows not to apply their defaults. */ + private final Set appliedOptions = new HashSet<>(); + private ConfigProto.Builder proto = Formats.get("_global_options").toBuilder(); + + public ConfigBuilder() + { + } + + private static ImageReaderWriterType imageType(String filename) + { + if (filename.endsWith(".adf") || filename.endsWith(".d81") || filename.endsWith(".dsk") || + filename.endsWith(".img") || filename.endsWith(".st") || + filename.endsWith(".vgi") || filename.endsWith(".xdf")) + return IMAGETYPE_IMG; + else if (filename.endsWith(".d64")) + return IMAGETYPE_D64; + else if (filename.endsWith(".d88")) + return IMAGETYPE_D88; + else if (filename.endsWith(".dim")) + return IMAGETYPE_DIM; + else if (filename.endsWith(".diskcopy")) + return IMAGETYPE_DISKCOPY; + else if (filename.endsWith(".fdi")) + return IMAGETYPE_FDI; + else if (filename.endsWith(".imd")) + return IMAGETYPE_IMD; + else if (filename.endsWith(".jv3")) + return IMAGETYPE_JV3; + else if (filename.endsWith(".nfd")) + return IMAGETYPE_NFD; + else if (filename.endsWith(".nsi")) + return IMAGETYPE_NSI; + else if (filename.endsWith(".td0")) + return IMAGETYPE_TD0; + else + return null; + } + + private static boolean isReadOnlyImage(String filename) + { + return filename.endsWith(".dim") || filename.endsWith(".fdi") || + filename.endsWith(".jv3") || filename.endsWith(".nfd") || filename.endsWith(".td0"); + } + + /* Quotes a string if it contains spaces or quote characters, ported from + * lib/core/utils.cc quote(). */ + private static String quote(String s) + { + boolean spaces = s.contains(" "); + if (!spaces && !s.contains("\\") && !s.contains("'") && !s.contains("\"")) + return s; + + StringBuilder ss = new StringBuilder(); + if (spaces) + ss.append('"'); + + for (int i = 0; i < s.length(); i++) + { + char c = s.charAt(i); + if ((c == '\\') || (c == '"') || (c == '!')) + ss.append('\\'); + ss.append(c); + } + + if (spaces) + ss.append('"'); + + return ss.toString(); + } + + public ConfigBuilder fromFlags(ImmutableList args, FlagGroup... group) + { + ImmutableList allGroups = ImmutableList.builder() + .add(group) + .add(new ConfigFlagGroup(this)) + .build(); + Flags.parse(args, allGroups); + + return this; + } + + public ConfigBuilder loadConfigFile(String name) + { + /* Try to load the config from the built-in formats first. */ + + ConfigProto config = Formats.get(name); + if (config != null) + { + proto.mergeFrom(config); + return this; + } + + String contents; + try + { + contents = Files.readString(Path.of(name)); + } catch (IOException e) + { + throw new ConfigException("Cannot open '" + name + "': " + e.getMessage()); + } + + try + { + TextFormat.merge(contents, proto); + } catch (TextFormat.ParseException e) + { + throw new ConfigException("couldn't load external config proto"); + } + + return this; + } + + public ConfigBuilder mergeConfig(ConfigProto other) + { + proto.mergeFrom(other); + return this; + } + + public ConfigBuilder withFluxSource(String filename) + { + FluxSourceProto.Builder fluxSource = proto.getFluxSourceBuilder(); + if (filename.endsWith(".flux")) + { + fluxSource.setType(FLUXTYPE_FLUX); + fluxSource.getFl2Builder().setFilename(filename); + } else if (filename.endsWith(".scp")) + { + fluxSource.setType(FLUXTYPE_SCP); + fluxSource.getScpBuilder().setFilename(filename); + } else if (filename.endsWith(".a2r")) + { + fluxSource.setType(FLUXTYPE_A2R); + fluxSource.getA2RBuilder().setFilename(filename); + } else if (filename.endsWith(".cwf")) + { + fluxSource.setType(FLUXTYPE_CWF); + fluxSource.getCwfBuilder().setFilename(filename); + } else if (filename.startsWith("dmk:")) + { + fluxSource.setType(FLUXTYPE_DMK); + fluxSource.getDmkBuilder().setDirectory(filename.substring(4)); + } else if (filename.equals("erase:")) + { + fluxSource.setType(FLUXTYPE_ERASE); + } else if (filename.startsWith("kryoflux:")) + { + fluxSource.setType(FLUXTYPE_KRYOFLUX); + fluxSource.getKryofluxBuilder().setDirectory(filename.substring(9)); + } else if (filename.startsWith("testpattern:")) + { + fluxSource.setType(FLUXTYPE_TEST_PATTERN); + } else if (filename.startsWith("drive:")) + { + fluxSource.setType(FLUXTYPE_DRIVE); + proto.getDriveBuilder().setDrive(Integer.parseInt(filename.substring(6))); + } else if (filename.startsWith("flx:")) + { + fluxSource.setType(FLUXTYPE_FLX); + fluxSource.getFlxBuilder().setDirectory(filename.substring(4)); + } else + throw new ConfigException("unrecognised flux filename '" + filename + "'"); + return this; + } + + public ConfigBuilder withCopyFluxTo(String filename) + { + setFluxSink(proto.getDecoderBuilder().getCopyFluxToBuilder(), filename); + return this; + } + + public ConfigBuilder withFluxSink(String filename) + { + setFluxSink(proto.getFluxSinkBuilder(), filename); + return this; + } + + private void setFluxSink(FluxSinkProto.Builder fluxSink, String filename) + { + if (filename.endsWith(".flux")) + { + fluxSink.setType(FLUXTYPE_FLUX); + fluxSink.getFl2Builder().setFilename(filename); + } else if (filename.endsWith(".scp")) + { + fluxSink.setType(FLUXTYPE_SCP); + fluxSink.getScpBuilder().setFilename(filename); + } else if (filename.endsWith(".a2r")) + { + fluxSink.setType(FLUXTYPE_A2R); + fluxSink.getA2RBuilder().setFilename(filename); + } else if (filename.startsWith("drive:")) + { + fluxSink.setType(FLUXTYPE_DRIVE); + proto.getDriveBuilder().setDrive(Integer.parseInt(filename.substring(6))); + } else if (filename.startsWith("vcd:")) + { + fluxSink.setType(FLUXTYPE_VCD); + fluxSink.getVcdBuilder().setDirectory(filename.substring(4)); + } else if (filename.startsWith("au:")) + { + fluxSink.setType(FLUXTYPE_AU); + fluxSink.getAuBuilder().setDirectory(filename.substring(3)); + } else + throw new ConfigException("unrecognised flux filename '" + filename + "'"); + } + + public ConfigBuilder withImageWriter(String filename) + { + ImageReaderWriterType type = imageType(filename); + if (type == null || isReadOnlyImage(filename)) + throw new ConfigException("unrecognised image filename '" + filename + "'"); + proto.getImageWriterBuilder().setType(type).setFilename(filename); + return this; + } + + public ConfigBuilder withImageReader(String filename) + { + ImageReaderWriterType type = imageType(filename); + if (type == null) + throw new ConfigException("unrecognised image filename '" + filename + "'"); + proto.getImageReaderBuilder().setType(type).setFilename(filename); + return this; + } + + public ConfigBuilder showCurrentConfig() + { + return this; + } + + public ConfigBuilder set(String key, String value) + { + ProtoPath.set(proto, key, value); + return this; + } + + /* Returns the value of the config key at the given path, or throws a + * ProtoPathNotFoundException if it isn't a real config field, ported from + * Config::get. */ + public String get(String key) + { + return ProtoPath.get(proto, key); + } + + /* Looks up an option by name, ported from Config::findOption. The group + * value parameter of the C++ version is not needed here, so it takes a + * key only. */ + public OptionInfo findOption(String name) + { + /* First look for any individual options. */ + + for (OptionProto option : proto.getOptionList()) + { + if (name.equals(option.getName())) + return new OptionInfo(null, option, false); + } + + /* Now search for individual options in unnamed groups. */ + + for (OptionGroupProto optionGroup : proto.getOptionGroupList()) + { + if (optionGroup.getName().isEmpty()) + { + for (OptionProto option : optionGroup.getOptionList()) + { + if (name.equals(option.getName())) + return new OptionInfo(optionGroup, option, false); + } + } + } + + /* Now look for named groups. A group itself is not an option; it is + * selected by supplying a value, so usesValue is true. */ + + for (OptionGroupProto optionGroup : proto.getOptionGroupList()) + { + if (name.equals(optionGroup.getName())) + return new OptionInfo(optionGroup, null, true); + } + + throw new ConfigException(String.format("option %s not found", name)); + } + + public void applyOption(OptionInfo option, String value) + { + OptionProto optionProto = option.option(); + if ((optionProto == null) && option.usesValue()) + { + /* A group with no option set means we need to select the option by + * value. */ + + for (OptionProto candidate : option.group().getOptionList()) + { + if (value.equals(candidate.getName())) + { + optionProto = candidate; + break; + } + } + + if (optionProto == null) + throw new InapplicableOptionException( + "value %s is not valid for option %s; valid values are: %s", + value, + option.group().getName(), + option.group() + .getOptionList() + .stream() + .map(OptionProto::getName) + .collect(java.util.stream.Collectors.joining(", "))); + } + + checkOptionValid(optionProto); + if (option.group() != null) + appliedOptions.add(option.group()); + Logger.log(new OptionLogMessage("user option", optionProto)); + proto.mergeFrom(optionProto.getConfig()); + } + + /* Applies the default option for every group which doesn't have one set, + * ported from Config::applyDefaultOptions. */ + private void applyDefaultOptions() + { + for (OptionGroupProto group : proto.getOptionGroupList()) + { + if (!appliedOptions.contains(group)) + { + for (OptionProto optionProto : group.getOptionList()) + { + if (optionProto.getSetByDefault()) + { + checkOptionValid(optionProto); + appliedOptions.add(group); + + /* Default options should never override anything the user set. */ + Logger.log(new OptionLogMessage("default option", optionProto)); + proto = optionProto.getConfig().toBuilder().mergeFrom(proto.build()); + } + } + } + } + } + + private void checkOptionValid(OptionProto optionProto) + { + for (OptionPrerequisiteProto req : optionProto.getPrerequisiteList()) + { + boolean matched = false; + try + { + String value = ProtoPath.get(proto, req.getKey()); + for (String requiredValue : req.getValueList()) + matched |= requiredValue.equals(value); + } catch (ProtoPathNotFoundException e) + { + /* This field isn't available, therefore it cannot match. */ + } + + if (!matched) + { + StringBuilder ss = new StringBuilder(); + ss.append('['); + boolean first = true; + for (String requiredValue : req.getValueList()) + { + if (!first) + ss.append(", "); + ss.append(quote(requiredValue)); + first = false; + } + ss.append(']'); + + throw new InapplicableOptionException( + "option '%s' is inapplicable to this configuration " + + "because %s=%s could not be met", + optionProto.getName(), + req.getKey(), + ss.toString()); + } + } + } + + public ConfigProto build() + { + applyDefaultOptions(); + validate(); + return proto.build(); + } + + private void validate() + { + if ((proto.getFluxSource().getType() == FLUXTYPE_DRIVE) || + (proto.getFluxSink().getType() == FLUXTYPE_DRIVE)) + validateUsb(); + } + + private void validateUsb() + { + if (!proto.getUsb().hasSerial()) + proto.getUsbBuilder().setSerial(UsbFinder.selectDevice(proto).serial); + } + + /* The result of looking up an option, ported from + * lib/config/config.h Config::OptionInfo. */ + public record OptionInfo(OptionGroupProto group, OptionProto option, boolean usesValue) + { + } + +} diff --git a/java/com/cowlark/fluxengine/config/ConfigException.java b/java/com/cowlark/fluxengine/config/ConfigException.java new file mode 100644 index 000000000..70f9afe75 --- /dev/null +++ b/java/com/cowlark/fluxengine/config/ConfigException.java @@ -0,0 +1,19 @@ +package com.cowlark.fluxengine.config; + +import com.cowlark.fluxengine.core.FluxEngineException; + +/** + * An error relating to loading or processing the configuration. + */ +public class ConfigException extends FluxEngineException +{ + public ConfigException(String message) + { + super(message); + } + + public ConfigException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/java/com/cowlark/fluxengine/config/ConfigFlagGroup.java b/java/com/cowlark/fluxengine/config/ConfigFlagGroup.java new file mode 100644 index 000000000..683ef43e0 --- /dev/null +++ b/java/com/cowlark/fluxengine/config/ConfigFlagGroup.java @@ -0,0 +1,75 @@ +package com.cowlark.fluxengine.config; + +import com.cowlark.fluxengine.core.flags.ActionFlag; +import com.cowlark.fluxengine.core.flags.Flag; +import com.cowlark.fluxengine.core.flags.FlagGroup; + +public class ConfigFlagGroup extends FlagGroup +{ + private final ConfigBuilder builder; + + public ConfigFlagGroup(ConfigBuilder builder) + { + this.builder = builder; + + ActionFlag.builder() + .setGroup(this) + .setName("-c") + .setName("--config") + .setHelpText("Reads an internal or external configuration file.") + .setValueCallback(builder::loadConfigFile) + .build(); + ActionFlag.builder() + .setGroup(this) + .setName("--show-config") + .setHelpText("Shows the currently set configuration and halts.") + .setVoidCallback(builder::showCurrentConfig) + .build(); + } + + @Override + public Flag findFlag(String key) + { + if (key.startsWith("--")) + { + String path = key.substring(2); + try + { + /* This is a config key. */ + builder.get(path); + return ActionFlag.builder() + .setGroup(this) + .setValueCallback(value -> builder.set(path, value)) + .build(); + } catch (ProtoPathNotFoundException e) + { + /* Not a config key. */ + } + } + + /* Look for a registered flag (e.g. --config, --show-config). */ + Flag flag = super.findFlag(key); + if (flag != null) + return flag; + + if (key.startsWith("--")) + { + /* Not a config key or registered flag: this is an option name; + * look it up (throws if unknown). */ + String path = key.substring(2); + ConfigBuilder.OptionInfo option = builder.findOption(path); + if (option.usesValue()) + return ActionFlag.builder() + .setGroup(this) + .setValueCallback(arg -> builder.applyOption(option, arg)) + .build(); + else + return ActionFlag.builder() + .setGroup(this) + .setVoidCallback(() -> builder.applyOption(option, null)) + .build(); + } + + return null; + } +} diff --git a/java/com/cowlark/fluxengine/config/ConfigTools.java b/java/com/cowlark/fluxengine/config/ConfigTools.java new file mode 100644 index 000000000..8e2f45443 --- /dev/null +++ b/java/com/cowlark/fluxengine/config/ConfigTools.java @@ -0,0 +1,8 @@ +package com.cowlark.fluxengine.config; + +public class ConfigTools +{ + private ConfigTools() + { + } +} diff --git a/java/com/cowlark/fluxengine/config/InapplicableOptionException.java b/java/com/cowlark/fluxengine/config/InapplicableOptionException.java new file mode 100644 index 000000000..f15e5ea4c --- /dev/null +++ b/java/com/cowlark/fluxengine/config/InapplicableOptionException.java @@ -0,0 +1,13 @@ +package com.cowlark.fluxengine.config; + +/** + * Thrown when an option cannot be applied to the current configuration, + * ported from lib/config/config.h. + */ +public class InapplicableOptionException extends ConfigException +{ + public InapplicableOptionException(String message, Object... args) + { + super(String.format(message, args)); + } +} diff --git a/java/com/cowlark/fluxengine/config/OptionLogMessage.java b/java/com/cowlark/fluxengine/config/OptionLogMessage.java new file mode 100644 index 000000000..67c40a10f --- /dev/null +++ b/java/com/cowlark/fluxengine/config/OptionLogMessage.java @@ -0,0 +1,17 @@ +package com.cowlark.fluxengine.config; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; +import com.google.common.base.Strings; + +public record OptionLogMessage(String message, OptionProto option) implements LogMessage +{ + @Override + public void render(LogRenderer r) + { + r.newline().add("OPTION:"); + if (!Strings.isNullOrEmpty(message)) + r.add(message + ":"); + r.add(option.getComment()).newline(); + } +} diff --git a/java/com/cowlark/fluxengine/config/ProtoPath.java b/java/com/cowlark/fluxengine/config/ProtoPath.java new file mode 100644 index 000000000..a15baac39 --- /dev/null +++ b/java/com/cowlark/fluxengine/config/ProtoPath.java @@ -0,0 +1,334 @@ +package com.cowlark.fluxengine.config; + +import com.google.protobuf.Descriptors.EnumValueDescriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Message; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Resolves dotted paths (e.g. "drive.drive_type" or "option[0].comment") + * against a protobuf builder and sets the leaf value, ported from + * lib/config/proto.cc's makeProtoPath/ProtoField. + */ +public class ProtoPath +{ + private static final Pattern PATH_COMPONENT = Pattern.compile("^(\\w+)(?:\\[(\\d+)\\])?$"); + + private ProtoPath() + { + } + + public static void set(Message.Builder builder, String path, String value) + { + List components = parsePath(path); + setRecursive(builder, components, 0, value, path); + } + + /* Resolves a dotted path against a message and returns the leaf value as + * a string, ported from lib/config/proto.cc's findProtoPath/get. Throws a + * ProtoPathNotFoundException if the path doesn't correspond to a real + * config field. */ + public static String get(Message.Builder builder, String path) + { + List components = parsePath(path); + return getRecursive(builder, components, 0, path); + } + + private static String getRecursive(Message.Builder builder, + List path, + int pos, + String originalPath) + { + PathComponent component = path.get(pos); + FieldDescriptor field = findField(builder, component, originalPath); + + if (pos == path.size() - 1) + { + return getLeaf(builder, component, field); + } + + if (field.getJavaType() != FieldDescriptor.JavaType.MESSAGE) + throw new ProtoPathNotFoundException( + "config field '" + component.name() + "' in '" + originalPath + + "' is not a message"); + + Message.Builder elementBuilder; + if (field.isRepeated()) + { + int index = requireIndex(component, field); + if (builder.getRepeatedFieldCount(field) > index) + elementBuilder = ((Message) builder.getRepeatedField(field, index)).toBuilder(); + else + elementBuilder = builder.newBuilderForField(field); + } else + { + if (component.index() >= 0) + throw new ProtoPathNotFoundException("config field '" + component.name() + + "' is not repeated but an index is provided"); + if (builder.hasField(field)) + elementBuilder = ((Message) builder.getField(field)).toBuilder(); + else + elementBuilder = builder.newBuilderForField(field); + } + return getRecursive(elementBuilder, path, pos + 1, originalPath); + } + + private static String getLeaf(Message.Builder builder, + PathComponent component, + FieldDescriptor field) + { + if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE) + throw new ConfigException("config field '" + component.name() + + "' is a message and can't be directly fetched"); + + Object value; + if (field.isRepeated()) + { + int index = requireIndex(component, field); + if (builder.getRepeatedFieldCount(field) <= index) + throw new ProtoPathNotFoundException( + "could not find config field '" + field.getName() + "'"); + value = builder.getRepeatedField(field, index); + } else + { + if (component.index() >= 0) + throw new ProtoPathNotFoundException("config field '" + component.name() + + "' is not repeated but an index is provided"); + value = builder.getField(field); + } + return formatValue(field, value); + } + + private static String formatValue(FieldDescriptor field, Object value) + { + switch (field.getType()) + { + case FLOAT: + case DOUBLE: + return String.valueOf(value); + case BOOL: + return String.valueOf(value); + case ENUM: + return ((EnumValueDescriptor) value).getName(); + default: + return String.valueOf(value); + } + } + + private static List parsePath(String path) + { + List components = new ArrayList<>(); + for (String token : path.split("\\.", -1)) + { + Matcher matcher = PATH_COMPONENT.matcher(token); + if (!matcher.matches()) + throw new ProtoPathNotFoundException("invalid config path '" + path + "'"); + String index = matcher.group(2); + components.add(new PathComponent( + matcher.group(1), + index == null ? -1 : Integer.parseInt(index))); + } + return components; + } + + private static void setRecursive(Message.Builder builder, + List path, + int pos, + String value, + String originalPath) + { + PathComponent component = path.get(pos); + FieldDescriptor field = findField(builder, component, originalPath); + + if (pos == path.size() - 1) + { + setLeaf(builder, component, field, value); + return; + } + + if (field.getJavaType() != FieldDescriptor.JavaType.MESSAGE) + throw new ProtoPathNotFoundException( + "config field '" + component.name() + "' in '" + originalPath + + "' is not a message"); + + if (field.isRepeated()) + { + int index = requireIndex(component, field); + extendTo(builder, field, index); + Message element = (Message) builder.getRepeatedField(field, index); + Message.Builder elementBuilder = element.toBuilder(); + setRecursive(elementBuilder, path, pos + 1, value, originalPath); + builder.setRepeatedField(field, index, elementBuilder.build()); + } else + { + if (component.index() >= 0) + throw new ProtoPathNotFoundException("config field '" + component.name() + + "' is not repeated but an index is provided"); + Message.Builder elementBuilder; + if (builder.hasField(field)) + elementBuilder = ((Message) builder.getField(field)).toBuilder(); + else + elementBuilder = builder.newBuilderForField(field); + setRecursive(elementBuilder, path, pos + 1, value, originalPath); + builder.setField(field, elementBuilder.build()); + } + } + + private static void setLeaf(Message.Builder builder, + PathComponent component, + FieldDescriptor field, + String value) + { + if (field.getJavaType() == FieldDescriptor.JavaType.MESSAGE) + throw new ConfigException("config field '" + component.name() + + "' is a message and can't be directly set"); + + Object coerced = coerce(field, value); + + if (field.isRepeated()) + { + int index = requireIndex(component, field); + extendScalarTo(builder, field, index); + builder.setRepeatedField(field, index, coerced); + } else + { + if (component.index() >= 0) + throw new ProtoPathNotFoundException("config field '" + component.name() + + "' is not repeated but an index is provided"); + builder.setField(field, coerced); + } + } + + private static FieldDescriptor findField(Message.Builder builder, + PathComponent component, + String path) + { + FieldDescriptor field = builder.getDescriptorForType().findFieldByName(component.name()); + if (field == null) + throw new ProtoPathNotFoundException( + "no such config field '" + component.name() + "' in '" + path + "'"); + return field; + } + + private static int requireIndex(PathComponent component, FieldDescriptor field) + { + if (component.index() < 0) + throw new ProtoPathNotFoundException( + "config field '" + component.name() + "' is repeated and must be indexed"); + return component.index(); + } + + private static void extendTo(Message.Builder builder, FieldDescriptor field, int index) + { + while (builder.getRepeatedFieldCount(field) <= index) + builder.addRepeatedField(field, builder.newBuilderForField(field).build()); + } + + private static void extendScalarTo(Message.Builder builder, FieldDescriptor field, int index) + { + Object defaultValue = scalarDefault(field); + while (builder.getRepeatedFieldCount(field) <= index) + builder.addRepeatedField(field, defaultValue); + } + + private static Object scalarDefault(FieldDescriptor field) + { + switch (field.getType()) + { + case FLOAT: + return 0.0f; + case DOUBLE: + return 0.0; + case INT32: + case SINT32: + case SFIXED32: + case UINT32: + case FIXED32: + return 0; + case INT64: + case SINT64: + case SFIXED64: + case UINT64: + case FIXED64: + return 0L; + case STRING: + return ""; + case BOOL: + return false; + case ENUM: + return field.getEnumType().getValues().get(0); + default: + throw new ConfigException("can't set this config value type"); + } + } + + private static Object coerce(FieldDescriptor field, String value) + { + try + { + switch (field.getType()) + { + case FLOAT: + return Float.parseFloat(value); + case DOUBLE: + return Double.parseDouble(value); + case INT32: + case SINT32: + case SFIXED32: + return Integer.parseInt(value); + case UINT32: + case FIXED32: + return Integer.parseUnsignedInt(value); + case INT64: + case SINT64: + case SFIXED64: + return Long.parseLong(value); + case UINT64: + case FIXED64: + return Long.parseUnsignedLong(value); + case STRING: + return value; + case BOOL: + return parseBoolean(value); + case ENUM: + EnumValueDescriptor enumValue = field.getEnumType().findValueByName(value); + if (enumValue == null) + throw new ConfigException("unrecognised enum value '" + value + "'"); + return enumValue; + default: + throw new ConfigException("can't set this config value type"); + } + } catch (NumberFormatException e) + { + throw new ConfigException("invalid number '" + value + "'"); + } + } + + private static boolean parseBoolean(String value) + { + switch (value) + { + case "false": + case "f": + case "no": + case "n": + case "0": + return false; + case "true": + case "t": + case "yes": + case "y": + case "1": + return true; + default: + throw new ConfigException("invalid boolean value"); + } + } + + private record PathComponent(String name, int index) + { + } +} diff --git a/java/com/cowlark/fluxengine/config/ProtoPathNotFoundException.java b/java/com/cowlark/fluxengine/config/ProtoPathNotFoundException.java new file mode 100644 index 000000000..1a1a7c5e3 --- /dev/null +++ b/java/com/cowlark/fluxengine/config/ProtoPathNotFoundException.java @@ -0,0 +1,13 @@ +package com.cowlark.fluxengine.config; + +/** + * Thrown when a config path cannot be resolved against a protobuf, ported + * from lib/config/proto.h. + */ +public class ProtoPathNotFoundException extends ConfigException +{ + public ProtoPathNotFoundException(String message) + { + super(message); + } +} diff --git a/java/com/cowlark/fluxengine/config/UsbFinder.java b/java/com/cowlark/fluxengine/config/UsbFinder.java new file mode 100644 index 000000000..3bab72340 --- /dev/null +++ b/java/com/cowlark/fluxengine/config/UsbFinder.java @@ -0,0 +1,152 @@ +package com.cowlark.fluxengine.config; + +import com.fazecast.jSerialComm.SerialPort; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import org.usb4java.javax.Services; +import javax.usb.UsbDeviceDescriptor; +import javax.usb.UsbException; +import javax.usb.UsbHub; +import javax.usb.UsbServices; +import java.util.Set; + +public class UsbFinder +{ + private static final int GREASEWEAZLE_ID = 0x12094d69; + private static final int FLUXENGINE_ID = 0x12096e00; + private static final int APPLESAUCE_ID = 0x16c00483; + private static final Set VALID_DEVICES = + Set.of(GREASEWEAZLE_ID, FLUXENGINE_ID, APPLESAUCE_ID); + + private static String getSerialNumber(javax.usb.UsbDevice device) + { + try + { + return device.getSerialNumberString(); + } catch (UsbException | java.io.UnsupportedEncodingException e) + { + return "n/a"; + } + } + + public static ImmutableList findUsbDevices() + { + ImmutableList.Builder candidates = ImmutableList.builder(); + try + { + UsbServices services = new Services(); + UsbHub rootHub = services.getRootUsbHub(); + walkHub(rootHub, candidates); + } catch (UsbException e) + { + System.err.println("USB error: " + e.getMessage()); + } + return candidates.build(); + } + + /* Selects a device to use, based on the configuration, ported from + * lib/usb/usb.cc. */ + public static CandidateDevice selectDevice(ConfigProtoOrBuilder config) + { + ImmutableList candidates = findUsbDevices(); + if (candidates.isEmpty()) + throw new ConfigException("no devices found (is one plugged in? Do you have the " + + "appropriate permissions?"); + + String wantedSerial = config.getUsb().getSerial(); + if (!Strings.isNullOrEmpty(wantedSerial)) + { + for (CandidateDevice candidate : candidates) + { + if (candidate.serial.equals(wantedSerial)) + return candidate; + } + throw new ConfigException("serial number not found"); + } + + if (candidates.size() == 1) + return Iterables.getOnlyElement(candidates); + + throw new ConfigException( + "more than one device detected; you'll need to explicitly specify the serial " + + "number of the device you want"); + } + + private static void walkHub(UsbHub hub, ImmutableList.Builder candidates) + { + for (Object o : hub.getAttachedUsbDevices()) + { + javax.usb.UsbDevice usbDevice = (javax.usb.UsbDevice) o; + if (usbDevice.isUsbHub()) + walkHub((UsbHub) usbDevice, candidates); + + UsbDeviceDescriptor descriptor = usbDevice.getUsbDeviceDescriptor(); + int id = ((descriptor.idVendor() & 0xffff) << 16) | (descriptor.idProduct() & 0xffff); + if (!VALID_DEVICES.contains(id)) + continue; + + CandidateDevice candidate = new CandidateDevice(); + candidate.device = usbDevice; + candidate.id = id; + candidate.serial = getSerialNumber(usbDevice); + + if (id == GREASEWEAZLE_ID) + candidate.type = DeviceType.GREASEWEAZLE; + else if (id == APPLESAUCE_ID) + candidate.type = DeviceType.APPLESAUCE; + else + candidate.type = DeviceType.FLUXENGINE; + + if (id == GREASEWEAZLE_ID || id == APPLESAUCE_ID) + candidate.serialPort = findSerialPort(id, candidate.serial); + + candidates.add(candidate); + } + } + + private static String findSerialPort(int id, String serial) + { + int vendorId = id >>> 16; + int productId = id & 0xffff; + for (SerialPort port : SerialPort.getCommPorts()) + { + if (port.getVendorID() == vendorId && port.getProductID() == productId) + { + String portSerial = port.getSerialNumber(); + if (serial == null || serial.isEmpty() || portSerial == null || + serial.equals(portSerial)) + { + return port.getSystemPortName(); + } + } + } + return null; + } + + public enum DeviceType + { + FLUXENGINE("FluxEngine"), GREASEWEAZLE("Greaseweazle"), APPLESAUCE("Applesauce"); + + private final String deviceName; + + DeviceType(String deviceName) + { + this.deviceName = deviceName; + } + + public String getDeviceName() + { + return deviceName; + } + } + + public static final class CandidateDevice + { + public DeviceType type; + public javax.usb.UsbDevice device; + public int id; + public String serial; + public String serialPort; + } +} diff --git a/java/com/cowlark/fluxengine/config/common.proto b/java/com/cowlark/fluxengine/config/common.proto new file mode 100644 index 000000000..2fc059a13 --- /dev/null +++ b/java/com/cowlark/fluxengine/config/common.proto @@ -0,0 +1,56 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.config"; +option java_multiple_files = true; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions +{ + optional string help = 50000; +} + +extend google.protobuf.MessageOptions +{ + optional bool recurse = 50001 [default = true]; +} + +enum IndexMode { + INDEXMODE_DRIVE = 0; + INDEXMODE_300 = 1; + INDEXMODE_360 = 2; +} + +enum FluxSourceSinkType { + FLUXTYPE_NOT_SET = 0; + FLUXTYPE_A2R = 1; + FLUXTYPE_AU = 2; + FLUXTYPE_CWF = 3; + FLUXTYPE_DRIVE = 4; + FLUXTYPE_ERASE = 5; + FLUXTYPE_FLUX = 6; + FLUXTYPE_FLX = 7; + FLUXTYPE_KRYOFLUX = 8; + FLUXTYPE_SCP = 9; + FLUXTYPE_TEST_PATTERN = 10; + FLUXTYPE_VCD = 11; + FLUXTYPE_DMK = 12; +} + +enum ImageReaderWriterType +{ + IMAGETYPE_NOT_SET = 0; + IMAGETYPE_D64 = 1; + IMAGETYPE_D88 = 2; + IMAGETYPE_DIM = 3; + IMAGETYPE_DISKCOPY = 4; + IMAGETYPE_FDI = 5; + IMAGETYPE_IMD = 6; + IMAGETYPE_IMG = 7; + IMAGETYPE_JV3 = 8; + IMAGETYPE_LDBS = 9; + IMAGETYPE_NFD = 10; + IMAGETYPE_NSI = 11; + IMAGETYPE_RAW = 12; + IMAGETYPE_TD0 = 13; +} diff --git a/java/com/cowlark/fluxengine/config/config.proto b/java/com/cowlark/fluxengine/config/config.proto new file mode 100644 index 000000000..d2e64b17d --- /dev/null +++ b/java/com/cowlark/fluxengine/config/config.proto @@ -0,0 +1,94 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.config"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/decoders/decoders.proto"; +import "com/cowlark/fluxengine/encoders/encoders.proto"; +import "com/cowlark/fluxengine/imagereader/imagereader.proto"; +import "com/cowlark/fluxengine/imagewriter/imagewriter.proto"; +import "com/cowlark/fluxengine/fluxsource/fluxsource.proto"; +import "com/cowlark/fluxengine/fluxsink/fluxsink.proto"; +import "com/cowlark/fluxengine/usb/usb.proto"; +import "com/cowlark/fluxengine/vfs/vfs.proto"; +import "com/cowlark/fluxengine/config/drive.proto"; +import "com/cowlark/fluxengine/config/common.proto"; +import "com/cowlark/fluxengine/config/layout.proto"; + +enum SupportStatus +{ + UNSUPPORTED = 0; DINOSAUR = 1; UNICORN = 2; +} + +// NEXT_TAG: 28 +message ConfigProto +{ + option(recurse) = false; + + optional string shortname = 1; + optional string comment = 2; + optional bool is_extension = 3; + repeated string documentation = 4; + optional SupportStatus read_support_status = 5 [default = UNSUPPORTED]; + optional SupportStatus write_support_status = 6 [default = UNSUPPORTED]; + + optional LayoutProto layout = 7; + + optional ImageReaderProto image_reader = 8; + optional ImageWriterProto image_writer = 9; + optional FluxSourceProto flux_source = 10; + optional FluxSinkProto flux_sink = 11; + optional DriveProto drive = 12; + + optional EncoderProto encoder = 13; + optional DecoderProto decoder = 14; + optional UsbProto usb = 15; + + optional string tracks = 16; + optional bool verify_writes = 27 [(help) = "verify writes where possible", default = true]; + + optional FilesystemProto filesystem = 18; + + repeated OptionProto option = 19; + repeated OptionGroupProto option_group = 20; +} + +message OptionPrerequisiteProto +{ + optional string key = 1 [(help) = "path to config value"]; + repeated string value = 2 [(help) = "list of required values"]; +} + +enum OptionApplicabilityHint +{ + FORMAT = 0; + ANY_SOURCESINK = 1; + HARDWARE_SOURCESINK = 2; + MANUAL_SOURCESINK = 3; + FLUXFILE_SOURCESINK = 4; +} + +// NEXT_TAG: 9 +message OptionProto +{ + optional string name = 1 [(help) = "option name"]; + optional string comment = 2 [(help) = "help text for option"]; + optional string message = + 3 [(help) = "message to display when option is in use"]; + optional bool set_by_default = + 6 [(help) = "this option is applied by default", default = false]; + repeated OptionPrerequisiteProto prerequisite = + 7 [(help) = "prerequisites for this option"]; + + optional ConfigProto config = 4 [(help) = "option data"]; + repeated OptionApplicabilityHint applicability = 8; +} + +// NEXT_TAG: 5 +message OptionGroupProto +{ + optional string comment = 1 [(help) = "help text for option group"]; + optional string name = 2 [(help) = "option group name"]; + repeated OptionProto option = 3; + repeated OptionApplicabilityHint applicability = 4; +} diff --git a/java/com/cowlark/fluxengine/config/drive.proto b/java/com/cowlark/fluxengine/config/drive.proto new file mode 100644 index 000000000..89e2f6039 --- /dev/null +++ b/java/com/cowlark/fluxengine/config/drive.proto @@ -0,0 +1,52 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.config"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; +import "com/cowlark/fluxengine/external/fl2.proto"; + +// Next: 14 +message DriveProto +{ + optional int32 drive = 1 + [default = 0, (help) = "which drive to write to (0 or 1)"]; + optional IndexMode index_mode = 2 + [default = INDEXMODE_DRIVE, (help) = "index pulse source"]; + optional int32 hard_sector_count = 3 + [default = 0, (help) = "number of hard sectors on disk"]; + optional double hard_sector_threshold_ns = 4 + [default = 0, (help) = "index pulses longer than this interval are " + "considered sector markers; shorter indicates an true index marker"]; + optional bool high_density = 5 + [default = false, (help) = "set if this is a high density disk"]; + optional bool sync_with_index = 6 + [default = false, (help) = "start reading at index mark"]; + optional double revolutions = 7 + [default = 2.5, (help) = "number of revolutions to read"]; + + optional string tracks = 8 + [default = "c0-80h0-1", (help) = "Tracks supported by drive"]; + optional int32 head_bias = 9 [ + default = 0, + (help) = "Bias to apply to the head position (in tracks)" + ]; + optional int32 group_offset = 10 [ + default = 0, + (help) = "When writing groups, erase all tracks except this one in each group" + ]; + optional DriveType drive_type = 11 [default = DRIVETYPE_UNKNOWN, (help) = "Type of drive"]; + optional double rotational_period_ms = 12 + [default = 0, (help) = "Rotational period of the drive in milliseconds (0 to autodetect)"]; + + enum ErrorBehaviour { + NOTHING = 0; + JIGGLE = 1; + RECALIBRATE = 2; + } + + optional ErrorBehaviour error_behaviour = 13 + [default = JIGGLE, (help) = "what to do when an error occurs during reads"]; +} + +// vim: ts=4 sw=4 et diff --git a/java/com/cowlark/fluxengine/config/layout.proto b/java/com/cowlark/fluxengine/config/layout.proto new file mode 100644 index 000000000..2ee9ce9b5 --- /dev/null +++ b/java/com/cowlark/fluxengine/config/layout.proto @@ -0,0 +1,69 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.config"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; +import "com/cowlark/fluxengine/external/fl2.proto"; + +message SectorListProto +{ + /* either */ + repeated int32 sector = 1 [(help) = "sector ID"]; + + /* or */ + optional int32 start_sector = 2 + [(help) = "first sector of a continuous run"]; + optional int32 count = 3 + [(help) = "number of sectors in a continuous run"]; + optional int32 skew = 4 + [default = 1, (help) = "apply this skew between sectors"]; +} + +message LayoutProto +{ + enum Order + { + UNDEFINED = 0; + CHS = 1; // sort by cylinder, then head, then sector -- libdsk 'alt' + HCS = 2; // sort by head, then cylinder, then sector -- libdsk 'outout' + HCS_RH1 = 3; // as HCS, except the cylinder count for head 1 is reversed -- libdsk 'outback' + } + + message LayoutdataProto + { + optional int32 track = 1 [ + (help) = + "if present, this format only applies to this logical track" + ]; + optional int32 up_to_track = 5 + [(help) = "if present, forms a range with track"]; + optional int32 side = 2 [ + (help) = + "if present, this format only applies to this logical side" + ]; + + optional int32 sector_size = 3 + [default = 512, (help) = "number of bytes per sector"]; + + optional SectorListProto physical = 4 + [(help) = "physical order of sectors on disk"]; + optional SectorListProto filesystem = 6 + [(help) = "logical order of sectors in filesystem"]; + } + + repeated LayoutdataProto layoutdata = 1 + [(help) = "per-track layout information (repeatable)"]; + optional int32 tracks = 2 + [default = 0, (help) = "number of tracks in image"]; + optional int32 sides = 3 + [default = 0, (help) = "number of sides in image"]; + optional Order filesystem_track_order = 4 + [default = CHS, (help) = "the order of sectors in the filesystem"]; + optional Order image_track_order = 5 + [default = CHS, (help) = "the order of sectors in disk images"]; + optional bool swap_sides = 6 + [default = false, (help) = "the sides are inverted on this disk"]; + optional FormatType format_type = 7 + [default = FORMATTYPE_UNKNOWN, (help) = "Format type of image"]; +} diff --git a/java/com/cowlark/fluxengine/core/BUILD.bazel b/java/com/cowlark/fluxengine/core/BUILD.bazel new file mode 100644 index 000000000..61f95064a --- /dev/null +++ b/java/com/cowlark/fluxengine/core/BUILD.bazel @@ -0,0 +1,11 @@ +load("@rules_java//java:defs.bzl", "java_library") + +package(default_visibility = ["//visibility:public"]) + +java_library( + name = "core", + srcs = glob(["*.java"]), + deps = [ + "@maven//:com_google_guava_guava", + ], +) diff --git a/java/com/cowlark/fluxengine/core/BitReader.java b/java/com/cowlark/fluxengine/core/BitReader.java new file mode 100644 index 000000000..fe3bae508 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/BitReader.java @@ -0,0 +1,58 @@ +package com.cowlark.fluxengine.core; + +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * A cursor which reads bits from a ByteReader. + */ +public final class BitReader implements Iterator +{ + private final ByteReader reader; + private int fifo; + private int bitcount; + + public BitReader(ByteReader reader) + { + this.reader = reader; + } + + public boolean get() + { + if (bitcount == 0) + fifo = reader.read8(); + + boolean bit = (fifo & 0x80) != 0; + fifo <<= 1; + bitcount = (bitcount + 1) & 7; + return bit; + } + + public boolean eof() + { + return bitcount == 0 && reader.eof(); + } + + /* Reads `count` bits into a fresh Bits. */ + public Bits get(int count) + { + Bits bits = new Bits(count); + for (int i = 0; i < count; i++) + bits.setBit(i, get()); + return bits; + } + + @Override + public boolean hasNext() + { + return !eof(); + } + + @Override + public Boolean next() + { + if (!hasNext()) + throw new NoSuchElementException(); + return get(); + } +} diff --git a/java/com/cowlark/fluxengine/core/BitWriter.java b/java/com/cowlark/fluxengine/core/BitWriter.java new file mode 100644 index 000000000..fe0a1dd17 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/BitWriter.java @@ -0,0 +1,49 @@ +package com.cowlark.fluxengine.core; + +/** + * A cursor which packs bits into a ByteWriter. + */ +public final class BitWriter +{ + private final ByteWriter writer; + private int fifo; + private int bitcount; + + public BitWriter(ByteWriter writer) + { + this.writer = writer; + } + + public BitWriter push(int bits, int size) + { + bits <<= 32 - size; + + while (size-- != 0) + { + fifo = (fifo << 1) | (bits >>> 31); + bitcount++; + bits <<= 1; + if (bitcount == 8) + { + writer.write8(fifo); + bitcount = 0; + fifo = 0; + } + } + return this; + } + + public BitWriter push(boolean bit) + { + return push(bit ? 1 : 0, 1); + } + + public void flush() + { + if (bitcount != 0) + { + writer.write8(fifo); + bitcount = 0; + } + } +} diff --git a/java/com/cowlark/fluxengine/core/Bits.java b/java/com/cowlark/fluxengine/core/Bits.java new file mode 100644 index 000000000..2b697e87c --- /dev/null +++ b/java/com/cowlark/fluxengine/core/Bits.java @@ -0,0 +1,189 @@ +package com.cowlark.fluxengine.core; + +import java.util.AbstractList; +import java.util.BitSet; + +/** + * A packed list of booleans backed by a java.util.BitSet, the Java equivalent + * of std::vector. The logical size is tracked separately, so trailing + * falses are part of the list. + */ +public final class Bits extends AbstractList +{ + private final BitSet bits = new BitSet(); + private int size; + + public Bits() + { + } + + public Bits(int size) + { + this.size = size; + } + + @Override + public int size() + { + return size; + } + + @Override + public Boolean get(int index) + { + return getBit(index); + } + + /* Fast, allocation-free bit access for hot paths. */ + public boolean getBit(int index) + { + checkIndex(index); + return bits.get(index); + } + + @Override + public Boolean set(int index, Boolean value) + { + checkIndex(index); + boolean old = bits.get(index); + bits.set(index, value); + return old; + } + + /* Fast, allocation-free bit write for hot paths. */ + public void setBit(int index, boolean value) + { + checkIndex(index); + bits.set(index, value); + } + + @Override + public boolean add(Boolean value) + { + bits.set(size, value); + size++; + modCount++; + return true; + } + + @Override + public void add(int index, Boolean value) + { + if (index < 0 || index > size) + throw new IndexOutOfBoundsException(String.valueOf(index)); + for (int i = size; i > index; i--) + bits.set(i, bits.get(i - 1)); + bits.set(index, value); + size++; + modCount++; + } + + @Override + public Boolean remove(int index) + { + throw new UnsupportedOperationException(); + } + + @Override + public boolean remove(Object o) + { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() + { + bits.clear(); + size = 0; + modCount++; + } + + /* Returns a new Bits containing the bits from fromIndex (inclusive) to + * toIndex (exclusive). */ + public Bits subBits(int fromIndex, int toIndex) + { + Bits result = new Bits(toIndex - fromIndex); + for (int i = fromIndex; i < toIndex; i++) + result.setBit(i - fromIndex, getBit(i)); + return result; + } + + /* Returns a new Bits with the bits in reverse order. */ + public Bits reverseBits() + { + Bits result = new Bits(size); + for (int i = 0; i < size; i++) + result.setBit(size - 1 - i, getBit(i)); + return result; + } + + /* Packs the bits MSB-first into a Bytes (the inverse of Bytes.toBits). */ + public Bytes toBytes() + { + Bytes bytes = new Bytes(0); + BitWriter bitw = new BitWriter(new ByteWriter(bytes)); + for (int i = 0; i < size; i++) + bitw.push(getBit(i)); + bitw.flush(); + return bytes; + } + + /* Fills this Bits from the cursor's current position up to (but not + * including) terminateAt with the given pattern, advancing the cursor. */ + public void fillBitmapTo(Cursor cursor, int terminateAt, boolean[] pattern) + { + while (cursor.get() < terminateAt) + { + for (boolean b : pattern) + { + if (cursor.get() < size) + { + setBit(cursor.get(), b); + cursor.advance(); + } + } + } + } + + private void checkIndex(int index) + { + if (index < 0 || index >= size) + throw new IndexOutOfBoundsException(String.valueOf(index)); + } + + /** + * A mutable cursor into a {@link Bits}, providing the in/out semantics of the + * C++ {@code unsigned& cursor} parameter passed to the bit-writing helpers. + * The current position is held directly, so a single cursor can be shared and + * advanced by successive calls. + */ + public static final class Cursor + { + private int index; + + public Cursor(int index) + { + this.index = index; + } + + public int get() + { + return index; + } + + public void set(int value) + { + index = value; + } + + public void advance() + { + index++; + } + + public void advance(int delta) + { + index += delta; + } + } +} diff --git a/java/com/cowlark/fluxengine/core/ByteReader.java b/java/com/cowlark/fluxengine/core/ByteReader.java new file mode 100644 index 000000000..57133725f --- /dev/null +++ b/java/com/cowlark/fluxengine/core/ByteReader.java @@ -0,0 +1,166 @@ +package com.cowlark.fluxengine.core; + +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * A cursor which reads values from a Bytes, ported from lib/core/bytes.h. + */ +public final class ByteReader implements Iterator +{ + private final Bytes bytes; + private int pos; + + public ByteReader(Bytes bytes) + { + this.bytes = bytes; + pos = 0; + } + + public int pos() + { + return pos; + } + + public ByteReader seek(int pos) + { + this.pos = pos; + return this; + } + + public ByteReader skip(int delta) + { + pos += delta; + return this; + } + + public boolean eof() + { + return pos >= bytes.size(); + } + + public int remaining() + { + return bytes.size() - pos; + } + + @Override + public boolean hasNext() + { + return !eof(); + } + + @Override + public Byte next() + { + if (!hasNext()) + throw new NoSuchElementException(); + return (byte) read8(); + } + + public Bytes read(int len) + { + checkReadable(len); + Bytes slice = bytes.slice(pos, len); + pos += len; + return slice; + } + + public int read8() + { + checkReadable(1); + return bytes.getByte(pos++); + } + + public int readBe16() + { + checkReadable(2); + int b1 = read8(); + int b2 = read8(); + return (b1 << 8) | b2; + } + + public int readLe16() + { + checkReadable(2); + int b1 = read8(); + int b2 = read8(); + return (b2 << 8) | b1; + } + + public int readBe24() + { + checkReadable(3); + int b1 = read8(); + int b2 = read8(); + int b3 = read8(); + return (b1 << 16) | (b2 << 8) | b3; + } + + public int readLe24() + { + checkReadable(3); + int b1 = read8(); + int b2 = read8(); + int b3 = read8(); + return (b3 << 16) | (b2 << 8) | b1; + } + + public int readBe32() + { + checkReadable(4); + int b1 = read8(); + int b2 = read8(); + int b3 = read8(); + int b4 = read8(); + return (b1 << 24) | (b2 << 16) | (b3 << 8) | b4; + } + + public int readLe32() + { + checkReadable(4); + int b1 = read8(); + int b2 = read8(); + int b3 = read8(); + int b4 = read8(); + return (b4 << 24) | (b3 << 16) | (b2 << 8) | b1; + } + + public long readBe48() + { + checkReadable(6); + long hi = readBe16(); + long lo = readBe32() & 0xffffffffL; + return (hi << 32) | lo; + } + + public long readLe48() + { + checkReadable(6); + long lo = readLe32() & 0xffffffffL; + long hi = readLe16(); + return (hi << 32) | lo; + } + + public long readBe64() + { + checkReadable(8); + long hi = readBe32() & 0xffffffffL; + long lo = readBe32() & 0xffffffffL; + return (hi << 32) | lo; + } + + public long readLe64() + { + checkReadable(8); + long lo = readLe32() & 0xffffffffL; + long hi = readLe32() & 0xffffffffL; + return (hi << 32) | lo; + } + + private void checkReadable(int len) + { + if (len < 0 || pos + len > bytes.size()) + throw new IndexOutOfBoundsException(String.valueOf(pos)); + } +} diff --git a/java/com/cowlark/fluxengine/core/ByteWriter.java b/java/com/cowlark/fluxengine/core/ByteWriter.java new file mode 100644 index 000000000..f5936633d --- /dev/null +++ b/java/com/cowlark/fluxengine/core/ByteWriter.java @@ -0,0 +1,187 @@ +package com.cowlark.fluxengine.core; + +/** + * A cursor which writes values into a Bytes, ported from lib/core/bytes.h. + */ +public final class ByteWriter +{ + private final Bytes bytes; + private int pos; + + public ByteWriter(Bytes bytes) + { + this.bytes = bytes; + pos = 0; + } + + public int pos() + { + return pos; + } + + public ByteWriter seek(int pos) + { + this.pos = pos; + return this; + } + + public ByteWriter seekToEnd() + { + pos = bytes.size(); + return this; + } + + public ByteWriter skip(int delta) + { + pos += delta; + return this; + } + + public ByteWriter write8(int value) + { + ensureWritable(1); + bytes.setByte(pos++, (byte) value); + return this; + } + + public ByteWriter writeBe16(int value) + { + ensureWritable(2); + bytes.setByte(pos++, (byte) (value >> 8)); + bytes.setByte(pos++, (byte) value); + return this; + } + + public ByteWriter writeLe16(int value) + { + ensureWritable(2); + bytes.setByte(pos++, (byte) value); + bytes.setByte(pos++, (byte) (value >> 8)); + return this; + } + + public ByteWriter writeBe24(int value) + { + ensureWritable(3); + bytes.setByte(pos++, (byte) (value >> 16)); + bytes.setByte(pos++, (byte) (value >> 8)); + bytes.setByte(pos++, (byte) value); + return this; + } + + public ByteWriter writeLe24(int value) + { + ensureWritable(3); + bytes.setByte(pos++, (byte) value); + bytes.setByte(pos++, (byte) (value >> 8)); + bytes.setByte(pos++, (byte) (value >> 16)); + return this; + } + + public ByteWriter writeBe32(int value) + { + ensureWritable(4); + bytes.setByte(pos++, (byte) (value >> 24)); + bytes.setByte(pos++, (byte) (value >> 16)); + bytes.setByte(pos++, (byte) (value >> 8)); + bytes.setByte(pos++, (byte) value); + return this; + } + + public ByteWriter writeLe32(int value) + { + ensureWritable(4); + bytes.setByte(pos++, (byte) value); + bytes.setByte(pos++, (byte) (value >> 8)); + bytes.setByte(pos++, (byte) (value >> 16)); + bytes.setByte(pos++, (byte) (value >> 24)); + return this; + } + + public ByteWriter writeBe48(long value) + { + ensureWritable(6); + bytes.setByte(pos++, (byte) (value >> 40)); + bytes.setByte(pos++, (byte) (value >> 32)); + bytes.setByte(pos++, (byte) (value >> 24)); + bytes.setByte(pos++, (byte) (value >> 16)); + bytes.setByte(pos++, (byte) (value >> 8)); + bytes.setByte(pos++, (byte) value); + return this; + } + + public ByteWriter writeLe48(long value) + { + ensureWritable(6); + bytes.setByte(pos++, (byte) value); + bytes.setByte(pos++, (byte) (value >> 8)); + bytes.setByte(pos++, (byte) (value >> 16)); + bytes.setByte(pos++, (byte) (value >> 24)); + bytes.setByte(pos++, (byte) (value >> 32)); + bytes.setByte(pos++, (byte) (value >> 40)); + return this; + } + + public ByteWriter writeBe64(long value) + { + ensureWritable(8); + bytes.setByte(pos++, (byte) (value >> 56)); + bytes.setByte(pos++, (byte) (value >> 48)); + bytes.setByte(pos++, (byte) (value >> 40)); + bytes.setByte(pos++, (byte) (value >> 32)); + bytes.setByte(pos++, (byte) (value >> 24)); + bytes.setByte(pos++, (byte) (value >> 16)); + bytes.setByte(pos++, (byte) (value >> 8)); + bytes.setByte(pos++, (byte) value); + return this; + } + + public ByteWriter writeLe64(long value) + { + ensureWritable(8); + bytes.setByte(pos++, (byte) value); + bytes.setByte(pos++, (byte) (value >> 8)); + bytes.setByte(pos++, (byte) (value >> 16)); + bytes.setByte(pos++, (byte) (value >> 24)); + bytes.setByte(pos++, (byte) (value >> 32)); + bytes.setByte(pos++, (byte) (value >> 40)); + bytes.setByte(pos++, (byte) (value >> 48)); + bytes.setByte(pos++, (byte) (value >> 56)); + return this; + } + + public ByteWriter write(Bytes data) + { + ensureWritable(data.size()); + for (int i = 0; i < data.size(); i++) + bytes.setByte(pos++, data.get(i)); + return this; + } + + public ByteWriter write(byte[] data) + { + ensureWritable(data.length); + for (byte b : data) + bytes.setByte(pos++, b); + return this; + } + + public ByteWriter pad(int count) + { + return pad(count, 0); + } + + public ByteWriter pad(int count, int value) + { + ensureWritable(count); + for (int i = 0; i < count; i++) + bytes.setByte(pos++, (byte) value); + return this; + } + + private void ensureWritable(int width) + { + if (pos + width > bytes.size()) + bytes.resize(pos + width); + } +} diff --git a/java/com/cowlark/fluxengine/core/Bytes.java b/java/com/cowlark/fluxengine/core/Bytes.java new file mode 100644 index 000000000..ab6f6fb94 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/Bytes.java @@ -0,0 +1,653 @@ +package com.cowlark.fluxengine.core; + +import com.google.common.collect.ImmutableList; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.ListIterator; +import java.util.NoSuchElementException; +import java.util.zip.DataFormatException; +import java.util.zip.Deflater; +import java.util.zip.Inflater; + +/** + * A resizable byte container, ported from lib/core/bytes.h. Slices share the + * parent's storage; writes to a shared storage copy it first, so changes to + * one window are invisible to the others. + */ +public final class Bytes implements List +{ + private Storage storage; + private int low; + private int high; + + public Bytes() + { + this(0); + } + + public Bytes(int size) + { + storage = new Storage(size); + low = 0; + high = size; + } + + public Bytes(byte[] data) + { + this(data.length); + System.arraycopy(data, 0, storage.data, 0, data.length); + } + + public Bytes(String data) + { + this(data.getBytes(StandardCharsets.UTF_8)); + } + + private Bytes(Storage storage, int low, int high) + { + this.storage = storage; + this.low = low; + this.high = high; + storage.refcount++; + } + + public static Bytes of(int... values) + { + byte[] data = new byte[values.length]; + for (int i = 0; i < values.length; i++) + data[i] = (byte) values[i]; + return new Bytes(data); + } + + private static int reverseBits(int b) + { + b = ((b & 0xF0) >> 4) | ((b & 0x0F) << 4); + b = ((b & 0xCC) >> 2) | ((b & 0x33) << 2); + b = ((b & 0xAA) >> 1) | ((b & 0x55) << 1); + return b; + } + + public int size() + { + return high - low; + } + + public boolean isEmpty() + { + return high == low; + } + + @Override + public Byte get(int offset) + { + return (byte) getByte(offset); + } + + /* Fast, allocation-free byte access for hot paths (avoids Byte boxing). + * Returns the value as an unsigned int (0..255). */ + public int getByte(int offset) + { + boundsCheck(offset); + return storage.data[low + offset] & 0xff; + } + + @Override + public Byte set(int offset, Byte value) + { + boundsCheck(offset); + detach(); + byte old = storage.data[low + offset]; + storage.data[low + offset] = value; + return old; + } + + /* Fast, allocation-free byte write for hot paths (avoids Byte boxing). + * Accepts an unsigned int (0..255). */ + public void setByte(int offset, int value) + { + boundsCheck(offset); + detach(); + storage.data[low + offset] = (byte) value; + } + + public byte[] toByteArray() + { + byte[] result = new byte[size()]; + System.arraycopy(storage.data, low, result, 0, result.length); + return result; + } + + /* Writes the contents to a file, ported from lib/core/bytes.h + * Bytes::writeToFile(). */ + public void writeToFile(String filename) + { + try + { + java.nio.file.Files.write(java.nio.file.Path.of(filename), toByteArray()); + } catch (IOException e) + { + throw new FluxEngineException( + "cannot write to file " + filename + ": " + e.getMessage()); + } + } + + @Override + public Object[] toArray() + { + Object[] result = new Object[size()]; + for (int i = 0; i < size(); i++) + result[i] = (byte) getByte(i); + return result; + } + + @Override + @SuppressWarnings("unchecked") + public T[] toArray(T[] a) + { + int n = size(); + if (a.length < n) + a = (T[]) Arrays.copyOf(a, n, a.getClass()); + for (int i = 0; i < n; i++) + a[i] = (T) Byte.valueOf((byte) getByte(i)); + if (a.length > n) + a[n] = null; + return a; + } + + public void resize(int newSize) + { + detach(); + ensureCapacity(low + newSize); + high = low + newSize; + } + + public Bytes slice(int start, int len) + { + if (start < 0 || len < 0) + throw new IndexOutOfBoundsException(); + if (start >= size()) + return new Bytes(len); + int available = Math.min(len, size() - start); + if (available < len) + { + Bytes result = new Bytes(len); + System.arraycopy(storage.data, low + start, result.storage.data, 0, available); + return result; + } + return new Bytes(storage, low + start, low + start + len); + } + + public Bytes slice(int start) + { + int len = 0; + if (start < size()) + len = size() - start; + return slice(start, len); + } + + public void clear() + { + resize(0); + } + + public ImmutableList split(int separator) + { + ImmutableList.Builder pieces = ImmutableList.builder(); + int lastEnd = 0; + for (int i = 0; i < size(); i++) + { + if ((getByte(i) & 0xff) == separator) + { + pieces.add(slice(lastEnd, i - lastEnd)); + lastEnd = i + 1; + } + } + pieces.add(slice(lastEnd)); + return pieces.build(); + } + + public Bytes swab() + { + Bytes output = new Bytes(0); + ByteWriter bw = new ByteWriter(output); + ByteReader br = new ByteReader(this); + while (!br.eof()) + { + int a = br.read8(); + int b = br.eof() ? 0 : br.read8(); + bw.write8(b); + bw.write8(a); + } + return output; + } + + /* Reverses the bits within each byte, keeping the byte order. */ + public Bytes reverseBits() + { + Bytes output = new Bytes(0); + for (int i = 0; i < size(); i++) + output.add((byte) reverseBits(getByte(i))); + return output; + } + + /* Extracts the bytes as bits, MSB-first within each byte. */ + public Bits toBits() + { + Bits bits = new Bits(size() * 8); + int bit = 0; + for (int i = 0; i < size(); i++) + { + int b = getByte(i) & 0xff; + bits.setBit(bit++, (b & 0x80) != 0); + bits.setBit(bit++, (b & 0x40) != 0); + bits.setBit(bit++, (b & 0x20) != 0); + bits.setBit(bit++, (b & 0x10) != 0); + bits.setBit(bit++, (b & 0x08) != 0); + bits.setBit(bit++, (b & 0x04) != 0); + bits.setBit(bit++, (b & 0x02) != 0); + bits.setBit(bit++, (b & 0x01) != 0); + } + return bits; + } + + /* Produces zlib-format (RFC 1950) data, compatible with the C++ zlib + * compress(). */ + public Bytes compress() + { + Deflater deflater = new Deflater(); + deflater.setInput(storage.data, low, size()); + deflater.finish(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + while (!deflater.finished()) + { + int n = deflater.deflate(buffer); + out.write(buffer, 0, n); + } + deflater.end(); + return new Bytes(out.toByteArray()); + } + + /* Consumes zlib-format (RFC 1950) data, compatible with the C++ zlib + * uncompress(). */ + public Bytes decompress() + { + Inflater inflater = new Inflater(); + inflater.setInput(storage.data, low, size()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + try + { + while (true) + { + int n = inflater.inflate(buffer); + if (n > 0) + out.write(buffer, 0, n); + if (inflater.finished()) + break; + if (n == 0) + throw new FluxEngineException("failed to decompress data"); + } + } catch (DataFormatException e) + { + throw new FluxEngineException("failed to decompress data: " + e.getMessage()); + } finally + { + inflater.end(); + } + return new Bytes(out.toByteArray()); + } + + public Bytes concat(Bytes other) + { + Bytes result = new Bytes(size() + other.size()); + System.arraycopy(storage.data, low, result.storage.data, 0, size()); + System.arraycopy(other.storage.data, other.low, result.storage.data, size(), other.size()); + return result; + } + + public Bytes repeat(int count) + { + Bytes result = new Bytes(size() * count); + for (int i = 0; i < count; i++) + System.arraycopy(storage.data, low, result.storage.data, i * size(), size()); + return result; + } + + byte[] array() + { + return storage.data; + } + + int refcount() + { + return storage.refcount; + } + + @Override + public ByteReader iterator() + { + return new ByteReader(this); + } + + public ByteWriter writer() + { + return new ByteWriter(this); + } + + public ByteReader reader() + { + return new ByteReader(this); + } + + @Override + public boolean add(Byte value) + { + detach(); + ensureCapacity(high + 1); + storage.data[high] = value; + high++; + return true; + } + + @Override + public void add(int index, Byte value) + { + if (index < 0 || index > size()) + throw new IndexOutOfBoundsException(String.valueOf(index)); + detach(); + ensureCapacity(high + 1); + System.arraycopy(storage.data, low + index, storage.data, low + index + 1, size() - index); + storage.data[low + index] = value; + high++; + } + + @Override + public Byte remove(int index) + { + if (index < 0 || index >= size()) + throw new IndexOutOfBoundsException(String.valueOf(index)); + detach(); + byte old = storage.data[low + index]; + System.arraycopy( + storage.data, + low + index + 1, + storage.data, + low + index, + size() - index - 1); + high--; + return old; + } + + @Override + public boolean remove(Object o) + { + int index = indexOf(o); + if (index < 0) + return false; + remove(index); + return true; + } + + @Override + public int indexOf(Object o) + { + if (!(o instanceof Byte)) + return -1; + byte target = (Byte) o; + for (int i = 0; i < size(); i++) + { + if (storage.data[low + i] == target) + return i; + } + return -1; + } + + @Override + public int lastIndexOf(Object o) + { + if (!(o instanceof Byte)) + return -1; + byte target = (Byte) o; + for (int i = size() - 1; i >= 0; i--) + { + if (storage.data[low + i] == target) + return i; + } + return -1; + } + + @Override + public ListIterator listIterator() + { + return listIterator(0); + } + + @Override + public ListIterator listIterator(final int index) + { + if (index < 0 || index > size()) + throw new IndexOutOfBoundsException(String.valueOf(index)); + return new ListIterator() + { + private int cursor = index; + + @Override + public boolean hasNext() + { + return cursor < size(); + } + + @Override + public Byte next() + { + if (!hasNext()) + throw new NoSuchElementException(); + return get(cursor++); + } + + @Override + public boolean hasPrevious() + { + return cursor > 0; + } + + @Override + public Byte previous() + { + if (!hasPrevious()) + throw new NoSuchElementException(); + return get(--cursor); + } + + @Override + public int nextIndex() + { + return cursor; + } + + @Override + public int previousIndex() + { + return cursor - 1; + } + + @Override + public void remove() + { + throw new UnsupportedOperationException(); + } + + @Override + public void set(Byte value) + { + throw new UnsupportedOperationException(); + } + + @Override + public void add(Byte value) + { + throw new UnsupportedOperationException(); + } + }; + } + + @Override + public List subList(int fromIndex, int toIndex) + { + throw new UnsupportedOperationException(); + } + + @Override + public boolean contains(Object o) + { + return indexOf(o) >= 0; + } + + @Override + public boolean containsAll(Collection c) + { + for (Object o : c) + { + if (!contains(o)) + return false; + } + return true; + } + + @Override + public boolean addAll(Collection c) + { + for (Byte b : c) + add(b); + return !c.isEmpty(); + } + + @Override + public boolean addAll(int index, Collection c) + { + if (c.isEmpty()) + return false; + for (Byte b : c) + add(index++, b); + return true; + } + + @Override + public boolean removeAll(Collection c) + { + boolean changed = false; + for (int i = size() - 1; i >= 0; i--) + { + if (c.contains((byte) getByte(i))) + { + remove(i); + changed = true; + } + } + return changed; + } + + @Override + public boolean retainAll(Collection c) + { + boolean changed = false; + for (int i = size() - 1; i >= 0; i--) + { + if (!c.contains((byte) getByte(i))) + { + remove(i); + changed = true; + } + } + return changed; + } + + @Override + public boolean equals(Object o) + { + if (o instanceof Bytes) + { + Bytes other = (Bytes) o; + if (size() != other.size()) + return false; + for (int i = 0; i < size(); i++) + { + if (storage.data[low + i] != other.storage.data[other.low + i]) + return false; + } + return true; + } + if (o instanceof List) + return o.equals(this); + return false; + } + + @Override + public int hashCode() + { + int hash = 1; + for (int i = 0; i < size(); i++) + hash = 31 * hash + storage.data[low + i]; + return hash; + } + + @Override + public String toString() + { + return String.format( + "Bytes(hash=%08x, refcount=%d, size=%d)", + System.identityHashCode(this), + storage.refcount, + size()); + } + + /* Copy-on-write: if this window shares its storage with other windows, + * detach it into a private copy so mutations don't affect them. */ + private void detach() + { + if (storage.refcount > 1) + { + Storage old = storage; + int size = size(); + Storage fresh = new Storage(size); + System.arraycopy(old.data, low, fresh.data, 0, size); + storage = fresh; + low = 0; + high = size; + old.refcount--; + } + } + + private void boundsCheck(int offset) + { + if (offset < 0 || offset >= size()) + throw new IndexOutOfBoundsException(String.valueOf(offset)); + } + + private void ensureCapacity(int capacity) + { + if (capacity <= storage.data.length) + return; + int newCapacity = Math.max(capacity, storage.data.length * 2); + byte[] newData = new byte[newCapacity]; + System.arraycopy(storage.data, 0, newData, 0, storage.data.length); + storage.data = newData; + } + + private static final class Storage + { + byte[] data; + int refcount; + + Storage(int capacity) + { + data = new byte[capacity]; + refcount = 1; + } + } +} diff --git a/java/com/cowlark/fluxengine/core/EmergencyStopException.java b/java/com/cowlark/fluxengine/core/EmergencyStopException.java new file mode 100644 index 000000000..5aa31053e --- /dev/null +++ b/java/com/cowlark/fluxengine/core/EmergencyStopException.java @@ -0,0 +1,12 @@ +package com.cowlark.fluxengine.core; + +/** + * Thrown to abort a running operation, ported from lib/core/utils.h. + */ +public class EmergencyStopException extends RuntimeException +{ + public EmergencyStopException() + { + super(); + } +} diff --git a/java/com/cowlark/fluxengine/core/FluxEngineException.java b/java/com/cowlark/fluxengine/core/FluxEngineException.java new file mode 100644 index 000000000..b8bd6289a --- /dev/null +++ b/java/com/cowlark/fluxengine/core/FluxEngineException.java @@ -0,0 +1,17 @@ +package com.cowlark.fluxengine.core; + +/** + * The base exception for FluxEngine errors. + */ +public class FluxEngineException extends RuntimeException +{ + public FluxEngineException(String message) + { + super(message); + } + + public FluxEngineException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/java/com/cowlark/fluxengine/core/LogMessage.java b/java/com/cowlark/fluxengine/core/LogMessage.java new file mode 100644 index 000000000..24775d0e5 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/LogMessage.java @@ -0,0 +1,38 @@ +package com.cowlark.fluxengine.core; + +/** + * A log message, ported from lib/core/logger.h. Each message type renders + * itself to a LogRenderer. + */ +public interface LogMessage +{ + /* Renders this message. */ + void render(LogRenderer r); + + record StringMessage(String message) implements LogMessage + { + @Override + public void render(LogRenderer r) + { + r.newline().add(message).newline(); + } + } + + record ErrorLogMessage(String message) implements LogMessage + { + @Override + public void render(LogRenderer r) + { + r.newline().add("Error:").add(message).newline(); + } + } + + record EmergencyStopMessage() implements LogMessage + { + @Override + public void render(LogRenderer r) + { + r.newline().add("Stop!").newline(); + } + } +} diff --git a/java/com/cowlark/fluxengine/core/LogRenderer.java b/java/com/cowlark/fluxengine/core/LogRenderer.java new file mode 100644 index 000000000..c7adbdf27 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/LogRenderer.java @@ -0,0 +1,118 @@ +package com.cowlark.fluxengine.core; + +import java.io.PrintStream; + +/** + * Renders log messages to a stream, ported from lib/core/logrenderer.cc. + */ +public abstract class LogRenderer +{ + public static LogRenderer create(PrintStream stream) + { + return new LogRendererImpl(stream); + } + + public LogRenderer add(LogMessage message) + { + message.render(this); + return this; + } + + public abstract LogRenderer add(String message); + + public abstract LogRenderer comma(); + + public abstract LogRenderer header(String message); + + public abstract LogRenderer newline(); + + private static class LogRendererImpl extends LogRenderer + { + private final PrintStream stream; + private boolean header = false; + private boolean newline = false; + private boolean space = false; + private int lineLen = 0; + + LogRendererImpl(PrintStream stream) + { + this.stream = stream; + } + + private void indent() + { + stream.print(" "); + lineLen = 7; + space = true; + } + + @Override + public LogRenderer add(String message) + { + if (newline && !header) + indent(); + + if (!space) + { + stream.print(' '); + lineLen++; + } + + newline = false; + header = false; + + lineLen += message.length(); + if (lineLen >= 80) + { + stream.print('\n'); + indent(); + } + stream.print(message); + space = !message.isEmpty() && + Character.isWhitespace(message.charAt(message.length() - 1)); + return this; + } + + @Override + public LogRenderer header(String message) + { + if (!newline) + stream.print('\n'); + stream.print(message); + lineLen = message.length(); + header = true; + newline = true; + space = !message.isEmpty() && + Character.isWhitespace(message.charAt(message.length() - 1)); + return this; + } + + @Override + public LogRenderer comma() + { + if (!newline || header) + { + stream.print(';'); + space = false; + } + return this; + } + + @Override + public LogRenderer newline() + { + if (!header) + { + if (!newline) + stream.print('\n'); + + lineLen = 0; + header = false; + newline = true; + space = true; + } + return this; + } + } + +} diff --git a/java/com/cowlark/fluxengine/core/Logger.java b/java/com/cowlark/fluxengine/core/Logger.java new file mode 100644 index 000000000..5d732a60c --- /dev/null +++ b/java/com/cowlark/fluxengine/core/Logger.java @@ -0,0 +1,39 @@ +package com.cowlark.fluxengine.core; + +import com.cowlark.fluxengine.core.LogMessage.StringMessage; +import java.util.function.Consumer; + +/** + * The logger, ported from lib/core/logger.{h,cc}. + */ +public final class Logger +{ + private static final ThreadLocal> loggerImpl = + ThreadLocal.withInitial(() -> message -> { + throw new IllegalStateException("logging from a thread with no logger set"); + }); + + private Logger() + { + } + + public static void logf(String message, Object... args) + { + log(new StringMessage(String.format(message, args))); + } + + public static void log(LogMessage message) + { + loggerImpl.get().accept(message); + } + + public static void setLogger(Consumer callback) + { + loggerImpl.set(callback); + } + + public static Consumer getLogger() + { + return loggerImpl.get(); + } +} diff --git a/java/com/cowlark/fluxengine/core/SupplierOfAutocloseable.java b/java/com/cowlark/fluxengine/core/SupplierOfAutocloseable.java new file mode 100644 index 000000000..5230f98d5 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/SupplierOfAutocloseable.java @@ -0,0 +1,41 @@ +package com.cowlark.fluxengine.core; + +import java.util.function.Supplier; + +public class SupplierOfAutocloseable implements Supplier, AutoCloseable +{ + private final Supplier delegate; + public T instance; + private boolean closed = false; + + public SupplierOfAutocloseable(Supplier delegate) + { + if (delegate == null) + throw new IllegalArgumentException("Delegate supplier cannot be null"); + this.delegate = delegate; + } + + @Override + public T get() + { + synchronized (this) + { + if (closed) + throw new IllegalStateException("Supplier has already been closed"); + if (instance == null) + instance = delegate.get(); + return instance; + } + } + + @Override + public void close() throws Exception + { + synchronized (this) + { + if ((instance != null) && !closed) + instance.close(); + closed = true; + } + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/core/Utils.java b/java/com/cowlark/fluxengine/core/Utils.java new file mode 100644 index 000000000..56ecedd43 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/Utils.java @@ -0,0 +1,36 @@ +package com.cowlark.fluxengine.core; + +public class Utils +{ + public static void hexdump(java.io.PrintStream stream, Bytes buffer) + { + int pos = 0; + + while (pos < buffer.size()) + { + stream.printf("%05x : ", pos); + for (int i = 0; i < 16; i++) + { + if ((pos + i) < buffer.size()) + stream.printf("%02x ", buffer.getByte(pos + i)); + else + stream.print("-- "); + } + stream.print(" : "); + for (int i = 0; i < 16; i++) + { + if ((pos + i) >= buffer.size()) + break; + + int c = buffer.getByte(pos + i) & 0xff; + if ((c >= 32) && (c <= 126)) + stream.print((char) c); + else + stream.print('.'); + } + stream.println(); + + pos += 16; + } + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/ActionFlag.java b/java/com/cowlark/fluxengine/core/flags/ActionFlag.java new file mode 100644 index 000000000..497447d28 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/ActionFlag.java @@ -0,0 +1,47 @@ +package com.cowlark.fluxengine.core.flags; + +import lombok.Builder; +import lombok.Singular; +import java.util.List; +import java.util.function.Consumer; + +public class ActionFlag extends Flag +{ + private final Runnable voidCallback; + private final Consumer valueCallback; + private final boolean hasArgument; + + @Builder(setterPrefix = "set") + private ActionFlag(FlagGroup group, + @Singular List names, + String helpText, + Runnable voidCallback, + Consumer valueCallback) + { + super(group, names, helpText); + this.voidCallback = voidCallback; + this.valueCallback = valueCallback; + hasArgument = valueCallback != null; + } + + @Override + public boolean hasArgument() + { + return hasArgument; + } + + @Override + public String defaultValueAsString() + { + return ""; + } + + @Override + public void set(String value) + { + if (hasArgument) + valueCallback.accept(value); + else + voidCallback.run(); + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/BUILD.bazel b/java/com/cowlark/fluxengine/core/flags/BUILD.bazel new file mode 100644 index 000000000..5ce39a05e --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/BUILD.bazel @@ -0,0 +1,21 @@ +load("@rules_java//java:defs.bzl", "java_library", "java_plugin") + +package(default_visibility = ["//visibility:public"]) + +java_plugin( + name = "lombok_plugin", + generates_api = True, + processor_class = "lombok.launch.AnnotationProcessorHider$AnnotationProcessor", + deps = ["@maven//:org_projectlombok_lombok"], +) + +java_library( + name = "flags", + srcs = glob(["*.java"]), + plugins = [":lombok_plugin"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_guava_guava", + "@maven//:org_projectlombok_lombok", + ], +) diff --git a/java/com/cowlark/fluxengine/core/flags/BoolFlag.java b/java/com/cowlark/fluxengine/core/flags/BoolFlag.java new file mode 100644 index 000000000..3409b3998 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/BoolFlag.java @@ -0,0 +1,45 @@ +package com.cowlark.fluxengine.core.flags; + +import com.cowlark.fluxengine.core.FluxEngineException; +import lombok.Builder; +import lombok.Singular; +import java.util.List; +import java.util.function.Consumer; + +public class BoolFlag extends ValueFlag +{ + @Builder(setterPrefix = "set") + private BoolFlag(FlagGroup group, + @Singular List names, + String helpText, + boolean defaultValue, + Consumer callback) + { + super( + group, names, helpText, defaultValue, callback != null ? callback : unused -> { + }); + } + + @Override + public boolean hasArgument() + { + return true; + } + + @Override + public String defaultValueAsString() + { + return value ? "true" : "false"; + } + + @Override + public void set(String value) + { + if (value.equals("true") || value.equals("y")) + setValue(true); + else if (value.equals("false") || value.equals("n")) + setValue(false); + else + throw new FluxEngineException("can't parse '" + value + "'; try 'true' or 'false'"); + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/DoubleFlag.java b/java/com/cowlark/fluxengine/core/flags/DoubleFlag.java new file mode 100644 index 000000000..55dfe3e0b --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/DoubleFlag.java @@ -0,0 +1,43 @@ +package com.cowlark.fluxengine.core.flags; + +import lombok.Builder; +import lombok.Singular; +import java.util.List; +import java.util.function.Consumer; + +public class DoubleFlag extends ValueFlag +{ + @Builder(setterPrefix = "set") + private DoubleFlag(FlagGroup group, + @Singular List names, + String helpText, + Double defaultValue, + Consumer callback) + { + super( + group, + names, + helpText, + defaultValue != null ? defaultValue : 1.0, + callback != null ? callback : unused -> { + }); + } + + @Override + public boolean hasArgument() + { + return true; + } + + @Override + public String defaultValueAsString() + { + return Double.toString(value); + } + + @Override + public void set(String value) + { + setValue(Double.parseDouble(value)); + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/Flag.java b/java/com/cowlark/fluxengine/core/flags/Flag.java new file mode 100644 index 000000000..61e72a6eb --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/Flag.java @@ -0,0 +1,44 @@ +package com.cowlark.fluxengine.core.flags; + +import java.util.List; + +public abstract class Flag +{ + private final FlagGroup group; + private final List names; + private final String helptext; + + protected Flag(FlagGroup group, List names, String helptext) + { + this.group = group; + this.names = List.copyOf(names); + this.helptext = helptext; + group.addFlag(this); + } + + public String name() + { + return names.get(0); + } + + public List names() + { + return names; + } + + public String helptext() + { + return helptext; + } + + public abstract boolean hasArgument(); + + public abstract String defaultValueAsString(); + + public abstract void set(String value); + + protected void checkInitialised() + { + group.checkInitialised(); + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/FlagGroup.java b/java/com/cowlark/fluxengine/core/flags/FlagGroup.java new file mode 100644 index 000000000..6e8d4c60d --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/FlagGroup.java @@ -0,0 +1,77 @@ +package com.cowlark.fluxengine.core.flags; + +import com.google.common.collect.ImmutableList; +import lombok.AccessLevel; +import lombok.Getter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public class FlagGroup +{ + private final ImmutableList parents; + private final List flags = new ArrayList<>(); + @Getter(AccessLevel.PACKAGE) private boolean initialised; + + public FlagGroup() + { + parents = ImmutableList.of(); + } + + public FlagGroup(FlagGroup... parents) + { + this.parents = ImmutableList.copyOf(parents); + } + + static void initialise(FlagGroup group, Set names) + { + if (group.initialised) + return; + + for (FlagGroup parent : group.parents) + initialise(parent, names); + + for (Flag flag : group.flags) + { + for (String name : flag.names()) + { + if (!names.add(name)) + throw new IllegalStateException("two flags use the name '" + name + "'"); + } + } + + group.initialised = true; + } + + public void addFlag(Flag flag) + { + flags.add(flag); + } + + public Flag findFlag(String key) + { + for (Flag flag : flags) + { + for (String name : flag.names()) + { + if (name.equals(key)) + return flag; + } + } + + for (FlagGroup parent : parents) + { + Flag flag = parent.findFlag(key); + if (flag != null) + return flag; + } + + return null; + } + + public void checkInitialised() + { + if (!initialised) + throw new IllegalStateException("Attempt to access uninitialised flag"); + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/Flags.java b/java/com/cowlark/fluxengine/core/flags/Flags.java new file mode 100644 index 000000000..f9da19c4b --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/Flags.java @@ -0,0 +1,122 @@ +package com.cowlark.fluxengine.core.flags; + +import com.cowlark.fluxengine.core.FluxEngineException; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Sets; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Command-line flags system, ported from lib/config/flags.{h,cc}. + */ +public class Flags +{ + private Flags() + { + } + + public static void parse(ImmutableList argv, FlagGroup... groups) + { + parse(argv, ImmutableList.copyOf(groups)); + } + + public static void parse(ImmutableList argv, ImmutableList groups) + { + ImmutableList filenames = parseWithFilenames(argv, unused -> false, groups); + if (!filenames.isEmpty()) + throw new FluxEngineException( + "non-option parameter '" + filenames.get(0) + "' seen (try --help)"); + } + + public static ImmutableList parseWithFilenames(ImmutableList argv, + Predicate callback, + FlagGroup... groups) + { + return parseWithFilenames(argv, callback, ImmutableList.copyOf(groups)); + } + + public static ImmutableList parseWithFilenames(ImmutableList argv, + Predicate callback, + ImmutableList groups) + { + if (groups.isEmpty()) + throw new IllegalArgumentException("no flag groups"); + if (groups.get(0).isInitialised()) + throw new IllegalStateException("called parse() twice"); + + /* Recursively accumulate a list of all flag names, checking for duplicates. */ + Set names = Sets.newHashSet(); + for (FlagGroup group : groups) + FlagGroup.initialise(group, names); + + ImmutableList.Builder filenames = ImmutableList.builder(); + int index = 0; + while (index < argv.size()) + { + String thisArg = argv.get(index); + String thatArg = (index < argv.size() - 1) ? argv.get(index + 1) : ""; + + String key; + String value; + boolean useThat = false; + + if (thisArg.isEmpty()) + { + /* Ignore this argument. */ + } else if (thisArg.charAt(0) != '-') + { + /* This is a filename. */ + if (!callback.test(thisArg)) + filenames.add(thisArg); + } else + { + if (thisArg.length() > 1 && thisArg.charAt(1) == '-') + { + /* Long option. */ + int equals = thisArg.lastIndexOf('='); + if (equals >= 0) + { + key = thisArg.substring(0, equals); + value = thisArg.substring(equals + 1); + } else + { + key = thisArg; + value = thatArg; + useThat = true; + } + } else + { + /* Short option. */ + if (thisArg.length() > 2) + { + key = thisArg.substring(0, 2); + value = thisArg.substring(2); + } else + { + key = thisArg; + value = thatArg; + useThat = true; + } + } + + Flag flag = null; + for (FlagGroup group : groups) + { + flag = group.findFlag(key); + if (flag != null) + break; + } + + if (flag == null) + throw new FluxEngineException("unrecognised flag '" + key + "'; try --help"); + flag.set(value); + if (useThat && flag.hasArgument()) + index++; + } + + index++; + } + + return filenames.build(); + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/HexIntFlag.java b/java/com/cowlark/fluxengine/core/flags/HexIntFlag.java new file mode 100644 index 000000000..9d4d79168 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/HexIntFlag.java @@ -0,0 +1,37 @@ +package com.cowlark.fluxengine.core.flags; + +import lombok.Builder; +import lombok.Singular; +import java.util.List; + +public class HexIntFlag extends ValueFlag +{ + @Builder(setterPrefix = "set") + private HexIntFlag(FlagGroup group, + @Singular List names, + String helpText, + Integer defaultValue) + { + super( + group, names, helpText, defaultValue != null ? defaultValue : 0, unused -> { + }); + } + + @Override + public boolean hasArgument() + { + return true; + } + + @Override + public String defaultValueAsString() + { + return String.format("0x%x", value); + } + + @Override + public void set(String value) + { + setValue(Integer.parseInt(value)); + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/IntFlag.java b/java/com/cowlark/fluxengine/core/flags/IntFlag.java new file mode 100644 index 000000000..555304b3f --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/IntFlag.java @@ -0,0 +1,39 @@ +package com.cowlark.fluxengine.core.flags; + +import lombok.Builder; +import lombok.Singular; +import java.util.List; +import java.util.function.Consumer; + +public class IntFlag extends ValueFlag +{ + @Builder(setterPrefix = "set") + private IntFlag(FlagGroup group, + @Singular List names, + String helpText, + int defaultValue, + Consumer callback) + { + super( + group, names, helpText, defaultValue, callback != null ? callback : unused -> { + }); + } + + @Override + public boolean hasArgument() + { + return true; + } + + @Override + public String defaultValueAsString() + { + return Integer.toString(value); + } + + @Override + public void set(String value) + { + setValue(Integer.parseInt(value)); + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/SettableFlag.java b/java/com/cowlark/fluxengine/core/flags/SettableFlag.java new file mode 100644 index 000000000..f5f4d4de7 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/SettableFlag.java @@ -0,0 +1,40 @@ +package com.cowlark.fluxengine.core.flags; + +import lombok.Builder; +import lombok.Singular; +import java.util.List; + +public class SettableFlag extends Flag +{ + private boolean value; + + @Builder(setterPrefix = "set") + private SettableFlag(FlagGroup group, @Singular List names, String helpText) + { + super(group, names, helpText); + } + + public boolean get() + { + checkInitialised(); + return value; + } + + @Override + public boolean hasArgument() + { + return false; + } + + @Override + public String defaultValueAsString() + { + return "false"; + } + + @Override + public void set(String value) + { + this.value = true; + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/StringFlag.java b/java/com/cowlark/fluxengine/core/flags/StringFlag.java new file mode 100644 index 000000000..1ae383399 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/StringFlag.java @@ -0,0 +1,43 @@ +package com.cowlark.fluxengine.core.flags; + +import lombok.Builder; +import lombok.Singular; +import java.util.List; +import java.util.function.Consumer; + +public class StringFlag extends ValueFlag +{ + @Builder(setterPrefix = "set") + private StringFlag(FlagGroup group, + @Singular List names, + String helpText, + String defaultValue, + Consumer callback) + { + super( + group, + names, + helpText, + defaultValue != null ? defaultValue : "", + callback != null ? callback : unused -> { + }); + } + + @Override + public boolean hasArgument() + { + return true; + } + + @Override + public String defaultValueAsString() + { + return value; + } + + @Override + public void set(String value) + { + setValue(value); + } +} diff --git a/java/com/cowlark/fluxengine/core/flags/ValueFlag.java b/java/com/cowlark/fluxengine/core/flags/ValueFlag.java new file mode 100644 index 000000000..df39e6b41 --- /dev/null +++ b/java/com/cowlark/fluxengine/core/flags/ValueFlag.java @@ -0,0 +1,48 @@ +package com.cowlark.fluxengine.core.flags; + +import java.util.List; +import java.util.function.Consumer; + +public abstract class ValueFlag extends Flag +{ + private final Consumer callback; + protected T value; + private T defaultValue; + private boolean isSet; + + protected ValueFlag(FlagGroup group, + List names, + String helpText, + T defaultValue, + Consumer callback) + { + super(group, names, helpText); + this.defaultValue = defaultValue; + this.value = defaultValue; + this.callback = callback; + } + + public T get() + { + checkInitialised(); + return value; + } + + public boolean isSet() + { + return isSet; + } + + public void setDefaultValue(T value) + { + defaultValue = value; + this.value = value; + } + + protected void setValue(T value) + { + this.value = value; + callback.accept(value); + isSet = true; + } +} diff --git a/java/com/cowlark/fluxengine/data/BUILD.bazel b/java/com/cowlark/fluxengine/data/BUILD.bazel new file mode 100644 index 000000000..3292c9c6e --- /dev/null +++ b/java/com/cowlark/fluxengine/data/BUILD.bazel @@ -0,0 +1,29 @@ +load("@rules_java//java:defs.bzl", "java_library", "java_plugin") + +package(default_visibility = ["//visibility:public"]) + +java_plugin( + name = "lombok_plugin", + generates_api = True, + processor_class = "lombok.launch.AnnotationProcessorHider$AnnotationProcessor", + deps = ["@maven//:org_projectlombok_lombok"], +) + +java_library( + name = "data", + srcs = glob(["*.java"]), + plugins = [":lombok_plugin"], + resource_strip_prefix = "src/formats", + resources = ["//src/formats:formats_files"], + deps = [ + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/config:layout_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "//java/com/cowlark/fluxengine/external", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "@com_google_protobuf//java/core", + "@maven//:com_google_guava_guava", + "@maven//:org_projectlombok_lombok", + ], +) diff --git a/java/com/cowlark/fluxengine/data/CylinderHead.java b/java/com/cowlark/fluxengine/data/CylinderHead.java new file mode 100644 index 000000000..d9b559a76 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/CylinderHead.java @@ -0,0 +1,16 @@ +package com.cowlark.fluxengine.data; + +/** + * A cylinder/head location, ported from lib/data/locations.h. + */ +public record CylinderHead(int cylinder, int head) implements Comparable +{ + @Override + public int compareTo(CylinderHead other) + { + int result = Integer.compare(cylinder, other.cylinder); + if (result == 0) + result = Integer.compare(head, other.head); + return result; + } +} diff --git a/java/com/cowlark/fluxengine/data/CylinderHeadSector.java b/java/com/cowlark/fluxengine/data/CylinderHeadSector.java new file mode 100644 index 000000000..2402d1ef5 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/CylinderHeadSector.java @@ -0,0 +1,19 @@ +package com.cowlark.fluxengine.data; + +/** + * A cylinder/head/sector location, ported from lib/data/locations.h. + */ +public record CylinderHeadSector(int cylinder, int head, int sector) implements + Comparable +{ + @Override + public int compareTo(CylinderHeadSector other) + { + int result = Integer.compare(cylinder, other.cylinder); + if (result == 0) + result = Integer.compare(head, other.head); + if (result == 0) + result = Integer.compare(sector, other.sector); + return result; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/Disk.java b/java/com/cowlark/fluxengine/data/Disk.java new file mode 100644 index 000000000..e1d4e1dbe --- /dev/null +++ b/java/com/cowlark/fluxengine/data/Disk.java @@ -0,0 +1,68 @@ +package com.cowlark.fluxengine.data; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ListMultimap; +import java.util.Set; +import java.util.TreeSet; + +/** + * A disk, being the result of reading a physical disk, ported from + * lib/data/disk.h and lib/data/disk.cc. + */ +public class Disk +{ + public final ListMultimap tracksByPhysicalLocation = + ArrayListMultimap.create(); + public final ListMultimap sectorsByPhysicalLocation = + ArrayListMultimap.create(); + public Image image = null; + + /* 0 if the period is unknown (e.g. if this Disk was made from an image). */ + public double rotationalPeriodNs = 0; + + public Disk() + { + image = new Image(); + } + + public Disk(Image image, DiskLayout diskLayout) + { + this.image = image; + + ListMultimap sectorsGroupedByTrack = ArrayListMultimap.create(); + for (Sector sector : image) + sectorsGroupedByTrack.put(sector.physicalLocation, sector); + + Set sectorLocations = new TreeSet<>(); + for (CylinderHead ch : sectorsGroupedByTrack.keySet()) + sectorLocations.add(ch); + + for (CylinderHead physicalLocation : sectorLocations) + { + PhysicalTrackLayout ptl = diskLayout.layoutByPhysicalLocation.get(physicalLocation); + LogicalTrackLayout ltl = ptl.logicalTrackLayout; + + Track decodedTrack = new Track(); + decodedTrack.ltl = ltl; + decodedTrack.ptl = ptl; + tracksByPhysicalLocation.put(physicalLocation, decodedTrack); + + for (Sector sector : sectorsGroupedByTrack.get(physicalLocation)) + { + decodedTrack.allSectors.add(sector); + decodedTrack.normalisedSectors.add(sector); + sectorsByPhysicalLocation.put(physicalLocation, sector); + } + } + } + + /* Creates a copy of the given disk, so that the copy doesn't see the + * original get mutated later. */ + public Disk(Disk disk) + { + tracksByPhysicalLocation.putAll(disk.tracksByPhysicalLocation); + sectorsByPhysicalLocation.putAll(disk.sectorsByPhysicalLocation); + image = disk.image; + rotationalPeriodNs = disk.rotationalPeriodNs; + } +} diff --git a/java/com/cowlark/fluxengine/data/DiskLayout.java b/java/com/cowlark/fluxengine/data/DiskLayout.java new file mode 100644 index 000000000..728285e08 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/DiskLayout.java @@ -0,0 +1,431 @@ +package com.cowlark.fluxengine.data; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.config.LayoutProto; +import com.cowlark.fluxengine.config.SectorListProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.external.DriveType; +import com.cowlark.fluxengine.external.FormatType; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The physical layout of the disk, ported from lib/data/layout.cc. + */ +public class DiskLayout +{ + /* Logical size. */ + public final int numLogicalCylinders; + public final int numLogicalHeads; + /* Physical size and properties. */ + public final int minPhysicalCylinder; + public final int maxPhysicalCylinder; + public final int minPhysicalHead; + public final int maxPhysicalHead; + public final int groupSize; + public final int headBias; + public final int headWidth; + public final boolean swapSides; + public final long totalBytes; + /* Physical and logical layouts by location. */ + public final ImmutableMap layoutByPhysicalLocation; + public final ImmutableMap layoutByLogicalLocation; + /* Ordered lists of physical and logical locations. */ + public final ImmutableList logicalLocations; + public final ImmutableList logicalLocationsInFilesystemOrder; + public final ImmutableList physicalLocations; + /* Ordered lists of sector locations, plus the reverse mapping. */ + public final ImmutableList logicalSectorLocationsInFilesystemOrder; + public final ImmutableMap blockIdByLogicalSectorLocation; + public final ImmutableList physicalSectorLocationsInFilesystemOrder; + /* Mapping from logical location to sector offset and back again. */ + public final ImmutableMap logicalSectorLocationBySectorOffset; + public final ImmutableMap sectorOffsetByLogicalSectorLocation; + + public DiskLayout(ConfigProto config) + { + int minPhysicalCylinderLocal = Integer.MAX_VALUE; + int minPhysicalHeadLocal = Integer.MAX_VALUE; + int maxPhysicalCylinderLocal = 0; + int maxPhysicalHeadLocal = 0; + + numLogicalCylinders = config.getLayout().getTracks(); + numLogicalHeads = config.getLayout().getSides(); + + groupSize = getTrackStep(config); + headBias = config.getDrive().getHeadBias(); + swapSides = config.getLayout().getSwapSides(); + + switch (config.getDrive().getDriveType()) + { + case DRIVETYPE_APPLE2: + headWidth = 4; + break; + + default: + headWidth = 1; + break; + } + + Map logicalLayout = new LinkedHashMap<>(); + List logicalLocationsLocal = new ArrayList<>(); + + for (int logicalCylinder = 0; logicalCylinder < numLogicalCylinders; logicalCylinder++) + for (int logicalHead = 0; logicalHead < numLogicalHeads; logicalHead++) + { + int physicalCylinder = remapCylinderLogicalToPhysical(logicalCylinder); + int physicalHead = remapHeadLogicalToPhysical(logicalHead); + + minPhysicalCylinderLocal = Math.min(minPhysicalCylinderLocal, physicalCylinder); + maxPhysicalCylinderLocal = + Math.max(maxPhysicalCylinderLocal, physicalCylinder + groupSize - 1); + minPhysicalHeadLocal = Math.min(minPhysicalHeadLocal, physicalHead); + maxPhysicalHeadLocal = Math.max(maxPhysicalHeadLocal, physicalHead); + + LayoutProto.LayoutdataProto layoutdata = + getLayoutData(logicalCylinder, logicalHead, config); + int sectorSize = layoutdata.getSectorSize(); + List diskSectorOrder = expandSectorList(layoutdata.getPhysical()); + List naturalSectorOrder = new ArrayList<>(diskSectorOrder); + Collections.sort(naturalSectorOrder); + int numSectors = naturalSectorOrder.size(); + + List filesystemSectorOrder; + if (layoutdata.hasFilesystem()) + { + filesystemSectorOrder = expandSectorList(layoutdata.getFilesystem()); + if (filesystemSectorOrder.size() != numSectors) + throw new FluxEngineException( + "filesystem sector order list doesn't contain the right number of" + + " sectors"); + } else + filesystemSectorOrder = new ArrayList<>(naturalSectorOrder); + + Map sectorIdToNaturalOrdering = new LinkedHashMap<>(); + Map sectorIdToFilesystemOrdering = new LinkedHashMap<>(); + for (int i = 0; i < numSectors; i++) + { + int fid = naturalSectorOrder.get(i); + sectorIdToNaturalOrdering.put(i, fid); + sectorIdToFilesystemOrdering.put(i, fid); + } + + LogicalTrackLayout ltl = new LogicalTrackLayout( + physicalCylinder, + physicalHead, + groupSize, + logicalCylinder, + logicalHead, + numSectors, + sectorSize, + ImmutableList.copyOf(naturalSectorOrder), + ImmutableList.copyOf(diskSectorOrder), + ImmutableList.copyOf(filesystemSectorOrder), + ImmutableMap.copyOf(sectorIdToFilesystemOrdering), + ImmutableMap.copyOf(sectorIdToNaturalOrdering)); + logicalLayout.put(new CylinderHead(logicalCylinder, logicalHead), ltl); + logicalLocationsLocal.add(new CylinderHead(logicalCylinder, logicalHead)); + } + + minPhysicalCylinder = minPhysicalCylinderLocal; + maxPhysicalCylinder = maxPhysicalCylinderLocal; + minPhysicalHead = minPhysicalHeadLocal; + maxPhysicalHead = maxPhysicalHeadLocal; + + Map physicalLayout = new LinkedHashMap<>(); + List physicalLocationsLocal = new ArrayList<>(); + + for (int physicalCylinder = minPhysicalCylinder; physicalCylinder <= maxPhysicalCylinder; + physicalCylinder++) + for (int physicalHead = minPhysicalHead; physicalHead <= maxPhysicalHead; + physicalHead++) + { + CylinderHead ch = new CylinderHead(physicalCylinder, physicalHead); + PhysicalTrackLayout ptl = new PhysicalTrackLayout( + physicalCylinder, + physicalHead, + (physicalCylinder - headBias) % groupSize, + logicalLayout.get(new CylinderHead( + remapCylinderPhysicalToLogical(physicalCylinder), + remapHeadPhysicalToLogical(physicalHead)))); + physicalLayout.put(ch, ptl); + physicalLocationsLocal.add(ch); + } + + layoutByLogicalLocation = ImmutableMap.copyOf(logicalLayout); + logicalLocations = ImmutableList.copyOf(logicalLocationsLocal); + layoutByPhysicalLocation = ImmutableMap.copyOf(physicalLayout); + physicalLocations = ImmutableList.copyOf(physicalLocationsLocal); + + long sectorOffset = 0; + int blockId = 0; + List logicalLocationsFilesystemLocal = new ArrayList<>(); + List logicalSectorLocationsLocal = new ArrayList<>(); + Map logicalSectorOffsetLocal = new LinkedHashMap<>(); + Map sectorOffsetByLocationLocal = new LinkedHashMap<>(); + Map blockIdByLocationLocal = new LinkedHashMap<>(); + + for (CylinderHead ch : getTrackOrdering( + config.getLayout().getFilesystemTrackOrder(), + numLogicalCylinders, + numLogicalHeads)) + { + LogicalTrackLayout ltl = logicalLayout.get(ch); + logicalLocationsFilesystemLocal.add(ch); + + for (int lid : ltl.filesystemSectorOrder) + { + LogicalLocation logicalLocation = + new LogicalLocation(ch.cylinder(), ch.head(), lid); + logicalSectorOffsetLocal.put(sectorOffset, logicalLocation); + sectorOffsetByLocationLocal.put(logicalLocation, sectorOffset); + logicalSectorLocationsLocal.add(logicalLocation); + sectorOffset += ltl.sectorSize; + + blockIdByLocationLocal.put(logicalLocation, blockId); + blockId++; + } + } + + logicalLocationsInFilesystemOrder = ImmutableList.copyOf(logicalLocationsFilesystemLocal); + logicalSectorLocationsInFilesystemOrder = ImmutableList.copyOf(logicalSectorLocationsLocal); + logicalSectorLocationBySectorOffset = ImmutableMap.copyOf(logicalSectorOffsetLocal); + sectorOffsetByLogicalSectorLocation = ImmutableMap.copyOf(sectorOffsetByLocationLocal); + blockIdByLogicalSectorLocation = ImmutableMap.copyOf(blockIdByLocationLocal); + physicalSectorLocationsInFilesystemOrder = ImmutableList.of(); + + totalBytes = sectorOffset; + } + + public DiskLayout(int numCylinders, int numHeads, int numSectors, int sectorSize) + { + this(createTestConfig(numCylinders, numHeads, numSectors, sectorSize)); + } + + public static DiskLayout createDiskLayout(ConfigProto config) + { + return new DiskLayout(config); + } + + public static LayoutBounds getBounds(Iterable keys) + { + int minCylinder = Integer.MAX_VALUE; + int maxCylinder = Integer.MIN_VALUE; + int minHead = Integer.MAX_VALUE; + int maxHead = Integer.MIN_VALUE; + + for (CylinderHead ch : keys) + { + minCylinder = Math.min(minCylinder, ch.cylinder()); + maxCylinder = Math.max(maxCylinder, ch.cylinder()); + minHead = Math.min(minHead, ch.head()); + maxHead = Math.max(maxHead, ch.head()); + } + + return new LayoutBounds(minCylinder, maxCylinder, minHead, maxHead); + } + + private static int getTrackStep(ConfigProto config) + { + FormatType formatType = config.getLayout().getFormatType(); + DriveType driveType = config.getDrive().getDriveType(); + + switch (formatType) + { + case FORMATTYPE_40TRACK: + switch (driveType) + { + case DRIVETYPE_40TRACK: + return 1; + + case DRIVETYPE_80TRACK: + return 2; + + case DRIVETYPE_APPLE2: + return 4; + + default: + break; + } + + /* Fall through, as in the C++. */ + + case FORMATTYPE_80TRACK: + switch (driveType) + { + case DRIVETYPE_40TRACK: + throw new FluxEngineException( + "you can't read/write an 80 track image from/to a 40 track drive"); + + case DRIVETYPE_80TRACK: + return 1; + + case DRIVETYPE_APPLE2: + throw new FluxEngineException( + "you can't read/write an 80 track image from/to an Apple II drive"); + + default: + break; + } + break; + + default: + break; + } + + return 1; + } + + private static List getTrackOrdering(LayoutProto.Order ordering, + int tracks, + int sides) + { + List trackList = new ArrayList<>(); + switch (ordering) + { + case CHS: + for (int track = 0; track < tracks; track++) + for (int side = 0; side < sides; side++) + trackList.add(new CylinderHead(track, side)); + break; + + case HCS: + for (int side = 0; side < sides; side++) + for (int track = 0; track < tracks; track++) + trackList.add(new CylinderHead(track, side)); + break; + + case HCS_RH1: + for (int side = 0; side < sides; side++) + { + if (side == 0) + for (int track = 0; track < tracks; track++) + trackList.add(new CylinderHead(track, side)); + if (side == 1) + for (int track = tracks; track > 0; track--) + trackList.add(new CylinderHead(track - 1, side)); + } + break; + + default: + throw new FluxEngineException("LAYOUT: invalid track trackList"); + } + + return trackList; + } + + private static List expandSectorList(SectorListProto sectorsProto) + { + List sectors = new ArrayList<>(); + + if (sectorsProto.hasCount()) + { + if (sectorsProto.getSectorCount() != 0) + throw new FluxEngineException( + "LAYOUT: if you use a sector count, you can't use an explicit sector list"); + + Set sectorset = new HashSet<>(); + int id = sectorsProto.getStartSector(); + for (int i = 0; i < sectorsProto.getCount(); i++) + { + while (sectorset.contains(id)) + { + id++; + if (id >= (sectorsProto.getStartSector() + sectorsProto.getCount())) + id -= sectorsProto.getCount(); + } + + sectorset.add(id); + sectors.add(id); + + id += sectorsProto.getSkew(); + if (id >= (sectorsProto.getStartSector() + sectorsProto.getCount())) + id -= sectorsProto.getCount(); + } + } else if (sectorsProto.getSectorCount() > 0) + { + for (int i = 0; i < sectorsProto.getSectorCount(); i++) + sectors.add(sectorsProto.getSector(i)); + } else + throw new FluxEngineException("LAYOUT: no sectors in sector definition!"); + + return sectors; + } + + private static LayoutProto.LayoutdataProto getLayoutData(int logicalCylinder, + int logicalHead, + ConfigProto config) + { + LayoutProto.LayoutdataProto.Builder layoutData = LayoutProto.LayoutdataProto.newBuilder(); + for (LayoutProto.LayoutdataProto f : config.getLayout().getLayoutdataList()) + { + if (f.hasTrack() && f.hasUpToTrack() && + ((logicalCylinder < f.getTrack()) || (logicalCylinder > f.getUpToTrack()))) + continue; + if (f.hasTrack() && !f.hasUpToTrack() && (logicalCylinder != f.getTrack())) + continue; + if (f.hasSide() && (f.getSide() != logicalHead)) + continue; + + layoutData.mergeFrom(f); + } + return layoutData.build(); + } + + private static ConfigProto createTestConfig(int numCylinders, + int numHeads, + int numSectors, + int sectorSize) + { + ConfigProto.Builder config = ConfigProto.newBuilder(); + LayoutProto.Builder layout = config.getLayoutBuilder(); + layout.setTracks(numCylinders); + layout.setSides(numHeads); + LayoutProto.LayoutdataProto.Builder layoutData = layout.addLayoutdataBuilder(); + layoutData.setSectorSize(sectorSize); + layoutData.getPhysicalBuilder().setCount(numSectors); + + return config.build(); + } + + public LayoutBounds getPhysicalBounds() + { + return getBounds(layoutByPhysicalLocation.keySet()); + } + + public LayoutBounds getLogicalBounds() + { + return getBounds(layoutByLogicalLocation.keySet()); + } + + public int remapCylinderPhysicalToLogical(int physicalCylinder) + { + return (physicalCylinder - headBias) / groupSize; + } + + public int remapCylinderLogicalToPhysical(int logicalCylinder) + { + return headBias + logicalCylinder * groupSize; + } + + public int remapHeadPhysicalToLogical(int physicalHead) + { + return physicalHead ^ (swapSides ? 1 : 0); + } + + public int remapHeadLogicalToPhysical(int logicalHead) + { + return logicalHead ^ (swapSides ? 1 : 0); + } + + public record LayoutBounds(int minCylinder, int maxCylinder, int minHead, int maxHead) + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/FluxMatch.java b/java/com/cowlark/fluxengine/data/FluxMatch.java new file mode 100644 index 000000000..f114e9400 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/FluxMatch.java @@ -0,0 +1,13 @@ +package com.cowlark.fluxengine.data; + +/** + * The result of matching a pattern against a run of flux intervals, ported + * from lib/data/fluxpattern.h. + */ +public class FluxMatch +{ + public FluxMatcher matcher = null; + public int intervals = 0; + public double clock = 0.0; + public int zeroes = 0; +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/FluxMatcher.java b/java/com/cowlark/fluxengine/data/FluxMatcher.java new file mode 100644 index 000000000..6d2acafa3 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/FluxMatcher.java @@ -0,0 +1,20 @@ +package com.cowlark.fluxengine.data; + +/* A special-casing: the matcher walks a sliding window of the last + * `intervals()` intervals and checks whether they match the pattern. */ + +/** + * A matcher over a run of flux intervals, ported from lib/data/fluxpattern.h. + */ +public interface FluxMatcher +{ + /* Intervals is the window of candidate intervals, with `endIndex` one + * past the newest (and most recently found) interval. The matcher + * examines the last `intervals().size()` entries (i.e. from + * `endIndex - intervals()` to `endIndex`); `match` receives the result. + */ + + boolean matches(long[] intervals, int endIndex, double clockDecodeThreshold, FluxMatch match); + + int intervals(); +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/FluxMatchers.java b/java/com/cowlark/fluxengine/data/FluxMatchers.java new file mode 100644 index 000000000..1bda250a7 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/FluxMatchers.java @@ -0,0 +1,45 @@ +package com.cowlark.fluxengine.data; + +import java.util.Arrays; +import java.util.List; + +/** + * A compound flux matcher that tries several matchers in turn, ported from + * lib/data/fluxpattern.{h,cc}. + */ +public class FluxMatchers implements FluxMatcher +{ + private final List matchers; + private final int intervalCount; + + public FluxMatchers(List matchers) + { + this.matchers = matchers; + intervalCount = matchers.stream().mapToInt(FluxMatcher::intervals).max().orElse(0); + } + + public static FluxMatchers of(FluxMatcher... matchers) + { + return new FluxMatchers(Arrays.asList(matchers)); + } + + @Override + public boolean matches(long[] candidates, + int endIndex, + double clockDecodeThreshold, + FluxMatch match) + { + for (FluxMatcher matcher : matchers) + { + if (matcher.matches(candidates, endIndex, clockDecodeThreshold, match)) + return true; + } + return false; + } + + @Override + public int intervals() + { + return intervalCount; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/FluxPattern.java b/java/com/cowlark/fluxengine/data/FluxPattern.java new file mode 100644 index 000000000..d1e2254e6 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/FluxPattern.java @@ -0,0 +1,141 @@ +package com.cowlark.fluxengine.data; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A single flux pattern, ported from lib/data/fluxpattern.{h,cc}. + */ +public class FluxPattern implements FluxMatcher +{ + private static final long TOPBIT = 1L << 63; + + private final int bitCount; + private final int highZeroes; + private final List intervals = new ArrayList<>(); + private final int length; + private boolean lowZero = false; + + public FluxPattern(int bits, long pattern) + { + bitCount = bits; + if (pattern == 0) + throw new IllegalArgumentException("flux pattern may not be zero"); + if (bits < 1 || bits > 64) + throw new IllegalArgumentException("flux pattern bit count must be 1..64"); + + int lowBit = findLowestSetBit(pattern) - 1; + + pattern <<= (64 - bits); + int highZeroesLocal = 0; + while ((pattern & TOPBIT) == 0) + { + pattern <<= 1; + highZeroesLocal++; + } + highZeroes = highZeroesLocal; + + int lengthLocal = 0; + while (pattern != TOPBIT) + { + int interval = 0; + do + { + pattern <<= 1; + interval++; + } while ((pattern & TOPBIT) == 0); + intervals.add(interval); + lengthLocal += interval; + } + length = lengthLocal; + + if (lowBit != 0) + { + lowZero = true; + intervals.add(lowBit + 1); + } + } + + /* Returns the index (1-based) of the lowest set bit, or 0 if none. */ + private static int findLowestSetBit(long value) + { + if (value == 0) + return 0; + int bit = 1; + while ((value & 1) == 0) + { + value >>= 1; + bit++; + } + return bit; + } + + @Override + /* The `endIndex` is one past the newest candidate interval, mirroring the + * C++ pointer passed as `&*candidates.end()`. */ public boolean matches(long[] candidates, + int endIndex, + double clockDecodeThreshold, + FluxMatch match) + { + int start = endIndex - intervals.size(); + + int candidateLength = 0; + for (int i = start; i < endIndex - (lowZero ? 1 : 0); i++) + candidateLength += candidates[i]; + + if (candidateLength == 0) + return false; + match.clock = (double) candidateLength / (double) length; + + int exactIntervals = intervals.size() - (lowZero ? 1 : 0); + for (int i = 0; i < exactIntervals; i++) + { + double ii = match.clock * intervals.get(i); + double ci = candidates[start + i]; + + double error = Math.abs((ii - ci) / match.clock); + if (error > clockDecodeThreshold) + return false; + } + + if (lowZero) + { + double ii = match.clock * intervals.get(exactIntervals); + double ci = candidates[start + exactIntervals]; + + double error = (ii - ci) / match.clock; + if (error > clockDecodeThreshold) + return false; + } + + match.matcher = this; + match.intervals = intervals.size(); + match.zeroes = highZeroes; + return true; + } + + @Override + public int intervals() + { + return intervals.size(); + } + + /* Package-private accessors for the tests (mirrors the C++ `friend` + * test_patternconstruction/test_patternmatching). */ + + int getBitCount() + { + return bitCount; + } + + List getIntervals() + { + return Collections.unmodifiableList(intervals); + } + + int getHighZeroes() + { + return highZeroes; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/FluxPosition.java b/java/com/cowlark/fluxengine/data/FluxPosition.java new file mode 100644 index 000000000..71a1effaa --- /dev/null +++ b/java/com/cowlark/fluxengine/data/FluxPosition.java @@ -0,0 +1,17 @@ +package com.cowlark.fluxengine.data; + +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; + +public record FluxPosition(int bytes, int ticks, int zeroes) +{ + public double getDurationNs() + { + return ticks * NS_PER_TICK; + } + + @Override + public String toString() + { + return String.format("[b:%d, t:%d, z:%d]", bytes, ticks, zeroes); + } +} diff --git a/java/com/cowlark/fluxengine/data/Fluxmap.java b/java/com/cowlark/fluxengine/data/Fluxmap.java new file mode 100644 index 000000000..0087a0aad --- /dev/null +++ b/java/com/cowlark/fluxengine/data/Fluxmap.java @@ -0,0 +1,184 @@ +package com.cowlark.fluxengine.data; + +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_INDEX; +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_PULSE; +import static com.cowlark.fluxengine.external.FluxEngine.F_DESYNC; +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.google.common.collect.ImmutableList; + +/** + * A stream of flux transitions, ported from lib/data/fluxmap.{h,cc}. + */ +public class Fluxmap +{ + + private int ticks; + private Bytes bytes; + private ImmutableList indexMarks; + + public Fluxmap() + { + bytes = new Bytes(); + } + + public Fluxmap(String s) + { + this(); + appendBytes(new Bytes(s)); + } + + public Fluxmap(Bytes bytes) + { + this(); + appendBytes(bytes); + } + + public int ticks() + { + return ticks; + } + + /* The duration of the fluxmap in nanoseconds, ported from + * lib/data/fluxmap.h Fluxmap::duration(). */ + public double durationNs() + { + return ticks * NS_PER_TICK; + } + + public int bytes() + { + return bytes.size(); + } + + public Bytes rawBytes() + { + return bytes; + } + + public Fluxmap appendInterval(int ticks) + { + while (ticks >= 0x3f) + { + appendByte(0x3f); + ticks -= 0x3f; + } + appendByte(ticks & 0xff); + return this; + } + + public Fluxmap appendPulse() + { + ensureLastByte(); + int index = bytes.size() - 1; + bytes.setByte(index, (byte) (bytes.getByte(index) | F_BIT_PULSE)); + return this; + } + + public Fluxmap appendIndex() + { + flushIndexMarks(); + ensureLastByte(); + int index = bytes.size() - 1; + bytes.setByte(index, (byte) (bytes.getByte(index) | F_BIT_INDEX)); + return this; + } + + public Fluxmap appendDesync() + { + appendByte(F_DESYNC); + return this; + } + + public Fluxmap appendBytes(Bytes data) + { + if (data.isEmpty()) + return this; + + flushIndexMarks(); + + ByteWriter bw = new ByteWriter(bytes); + bw.seekToEnd(); + for (int i = 0; i < data.size(); i++) + { + int b = data.getByte(i) & 0xff; + ticks += b & 0x3f; + bw.write8(b); + } + + return this; + } + + public Fluxmap appendByte(int b) + { + return appendBytes(Bytes.of(b)); + } + + public Fluxmap appendBits(Bits bits, double clockNs) + { + double nowTicks = durationNs() / NS_PER_TICK; + double clockTicks = clockNs / NS_PER_TICK; + for (boolean bit : bits) + { + nowTicks += clockTicks; + if (bit) + { + int deltaTicks = (int) nowTicks - ticks; + appendInterval(deltaTicks); + appendPulse(); + } + } + int deltaTicks = (int) nowTicks - ticks; + if (deltaTicks != 0) + appendInterval(deltaTicks); + return this; + } + + public ImmutableList split() + { + ImmutableList.Builder maps = ImmutableList.builder(); + for (Bytes piece : bytes.split(F_DESYNC)) + { + if (!piece.isEmpty()) + maps.add(new Fluxmap(piece)); + } + return maps.build(); + } + + public ImmutableList getIndexMarks() + { + if (indexMarks == null) + { + ImmutableList.Builder marks = ImmutableList.builder(); + long totalTicks = 0; + long oldTicks = -1; + for (int i = 0; i < bytes.size(); i++) + { + int b = bytes.getByte(i) & 0xff; + totalTicks += b & 0x3f; + if ((b & F_BIT_INDEX) != 0) + { + if (totalTicks != oldTicks) + marks.add(totalTicks); + oldTicks = totalTicks; + } + } + indexMarks = marks.build(); + } + return indexMarks; + } + + private void ensureLastByte() + { + if (bytes.isEmpty()) + appendByte(0x00); + } + + private void flushIndexMarks() + { + indexMarks = null; + } +} diff --git a/java/com/cowlark/fluxengine/data/FluxmapReader.java b/java/com/cowlark/fluxengine/data/FluxmapReader.java new file mode 100644 index 000000000..c4d406eee --- /dev/null +++ b/java/com/cowlark/fluxengine/data/FluxmapReader.java @@ -0,0 +1,288 @@ +package com.cowlark.fluxengine.data; + +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_INDEX; +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_PULSE; +import static com.cowlark.fluxengine.external.FluxEngine.F_EOF; +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.decoders.DecoderProto; + +/** + * A cursor over a Fluxmap's raw bytes. + */ +public class FluxmapReader +{ + private final Fluxmap fluxmap; + private final Bytes bytes; + private final int size; + private final DecoderProto decoder; + private int posBytes; + private int posTicks; + private int posZeroes; + + public FluxmapReader(Fluxmap fluxmap, DecoderProto decoder) + { + this.fluxmap = fluxmap; + bytes = fluxmap.rawBytes(); + size = fluxmap.bytes(); + this.decoder = decoder; + rewind(); + } + + public void rewind() + { + posBytes = 0; + posTicks = 0; + posZeroes = 0; + } + + public boolean eof() + { + return posBytes == size; + } + + public FluxPosition tell() + { + return new FluxPosition(posBytes, posTicks, posZeroes); + } + + public void seek(FluxPosition pos) + { + posBytes = pos.bytes(); + posTicks = pos.ticks(); + posZeroes = pos.zeroes(); + } + + public double getDurationNs() + { + return fluxmap.ticks() * NS_PER_TICK; + } + + public int getCurrentEvent() + { + if (eof()) + return F_EOF; + return bytes.getByte(posBytes) & 0xc0; + } + + public Event getNextEvent() + { + long ticks = 0; + while (!eof()) + { + int b = bytes.getByte(posBytes++) & 0xff; + ticks += b & 0x3f; + if (b == 0 || (b & (F_BIT_PULSE | F_BIT_INDEX)) != 0) + { + posTicks += (int) ticks; + return new Event(b & 0xc0, ticks); + } + } + posTicks += (int) ticks; + return new Event(F_EOF, ticks); + } + + public void skipToEvent(int event) + { + findEvent(event); + } + + public EventResult findEvent(int event) + { + long ticks = 0; + while (!eof()) + { + Event e = getNextEvent(); + ticks += e.ticks(); + if (e.event() == F_EOF) + return new EventResult(false, ticks); + if (event == e.event() || (event & e.event()) != 0) + return new EventResult(true, ticks); + } + return new EventResult(false, ticks); + } + + public long readInterval(long clockTicks) + { + long thresholdTicks = (long) (clockTicks * decoder.getPulseDebounceThreshold()); + long ticks = 0; + while (ticks <= thresholdTicks) + { + EventResult r = findEvent(F_BIT_PULSE); + if (!r.found()) + break; + ticks += r.ticks(); + } + return ticks; + } + + public void seek(long ticks) + { + if (ticks < posTicks) + { + posTicks = 0; + posBytes = 0; + } + while (!eof() && posTicks < ticks) + getNextEvent(); + posZeroes = 0; + } + + public void seekToByte(int b) + { + if (b < posBytes) + { + posTicks = 0; + posBytes = 0; + } + while (!eof() && posBytes < b) + getNextEvent(); + posZeroes = 0; + } + + public void seekToIndexMark() + { + skipToEvent(F_BIT_INDEX); + posZeroes = 0; + } + + /* Ported from lib/data/fluxmapreader.cc FluxmapReader::seekToPattern. */ + + public double seekToPattern(FluxMatcher pattern) + { + return seekToPattern(pattern, null); + } + + public double seekToPattern(FluxMatcher pattern, FluxMatcher[] matching) + { + int intervalCount = pattern.intervals(); + long[] candidates = new long[intervalCount + 1]; + FluxPosition[] positions = new FluxPosition[intervalCount + 1]; + + for (int i = 0; i <= intervalCount; i++) + { + positions[i] = tell(); + candidates[i] = 0; + } + + double clockDecodeThreshold = decoder.getBitErrorThreshold(); + while (!eof()) + { + FluxMatch match = new FluxMatch(); + if (pattern.matches(candidates, intervalCount + 1, clockDecodeThreshold, match)) + { + seek(positions[intervalCount - match.intervals]); + posZeroes = match.zeroes; + if (matching != null) + matching[0] = match.matcher; + double detectedClock = match.clock * NS_PER_TICK; + if (detectedClock > decoder.getMinimumClockUs() * 1000) + return match.clock * NS_PER_TICK; + } + + for (int i = 0; i < intervalCount; i++) + { + positions[i] = positions[i + 1]; + candidates[i] = candidates[i + 1]; + } + EventResult r = findEvent(F_BIT_PULSE); + candidates[intervalCount] = r.ticks(); + positions[intervalCount] = tell(); + } + + if (matching != null) + matching[0] = null; + return 0; + } + + public ClockData guessClock() + { + return guessClock(0.01, 0.05); + } + + public ClockData guessClock(double noiseFloorFactor, double signalLevelFactor) + { + ClockData data = new ClockData(); + while (!eof()) + { + long intervalTicks = findEvent(F_BIT_PULSE).ticks(); + if (intervalTicks > 0xff) + continue; + data.buckets[(int) intervalTicks]++; + } + + int max = Integer.MIN_VALUE; + int min = Integer.MAX_VALUE; + for (int b : data.buckets) + { + max = Math.max(max, b); + min = Math.min(min, b); + } + data.noiseFloor = (int) (min + (max - min) * noiseFloorFactor); + data.signalLevel = (int) (min + (max - min) * signalLevelFactor); + + int pulseindexTicks = 0; + while (pulseindexTicks < 256) + { + if (data.buckets[pulseindexTicks] > data.signalLevel) + break; + pulseindexTicks++; + } + if (pulseindexTicks == 256) + return data; + + int peakloTicks = pulseindexTicks; + while (peakloTicks > 0) + { + if (data.buckets[peakloTicks] < data.noiseFloor) + break; + peakloTicks--; + } + + int peakhiTicks = pulseindexTicks; + while (peakhiTicks < 255) + { + if (data.buckets[peakhiTicks] < data.noiseFloor) + break; + peakhiTicks++; + } + + int totalSize = 0; + for (int i = peakloTicks; i < peakhiTicks; i++) + totalSize += data.buckets[i]; + + int count = 0; + int medianTicks = peakloTicks; + while (medianTicks < peakhiTicks) + { + count += data.buckets[medianTicks]; + if (count > totalSize / 2) + break; + medianTicks++; + } + + data.peakStartTicks = peakloTicks; + data.peakEndTicks = peakhiTicks; + data.medianTicks = medianTicks; + return data; + } + + public record Event(int event, long ticks) + { + } + + public record EventResult(boolean found, long ticks) + { + } + + public static class ClockData + { + public long medianTicks; + public int noiseFloor; + public int signalLevel; + public long peakStartTicks; + public long peakEndTicks; + public int[] buckets = new int[256]; + } +} diff --git a/java/com/cowlark/fluxengine/data/Formats.java b/java/com/cowlark/fluxengine/data/Formats.java new file mode 100644 index 000000000..bd0559027 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/Formats.java @@ -0,0 +1,99 @@ +package com.cowlark.fluxengine.data; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.google.common.collect.ImmutableList; +import com.google.protobuf.InvalidProtocolBufferException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The built-in format configurations, loaded on demand from the classpath + * resources generated from the textpb files in src/formats, ported from the + * C++ `formats` map in lib/config. + */ +public final class Formats +{ + private static final String RESOURCE_DIR = "formats"; + private static final String RESOURCE_SUFFIX = ".bin"; + private static final String NAMES_RESOURCE = RESOURCE_DIR + "/names.txt"; + + private static final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + private Formats() + { + } + + /* Returns the config with the given name, loading it on demand, or null + * if it doesn't exist. */ + public static ConfigProto get(String name) + { + ConfigProto config = cache.get(name); + if (config == null) + { + config = load(name); + if (config != null) + cache.putIfAbsent(name, config); + } + return config; + } + + /* Returns the names of all the available configs. */ + public static ImmutableList all() + { + return ImmutableList.copyOf(scanNames()); + } + + private static ConfigProto load(String name) + { + String resource = "/" + RESOURCE_DIR + "/" + name + RESOURCE_SUFFIX; + byte[] data; + try (InputStream stream = Formats.class.getResourceAsStream(resource)) + { + if (stream == null) + return null; + data = stream.readAllBytes(); + } catch (IOException e) + { + throw new FluxEngineException("cannot read format resource " + resource + ": " + e); + } + + try + { + return ConfigProto.parseFrom(data); + } catch (InvalidProtocolBufferException e) + { + throw new FluxEngineException("invalid format data in " + resource + ": " + e); + } + } + + /* Returns the list of format names from the generated names index. */ + private static List scanNames() + { + String contents; + try (InputStream stream = Formats.class.getResourceAsStream("/" + NAMES_RESOURCE)) + { + if (stream == null) + throw new FluxEngineException("format resource not found: " + NAMES_RESOURCE); + contents = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) + { + throw new FluxEngineException( + "cannot read format resource " + NAMES_RESOURCE + ": " + e); + } + + List names = new ArrayList<>(); + for (String line : contents.split("\n")) + { + if (!line.isEmpty()) + names.add(line); + } + Collections.sort(names); + return names; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/Geometry.java b/java/com/cowlark/fluxengine/data/Geometry.java new file mode 100644 index 000000000..b74270769 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/Geometry.java @@ -0,0 +1,15 @@ +package com.cowlark.fluxengine.data; + +/** + * The geometry of a disk image, ported from lib/data/image.h. + */ +public class Geometry +{ + public int numCylinders = 0; + public int numHeads = 0; + public int firstSector = Integer.MAX_VALUE; + public int numSectors = 0; + public int sectorSize = 0; + public boolean irregular = false; + public int totalBytes = 0; +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/Image.java b/java/com/cowlark/fluxengine/data/Image.java new file mode 100644 index 000000000..5077a8cc7 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/Image.java @@ -0,0 +1,168 @@ +package com.cowlark.fluxengine.data; + +import com.cowlark.fluxengine.core.Bytes; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A disk image, a collection of sectors indexed by logical location, ported + * from lib/data/image.h. + */ +public class Image implements Iterable +{ + private final Map sectors = new LinkedHashMap<>(); + private Geometry geometry = new Geometry(); + + public Image() + { + } + + public Image(Collection sectors) + { + for (Sector sector : sectors) + this.sectors.put(sector.location, sector); + calculateSize(); + } + + public void calculateSize() + { + geometry = new Geometry(); + int maxSector = 0; + for (Map.Entry entry : sectors.entrySet()) + { + Sector sector = entry.getValue(); + if (sector != null) + { + geometry.numCylinders = + Math.max(geometry.numCylinders, sector.location.logicalCylinder() + 1); + geometry.numHeads = Math.max(geometry.numHeads, sector.location.logicalHead() + 1); + geometry.firstSector = + Math.min(geometry.firstSector, sector.location.logicalSector()); + maxSector = Math.max(maxSector, sector.location.logicalSector()); + geometry.sectorSize = Math.max(geometry.sectorSize, sector.data.size()); + geometry.totalBytes += geometry.sectorSize; + } + } + geometry.numSectors = maxSector - geometry.firstSector + 1; + } + + public void clear() + { + sectors.clear(); + geometry = new Geometry(); + } + + public boolean empty() + { + return sectors.isEmpty(); + } + + public boolean contains(LogicalLocation location) + { + return sectors.containsKey(location); + } + + public boolean contains(int cylinder, int head, int sector) + { + return contains(new LogicalLocation(cylinder, head, sector)); + } + + public Sector get(LogicalLocation location) + { + return sectors.get(location); + } + + public Sector get(int cylinder, int head, int sector) + { + return get(new LogicalLocation(cylinder, head, sector)); + } + + public Sector put(LogicalLocation location) + { + Sector sector = new Sector(location); + sectors.put(location, sector); + return sector; + } + + public Sector put(int cylinder, int head, int sector) + { + return put(new LogicalLocation(cylinder, head, sector)); + } + + public void erase(LogicalLocation location) + { + sectors.remove(location); + } + + public void erase(int cylinder, int head, int sector) + { + erase(new LogicalLocation(cylinder, head, sector)); + } + + public void addMissingSectors(DiskLayout layout, boolean populated) + { + for (LogicalLocation location : layout.logicalSectorLocationsInFilesystemOrder) + { + if (!sectors.containsKey(location)) + { + LogicalTrackLayout ltl = + layout.layoutByLogicalLocation.get(location.trackLocation()); + Sector sector = new Sector(location); + + if (populated) + sector.data = new Bytes(ltl.sectorSize); + else + sector.status = Sector.Status.MISSING; + + sectors.put(location, sector); + } + } + calculateSize(); + } + + public void populateSectorPhysicalLocationsFromLogicalLocations(DiskLayout diskLayout) + { + Image tempImage = new Image(); + for (Sector sector : this) + { + LogicalTrackLayout ltl = + diskLayout.layoutByLogicalLocation.get(sector.location.trackLocation()); + Sector newSector = tempImage.put( + sector.location.logicalCylinder(), + sector.location.logicalHead(), + sector.location.logicalSector()); + newSector.location = sector.location; + newSector.status = sector.status; + newSector.position = sector.position; + newSector.clockNs = sector.clockNs; + newSector.headerStartTimeNs = sector.headerStartTimeNs; + newSector.headerEndTimeNs = sector.headerEndTimeNs; + newSector.dataStartTimeNs = sector.dataStartTimeNs; + newSector.dataEndTimeNs = sector.dataEndTimeNs; + newSector.data = sector.data; + newSector.records = sector.records; + newSector.physicalLocation = new CylinderHead(ltl.physicalCylinder, ltl.physicalHead); + } + + for (Sector sector : tempImage) + sectors.put(sector.location, sector); + } + + public Geometry getGeometry() + { + return geometry; + } + + public void setGeometry(Geometry geometry) + { + this.geometry = geometry; + } + + @Override + public Iterator iterator() + { + return sectors.values().iterator(); + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/Kryoflux.java b/java/com/cowlark/fluxengine/data/Kryoflux.java new file mode 100644 index 000000000..f44da538b --- /dev/null +++ b/java/com/cowlark/fluxengine/data/Kryoflux.java @@ -0,0 +1,249 @@ +package com.cowlark.fluxengine.data; + +import static com.cowlark.fluxengine.external.FluxEngine.TICK_FREQUENCY; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.TreeSet; + +/** + * Reader for raw Kryoflux stream files, ported from lib/external/kryoflux.cc. + * This file lives in the data package rather than the external one because it + * constructs Fluxmap objects. + */ +public final class Kryoflux +{ + private static final double MCLK_HZ = ((18432000.0 * 73.0) / 14.0) / 2.0; + private static final double SCLK_HZ = MCLK_HZ / 2; + private static final double TICKS_PER_SCLK = TICK_FREQUENCY / SCLK_HZ; + private static final double ICLK_HZ = MCLK_HZ / 16; + + private Kryoflux() + { + } + + public static Fluxmap readStream(String dir, int track, int side) + { + String suffix = String.format("%02d.%d.raw", track, side); + + File directory = new File(dir); + if (!directory.isDirectory()) + error("cannot access path '%s'", dir); + + String filename = null; + File[] files = directory.listFiles(); + if (files != null) + { + for (File file : files) + { + if (hasSuffix(file.getName(), suffix)) + { + if (filename != null) + error("data is ambiguous --- multiple files end in %s", suffix); + filename = dir + File.separator + file.getName(); + } + } + } + + if (filename == null) + error("failed to find track %d side %d in %s", track, side, dir); + + return readStream(filename); + } + + public static Fluxmap readStream(String filename) + { + try + { + return readStream(new Bytes(Files.readAllBytes(Path.of(filename)))); + } catch (IOException e) + { + throw new FluxEngineException(String.format( + "cannot open input file '%s': %s", + filename, + e.getMessage())); + } + } + + public static Fluxmap readStream(Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + + /* Pass 1: scan the stream looking for index marks. */ + + TreeSet indexmarks = new TreeSet<>(); + br.seek(0); + pass1: + while (!br.eof()) + { + int b = br.read8(); + int len = 0; + switch (b) + { + case 0x0d: /* OOB block */ + { + int blocktype = br.read8(); + len = br.readLe16(); + if (br.eof()) + break pass1; + + if (blocktype == 0x02) + { + /* index data, sent asynchronously */ + int streampos = br.readLe32(); + indexmarks.add(streampos); + len -= 4; + } + break; + } + + default: + { + if ((b >= 0x00) && (b <= 0x07)) + len = 1; /* Flux2: double byte value */ + else if (b == 0x08) + len = 0; /* Nop1: do nothing */ + else if (b == 0x09) + len = 1; /* Nop2: skip one byte */ + else if (b == 0x0a) + len = 2; /* Nop3: skip two bytes */ + else if (b == 0x0b) + len = 0; /* Ovl16: the next block is 0x10000 sclks + * longer than normal. */ + else if (b == 0x0c) + len = 2; /* Flux3: triple byte value */ + else if ((b >= 0x0e) && (b <= 0xff)) + len = 0; /* Flux1: single byte value */ + else + error( + "unknown stream block byte 0x%01x at 0x%08x", + b, + (long) br.pos() - 1); + } + } + br.skip(len); + } + + /* Pass 2: actually read the data. */ + + Fluxmap fluxmap = new Fluxmap(); + long extrasclks = 0; + int streamdelta = 0; + br.seek(0); + pass2: + while (!br.eof()) + { + int b = br.read8(); + switch (b) + { + case 0x0d: /* OOB block */ + { + int blocktype = br.read8(); + int blocklen = br.readLe16(); + if (br.eof()) + break pass2; + + switch (blocktype) + { + case 0x01: /* streaminfo */ + { + int blockpos = br.pos() - 3; + streamdelta = blockpos - br.readLe32(); + blocklen -= 4; + break; + } + } + + br.skip(blocklen); + break; + } + + default: + { + if ((b >= 0x00) && (b <= 0x07)) + { + /* Flux2: double byte value */ + b = (b << 8) | br.read8(); + writeFlux(fluxmap, indexmarks, br, streamdelta, extrasclks + b); + extrasclks = 0; + } else if (b == 0x08) + { + /* Nop1: do nothing */ + } else if (b == 0x09) + { + /* Nop2: skip one byte */ + br.skip(1); + } else if (b == 0x0a) + { + /* Nop3: skip two bytes */ + br.skip(2); + } else if (b == 0x0b) + { + /* Ovl16: the next flux value is 0x10000 sclks longer + * than normal. */ + extrasclks += 0x10000; + } else if (b == 0x0c) + { + /* Flux3: triple byte value */ + int ticks = br.readBe16(); /* yes, really big-endian */ + writeFlux(fluxmap, indexmarks, br, streamdelta, extrasclks + ticks); + extrasclks = 0; + } else if ((b >= 0x0e) && (b <= 0xff)) + { + /* Flux1: single byte value */ + writeFlux(fluxmap, indexmarks, br, streamdelta, extrasclks + b); + extrasclks = 0; + } else + error( + "unknown stream block byte 0x%02x at 0x%08x", + b, + (long) br.pos() - 1); + } + } + } + + if (!br.eof()) + error("I/O error reading stream"); + return fluxmap; + } + + private static void writeFlux(Fluxmap fluxmap, + TreeSet indexmarks, + ByteReader br, + int streamdelta, + long sclk) + { + if (!indexmarks.isEmpty()) + { + Integer nextindex = indexmarks.first(); + int nextindexpos = nextindex + streamdelta; + if (br.pos() >= nextindexpos) + { + fluxmap.appendIndex(); + indexmarks.remove(nextindex); + } + } + + int ticks = (int) ((double) sclk * TICKS_PER_SCLK); + fluxmap.appendInterval(ticks); + fluxmap.appendPulse(); + } + + private static boolean hasSuffix(String haystack, String needle) + { + if (needle.length() > haystack.length()) + return false; + + return haystack.substring(haystack.length() - needle.length()).equals(needle); + } + + private static void error(String format, Object... args) + { + throw new FluxEngineException(String.format(format, args)); + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/Locations.java b/java/com/cowlark/fluxengine/data/Locations.java new file mode 100644 index 000000000..4a2a1b4bd --- /dev/null +++ b/java/com/cowlark/fluxengine/data/Locations.java @@ -0,0 +1,162 @@ +package com.cowlark.fluxengine.data; + +import com.cowlark.fluxengine.core.FluxEngineException; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Parsing of cylinder/head location descriptor strings, ported from + * lib/data/locations.cc. + */ +public class Locations +{ + private Locations() + { + } + + public static ImmutableList parseCylinderHeadsString(String s) + { + List result = new ArrayList<>(); + Parser parser = new Parser(s); + parser.skipSpaces(); + while (!parser.eof()) + { + result.addAll(parser.parseCh()); + parser.skipSpaces(); + } + + if (result.isEmpty()) + throw new FluxEngineException("track descriptor parse error: no locations specified"); + + Collections.sort(result); + return ImmutableList.copyOf(result); + } + + public static String convertCylinderHeadsToString(List chs) + { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (CylinderHead ch : chs) + { + if (!first) + sb.append(' '); + sb.append(String.format("c%dh%d", ch.cylinder(), ch.head())); + first = false; + } + return sb.toString(); + } + + private static final class Parser + { + private final String s; + private int pos; + + Parser(String s) + { + this.s = s; + } + + boolean eof() + { + return pos >= s.length(); + } + + void skipSpaces() + { + while (pos < s.length() && s.charAt(pos) == ' ') + pos++; + } + + List parseCh() + { + expect('c'); + List cylinders = parseMembers(); + expect('h'); + List heads = parseMembers(); + + List result = new ArrayList<>(); + for (int c : cylinders) + { + for (int h : heads) + result.add(new CylinderHead(c, h)); + } + return result; + } + + List parseMembers() + { + List result = new ArrayList<>(); + result.addAll(parseMember()); + while (peek() == ',') + { + pos++; + result.addAll(parseMember()); + } + return result; + } + + List parseMember() + { + int start = parseUnsigned(); + int end = start; + int step = 1; + if (peek() == '-') + { + pos++; + end = parseUnsigned(); + } + if (peek() == 'x') + { + pos++; + step = parseUnsigned(); + } + + if (start < 0) + throw error("range start " + start + " must be at least 0"); + if (end < start) + throw error("range end " + end + " must be at least the start"); + if (step < 1) + throw error("range step " + step + " must be at least one"); + + List result = new ArrayList<>(); + for (int i = start; i <= end; i += step) + result.add(i); + return result; + } + + int parseUnsigned() + { + int start = pos; + while (pos < s.length() && Character.isDigit(s.charAt(pos))) + pos++; + if (pos == start) + throw error("expected a number at '" + pos + "'"); + try + { + return Integer.parseInt(s.substring(start, pos)); + } catch (NumberFormatException e) + { + throw error("number out of range at '" + start + "'"); + } + } + + char peek() + { + return pos < s.length() ? s.charAt(pos) : '\0'; + } + + void expect(char c) + { + if (eof() || s.charAt(pos) != c) + throw error("expected '" + c + "' at '" + pos + "'"); + pos++; + } + + FluxEngineException error(String message) + { + return new FluxEngineException("track descriptor parse error: " + message); + } + } +} diff --git a/java/com/cowlark/fluxengine/data/LogicalLocation.java b/java/com/cowlark/fluxengine/data/LogicalLocation.java new file mode 100644 index 000000000..2275bbe1f --- /dev/null +++ b/java/com/cowlark/fluxengine/data/LogicalLocation.java @@ -0,0 +1,18 @@ +package com.cowlark.fluxengine.data; + +/** + * A logical sector location, ported from lib/data/locations.h. + */ +public record LogicalLocation(int logicalCylinder, int logicalHead, int logicalSector) +{ + public CylinderHead trackLocation() + { + return new CylinderHead(logicalCylinder, logicalHead); + } + + @Override + public String toString() + { + return String.format("c%dh%ds%d", logicalCylinder, logicalHead, logicalSector); + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/LogicalTrackLayout.java b/java/com/cowlark/fluxengine/data/LogicalTrackLayout.java new file mode 100644 index 000000000..9b5978b81 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/LogicalTrackLayout.java @@ -0,0 +1,73 @@ +package com.cowlark.fluxengine.data; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +/** + * The layout of a single logical track, ported from lib/data/layout.h. + */ +public class LogicalTrackLayout +{ + /* Physical cylinder of the first element of the group. */ + public final int physicalCylinder; + + /* Physical head of the first element of the group. */ + public final int physicalHead; + + /* Size of this group. */ + public final int groupSize; + + /* Logical cylinder of this track. */ + public final int logicalCylinder; + + /* Logical side of this track. */ + public final int logicalHead; + + /* The number of sectors in this track. */ + public final int numSectors; + + /* Number of bytes in a sector. */ + public final int sectorSize; + + /* Sector IDs in sector ID order. */ + public final ImmutableList naturalSectorOrder; + + /* Sector IDs in disk order. */ + public final ImmutableList diskSectorOrder; + + /* Sector IDs in filesystem order. */ + public final ImmutableList filesystemSectorOrder; + + /* Mapping of sector ID to filesystem ordering. */ + public final ImmutableMap sectorIdToFilesystemOrdering; + + /* Mapping of sector ID to natural ordering. */ + public final ImmutableMap sectorIdToNaturalOrdering; + + public LogicalTrackLayout(int physicalCylinder, + int physicalHead, + int groupSize, + int logicalCylinder, + int logicalHead, + int numSectors, + int sectorSize, + ImmutableList naturalSectorOrder, + ImmutableList diskSectorOrder, + ImmutableList filesystemSectorOrder, + ImmutableMap sectorIdToFilesystemOrdering, + ImmutableMap sectorIdToNaturalOrdering) + { + this.physicalCylinder = physicalCylinder; + this.physicalHead = physicalHead; + this.groupSize = groupSize; + this.logicalCylinder = logicalCylinder; + this.logicalHead = logicalHead; + this.numSectors = numSectors; + this.sectorSize = sectorSize; + this.naturalSectorOrder = naturalSectorOrder; + this.diskSectorOrder = diskSectorOrder; + this.filesystemSectorOrder = filesystemSectorOrder; + this.sectorIdToFilesystemOrdering = sectorIdToFilesystemOrdering; + this.sectorIdToNaturalOrdering = sectorIdToNaturalOrdering; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/PhysicalTrackLayout.java b/java/com/cowlark/fluxengine/data/PhysicalTrackLayout.java new file mode 100644 index 000000000..975bd0ce2 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/PhysicalTrackLayout.java @@ -0,0 +1,30 @@ +package com.cowlark.fluxengine.data; + +/** + * The layout of a single physical track, ported from lib/data/layout.h. + */ +public class PhysicalTrackLayout +{ + /* Physical location of this track. */ + public final int physicalCylinder; + + /* Physical side of this track. */ + public final int physicalHead; + + /* Which member of the group this is. */ + public final int groupOffset; + + /* The logical track that this track is part of. */ + public final LogicalTrackLayout logicalTrackLayout; + + public PhysicalTrackLayout(int physicalCylinder, + int physicalHead, + int groupOffset, + LogicalTrackLayout logicalTrackLayout) + { + this.physicalCylinder = physicalCylinder; + this.physicalHead = physicalHead; + this.groupOffset = groupOffset; + this.logicalTrackLayout = logicalTrackLayout; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/Record.java b/java/com/cowlark/fluxengine/data/Record.java new file mode 100644 index 000000000..e00a7f238 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/Record.java @@ -0,0 +1,19 @@ +package com.cowlark.fluxengine.data; + +import com.cowlark.fluxengine.core.Bytes; + +/** + * A single record on a track, ported from lib/data/disk.h. + */ +public class Record +{ + public double clockNs = 0.0; + public double startTimeNs = 0.0; + public double endTimeNs = 0.0; + public int position = 0; + public Bytes rawData = new Bytes(); + + public Record() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/Sector.java b/java/com/cowlark/fluxengine/data/Sector.java new file mode 100644 index 000000000..cc3a7b323 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/Sector.java @@ -0,0 +1,102 @@ +package com.cowlark.fluxengine.data; + +import com.cowlark.fluxengine.core.Bytes; +import java.util.ArrayList; +import java.util.List; + +/** + * A sector, ported from lib/data/sector.h. + */ +public class Sector +{ + public LogicalLocation location; + + /* The logical location of this sector. */ + public Status status = Status.INTERNAL_ERROR; + public int position = 0; + public double clockNs = 0.0; + public double headerStartTimeNs = 0.0; + public double headerEndTimeNs = 0.0; + public double dataStartTimeNs = 0.0; + public double dataEndTimeNs = 0.0; + public CylinderHead physicalLocation = null; + public Bytes data = new Bytes(); + public List records = new ArrayList<>(); + + public Sector(LogicalLocation location) + { + this.location = location; + } + + public Sector(Sector other) + { + this.location = other.location; + this.status = other.status; + this.position = other.position; + this.clockNs = other.clockNs; + this.headerStartTimeNs = other.headerStartTimeNs; + this.headerEndTimeNs = other.headerEndTimeNs; + this.dataStartTimeNs = other.dataStartTimeNs; + this.dataEndTimeNs = other.dataEndTimeNs; + this.physicalLocation = other.physicalLocation; + this.data = other.data; + this.records = other.records; + } + + public static String statusToString(Status status) + { + switch (status) + { + case OK: + return "OK"; + case BAD_CHECKSUM: + return "bad checksum"; + case MISSING: + return "sector not found"; + case DATA_MISSING: + return "present but no data found"; + case CONFLICT: + return "conflicting data"; + default: + return String.format("unknown error %d", status.ordinal()); + } + } + + public static String statusToChar(Status status) + { + switch (status) + { + case OK: + return ""; + case MISSING: + return "?"; + case BAD_CHECKSUM: + case DATA_MISSING: + return "!"; + case CONFLICT: + return "*"; + default: + return "?"; + } + } + + public static Status stringToStatus(String value) + { + if (value.equals("OK")) + return Status.OK; + if (value.equals("bad checksum")) + return Status.BAD_CHECKSUM; + if (value.equals("sector not found") || value.equals("MISSING")) + return Status.MISSING; + if (value.equals("present but no data found")) + return Status.DATA_MISSING; + if (value.equals("conflicting data")) + return Status.CONFLICT; + return Status.INTERNAL_ERROR; + } + + public enum Status + { + OK, BAD_CHECKSUM, MISSING, DATA_MISSING, CONFLICT, INTERNAL_ERROR + } +} diff --git a/java/com/cowlark/fluxengine/data/Track.java b/java/com/cowlark/fluxengine/data/Track.java new file mode 100644 index 000000000..cd2cc26db --- /dev/null +++ b/java/com/cowlark/fluxengine/data/Track.java @@ -0,0 +1,17 @@ +package com.cowlark.fluxengine.data; + +import java.util.ArrayList; +import java.util.List; + +/** + * A decoded track, ported from lib/data/disk.h. + */ +public class Track +{ + public LogicalTrackLayout ltl; + public PhysicalTrackLayout ptl; + public Fluxmap fluxmap; + public List records = new ArrayList<>(); + public List allSectors = new ArrayList<>(); + public List normalisedSectors = new ArrayList<>(); +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/data/TrackInfo.java b/java/com/cowlark/fluxengine/data/TrackInfo.java new file mode 100644 index 000000000..cfe2625b8 --- /dev/null +++ b/java/com/cowlark/fluxengine/data/TrackInfo.java @@ -0,0 +1,83 @@ +package com.cowlark.fluxengine.data; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import lombok.Builder; + +/** + * Summary information about a track, ported from lib/data/layout.h. + */ +@Builder(setterPrefix = "set") +public class TrackInfo +{ + public final int numCylinders; + public final int numHeads; + + /* The number of sectors in this track. */ + public final int numSectors; + + /* Physical location of this track. */ + public final int physicalCylinder; + + /* Physical side of this track. */ + public final int physicalHead; + + /* Logical location of this track. */ + public final int logicalCylinder; + + /* Logical side of this track. */ + public final int logicalHead; + + /* The number of physical tracks which need to be written for one logical + * track. */ + public final int groupSize; + + /* Number of bytes in a sector. */ + public final int sectorSize; + + /* Sector IDs in sector ID order. */ + public final ImmutableList naturalSectorOrder; + + /* Sector IDs in disk order. */ + public final ImmutableList diskSectorOrder; + + /* Sector IDs in filesystem order. */ + public final ImmutableList filesystemSectorOrder; + + /* Mapping of filesystem order to natural order. */ + public final ImmutableMap filesystemToNaturalSectorMap; + + /* Mapping of natural order to filesystem order. */ + public final ImmutableMap naturalToFilesystemSectorMap; + + private TrackInfo(int numCylinders, + int numHeads, + int numSectors, + int physicalCylinder, + int physicalHead, + int logicalCylinder, + int logicalHead, + int groupSize, + int sectorSize, + ImmutableList naturalSectorOrder, + ImmutableList diskSectorOrder, + ImmutableList filesystemSectorOrder, + ImmutableMap filesystemToNaturalSectorMap, + ImmutableMap naturalToFilesystemSectorMap) + { + this.numCylinders = numCylinders; + this.numHeads = numHeads; + this.numSectors = numSectors; + this.physicalCylinder = physicalCylinder; + this.physicalHead = physicalHead; + this.logicalCylinder = logicalCylinder; + this.logicalHead = logicalHead; + this.groupSize = groupSize; + this.sectorSize = sectorSize; + this.naturalSectorOrder = naturalSectorOrder; + this.diskSectorOrder = diskSectorOrder; + this.filesystemSectorOrder = filesystemSectorOrder; + this.filesystemToNaturalSectorMap = filesystemToNaturalSectorMap; + this.naturalToFilesystemSectorMap = naturalToFilesystemSectorMap; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/decoders/BUILD.bazel b/java/com/cowlark/fluxengine/decoders/BUILD.bazel new file mode 100644 index 000000000..ca0b32554 --- /dev/null +++ b/java/com/cowlark/fluxengine/decoders/BUILD.bazel @@ -0,0 +1,32 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +java_library( + name = "decoders", + srcs = glob(["*.java"]), + deps = [ + ":decoders_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/external", + ], +) + +proto_library( + name = "decoders_proto", + srcs = ["decoders.proto"], + strip_import_prefix = "/java/", + deps = [ + "//java/com/cowlark/fluxengine/arch:arch_proto", + "//java/com/cowlark/fluxengine/config:common_proto", + "//java/com/cowlark/fluxengine/fluxsink:fluxsink_proto", + ], +) + +java_proto_library( + name = "decoders_java_proto", + deps = [":decoders_proto"], +) diff --git a/java/com/cowlark/fluxengine/decoders/Decoder.java b/java/com/cowlark/fluxengine/decoders/Decoder.java new file mode 100644 index 000000000..241c1fa48 --- /dev/null +++ b/java/com/cowlark/fluxengine/decoders/Decoder.java @@ -0,0 +1,238 @@ +package com.cowlark.fluxengine.decoders; + +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_PULSE; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.FluxMatcher; +import com.cowlark.fluxengine.data.FluxPosition; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.FluxmapReader; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.PhysicalTrackLayout; +import com.cowlark.fluxengine.data.Record; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.data.Track; + +/** + * The base class for track decoders, ported from lib/decoders/decoders.{h,cc}. + */ +public abstract class Decoder +{ + protected final DecoderProto config; + protected LogicalTrackLayout ltl; + protected Track trackdata; + protected Sector sector; + protected FluxDecoder decoder; + protected Bits recordBits = new Bits(); + private FluxmapReader fmr; + + public Decoder(DecoderProto config) + { + this.config = config; + } + + public Track decodeToSectors(Fluxmap fluxmap, PhysicalTrackLayout ptl) + { + ltl = ptl.logicalTrackLayout; + + trackdata = new Track(); + trackdata.fluxmap = fluxmap; + trackdata.ptl = ptl; + trackdata.ltl = ptl.logicalTrackLayout; + + FluxmapReader fmrLocal = new FluxmapReader(fluxmap, config); + fmr = fmrLocal; + + newSector(); + beginTrack(); + for (; ; ) + { + newSector(); + + FluxPosition recordStart = fmr.tell(); + sector.clockNs = advanceToNextRecord(); + if (fmr.eof() || sector.clockNs == 0) + break; + + /* Read the sector record. */ + + FluxPosition before = fmr.tell(); + decodeSectorRecord(); + FluxPosition after = fmr.tell(); + pushRecord(before, after); + + if (sector.status != Sector.Status.DATA_MISSING) + { + sector.position = before.bytes(); + sector.dataStartTimeNs = before.getDurationNs(); + sector.dataEndTimeNs = after.getDurationNs(); + } else + { + /* The data is in a separate record. */ + + sector.headerStartTimeNs = before.getDurationNs(); + sector.headerEndTimeNs = after.getDurationNs(); + + sector.clockNs = advanceToNextRecord(); + if (fmr.eof() || sector.clockNs == 0) + break; + + before = fmr.tell(); + decodeDataRecord(); + sector.data = sector.data.slice(0, ltl.sectorSize); + after = fmr.tell(); + + if (sector.status != Sector.Status.DATA_MISSING) + { + sector.position = before.bytes(); + sector.dataStartTimeNs = before.getDurationNs(); + sector.dataEndTimeNs = after.getDurationNs(); + pushRecord(before, after); + } else + { + fmr.skipToEvent(F_BIT_PULSE); + resetFluxDecoder(); + } + } + + if (sector.status != Sector.Status.MISSING) + trackdata.allSectors.add(sector); + } + + return trackdata; + } + + private void newSector() + { + sector = new Sector(new LogicalLocation(0, 0, 0)); + sector.physicalLocation = + new CylinderHead(trackdata.ptl.physicalCylinder, trackdata.ptl.physicalHead); + sector.status = Sector.Status.MISSING; + } + + protected void pushRecord(FluxPosition start, FluxPosition end) + { + Record record = new Record(); + trackdata.records.add(record); + sector.records.add(record); + + record.position = start.bytes(); + record.startTimeNs = start.getDurationNs(); + record.endTimeNs = end.getDurationNs(); + record.clockNs = sector.clockNs; + + record.rawData = recordBits.toBytes(); + recordBits = new Bits(); + } + + protected void resetFluxDecoder() + { + decoder = new FluxDecoder(fmr, sector.clockNs, config); + } + + public double seekToPattern(FluxMatcher pattern) + { + double clockNs = fmr.seekToPattern(pattern); + decoder = new FluxDecoder(fmr, clockNs, config); + return clockNs; + } + + public void seekToIndexMark() + { + fmr.skipToEvent(F_BIT_PULSE); + fmr.seekToIndexMark(); + } + + public Bits readRawBits(int count) + { + Bits bits = decoder.readBits(count); + for (int i = 0; i < bits.size(); i++) + recordBits.add(bits.getBit(i)); + return bits; + } + + public int readRaw8() + { + return readRawBits(8).toBytes().iterator().read8(); + } + + public int readRaw16() + { + return readRawBits(16).toBytes().iterator().readBe16(); + } + + public int readRaw20() + { + Bits bits = new Bits(); + for (int i = 0; i < 4; i++) + bits.add(false); + Bits raw = readRawBits(20); + for (int i = 0; i < raw.size(); i++) + bits.add(raw.getBit(i)); + return bits.toBytes().iterator().readBe24(); + } + + public int readRaw24() + { + return readRawBits(24).toBytes().iterator().readBe24(); + } + + public int readRaw32() + { + return readRawBits(32).toBytes().iterator().readBe32(); + } + + public long readRaw48() + { + return readRawBits(48).toBytes().iterator().readBe48(); + } + + public long readRaw64() + { + return readRawBits(64).toBytes().iterator().readBe64(); + } + + public FluxPosition tell() + { + return fmr.tell(); + } + + public void rewind() + { + fmr.rewind(); + } + + public void seek(FluxPosition pos) + { + fmr.seek(pos); + } + + public boolean eof() + { + return fmr.eof(); + } + + public double getFluxmapDuration() + { + return fmr.getDurationNs(); + } + + protected void beginTrack() + { + } + + protected abstract double advanceToNextRecord(); + + protected abstract void decodeSectorRecord(); + + protected void decodeDataRecord() + { + } + + public enum RecordType + { + SECTOR_RECORD, DATA_RECORD, UNKNOWN_RECORD + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/decoders/FluxDecoder.java b/java/com/cowlark/fluxengine/decoders/FluxDecoder.java new file mode 100644 index 000000000..22bde01cf --- /dev/null +++ b/java/com/cowlark/fluxengine/decoders/FluxDecoder.java @@ -0,0 +1,142 @@ +package com.cowlark.fluxengine.decoders; + +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.data.FluxPosition; +import com.cowlark.fluxengine.data.FluxmapReader; + +/* This is a port of the samdisk code: + * + * https://github.com/simonowen/samdisk/blob/master/src/FluxDecoder.cpp + * + * I'm not actually terribly sure how it works, but it does, and much better + * than my code. + */ +public class FluxDecoder +{ + private final FluxmapReader fmr; + private final double pllPhase; + private final double pllAdjust; + private final double fluxScale; + private final double clockCentreNs; + private final double clockMinNs; + private final double clockMaxNs; + private double clockNs; + private double fluxNs = 0.0; + private int clockedZeroes = 0; + private int goodbits = 0; + private boolean index = false; + private boolean syncLost = false; + private int leadingZeroes; + + public FluxDecoder(FluxmapReader fmr, double bitcellNs, DecoderProto config) + { + this.fmr = fmr; + pllPhase = config.getPllPhase(); + pllAdjust = config.getPllAdjust(); + fluxScale = config.getFluxScale(); + clockNs = bitcellNs; + clockCentreNs = bitcellNs; + clockMinNs = bitcellNs * (1.0 - pllAdjust); + clockMaxNs = bitcellNs * (1.0 + pllAdjust); + leadingZeroes = fmr.tell().zeroes(); + } + + private static double clampClock(double min, double value, double max) + { + if (value > max) + return max; + if (value < min) + return min; + return value; + } + + public boolean readBit() + { + if (leadingZeroes > 0) + { + leadingZeroes--; + return false; + } else if (leadingZeroes == 0) + { + leadingZeroes--; + return true; + } + + while (!fmr.eof() && fluxNs < clockNs / 2.0) + { + fluxNs += nextFlux() * fluxScale; + clockedZeroes = 0; + } + + fluxNs -= clockNs; + if (fluxNs >= clockNs / 2.0) + { + clockedZeroes++; + goodbits++; + return false; + } + + /* PLL adjustment: change the clock frequency according to the phase + * mismatch */ + if (clockedZeroes <= 3) + { + /* In sync: adjust base clock */ + + clockNs += fluxNs * pllAdjust; + } else + { + /* Out of sync: adjust the base clock back towards the centre */ + + clockNs += (clockCentreNs - clockNs) * pllAdjust; + + /* We require 256 good bits before reporting another sync loss + * event. */ + + if (goodbits >= 256) + syncLost = true; + goodbits = 0; + } + + /* Clamp the clock's adjustment range. */ + + clockNs = clampClock(clockMinNs, clockNs, clockMaxNs); + + /* I'm not sure what this does, but the original comment is: + * Authentic PLL: Do not snap the timing window to each flux + * transition */ + + fluxNs *= 1.0 - pllPhase; + + goodbits++; + return true; + } + + public Bits readBits(int count) + { + Bits result = new Bits(); + while (!fmr.eof() && count-- > 0) + result.add(readBit()); + return result; + } + + public Bits readBits(FluxPosition until) + { + Bits result = new Bits(); + while (!fmr.eof() && fmr.tell().bytes() < until.bytes()) + result.add(readBit()); + return result; + } + + public Bits readBits() + { + return readBits(Integer.MAX_VALUE); + } + + private double nextFlux() + { + long ticks = fmr.readInterval((long) (clockCentreNs / NS_PER_TICK)); + return ticks * NS_PER_TICK; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/decoders/decoders.proto b/java/com/cowlark/fluxengine/decoders/decoders.proto new file mode 100644 index 000000000..8cbf79cb2 --- /dev/null +++ b/java/com/cowlark/fluxengine/decoders/decoders.proto @@ -0,0 +1,76 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.decoders"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/arch/agat/agat.proto"; +import "com/cowlark/fluxengine/arch/aeslanier/aeslanier.proto"; +import "com/cowlark/fluxengine/arch/amiga/amiga.proto"; +import "com/cowlark/fluxengine/arch/apple2/apple2.proto"; +import "com/cowlark/fluxengine/arch/brother/brother.proto"; +import "com/cowlark/fluxengine/arch/c64/c64.proto"; +import "com/cowlark/fluxengine/arch/f85/f85.proto"; +import "com/cowlark/fluxengine/arch/fb100/fb100.proto"; +import "com/cowlark/fluxengine/arch/ibm/ibm.proto"; +import "com/cowlark/fluxengine/arch/macintosh/macintosh.proto"; +import "com/cowlark/fluxengine/arch/micropolis/micropolis.proto"; +import "com/cowlark/fluxengine/arch/mx/mx.proto"; +import "com/cowlark/fluxengine/arch/northstar/northstar.proto"; +import "com/cowlark/fluxengine/arch/rolandd20/rolandd20.proto"; +import "com/cowlark/fluxengine/arch/smaky6/smaky6.proto"; +import "com/cowlark/fluxengine/arch/tartu/tartu.proto"; +import "com/cowlark/fluxengine/arch/tids990/tids990.proto"; +import "com/cowlark/fluxengine/arch/victor9k/victor9k.proto"; +import "com/cowlark/fluxengine/arch/zilogmcz/zilogmcz.proto"; +import "com/cowlark/fluxengine/fluxsink/fluxsink.proto"; +import "com/cowlark/fluxengine/config/common.proto"; + +//NEXT: 33 +message DecoderProto { + optional double pulse_debounce_threshold = 1 [default = 0.30, + (help) = "ignore pulses with intervals shorter than this, in fractions of a clock"]; + optional double bit_error_threshold = 2 [default = 0.40, + (help) = "amount of error to tolerate in pulse timing, in fractions of a clock"]; + optional double minimum_clock_us = 4 [default = 0.75, + (help) = "refuse to detect clocks shorter than this, to avoid false positives"]; + + optional double pll_adjust = 25 [default = 0.04]; + optional double pll_phase = 26 [default = 0.60]; + optional double flux_scale = 27 [default = 1.0]; + + oneof format { + AesLanierDecoderProto aeslanier = 7; + AgatDecoderProto agat = 28; + AmigaDecoderProto amiga = 8; + Apple2DecoderProto apple2 = 13; + BrotherDecoderProto brother = 6; + Commodore64DecoderProto c64 = 9; + F85DecoderProto f85 = 10; + Fb100DecoderProto fb100 = 11; + IbmDecoderProto ibm = 5; + MacintoshDecoderProto macintosh = 12; + MicropolisDecoderProto micropolis = 14; + MxDecoderProto mx = 15; + NorthstarDecoderProto northstar = 24; + RolandD20DecoderProto rolandd20 = 31; + Smaky6DecoderProto smaky6 = 30; + TartuDecoderProto tartu = 32; + Tids990DecoderProto tids990 = 16; + Victor9kDecoderProto victor9k = 17; + ZilogMczDecoderProto zilogmcz = 18; + } + + optional FluxSinkProto copy_flux_to = 19 + [(help) = "while decoding, write a copy of the flux here"]; + optional bool dump_records = 20 [default = false, + (help) = "if set, then dump the parsed but undecoded disk records"]; + optional bool dump_sectors = 21 [default = false, + (help) = "if set, then dump the decoded sectors to this file"]; + optional int32 retries = 22 [default = 5, + (help) = "how many times to retry each track in the event of a read failure"]; + optional string write_csv_to = 23 + [(help) = "if set, write a CSV report of the disk state"]; + optional bool skip_unnecessary_tracks = 29 [default = true, + (help) = "don't read tracks if we already have all necessary sectors"]; +} + diff --git a/java/com/cowlark/fluxengine/encoders/BUILD.bazel b/java/com/cowlark/fluxengine/encoders/BUILD.bazel new file mode 100644 index 000000000..66e4fa286 --- /dev/null +++ b/java/com/cowlark/fluxengine/encoders/BUILD.bazel @@ -0,0 +1,29 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "encoders_proto", + srcs = ["encoders.proto"], + strip_import_prefix = "/java/", + deps = ["//java/com/cowlark/fluxengine/arch:arch_proto"], +) + +java_proto_library( + name = "encoders_java_proto", + deps = [":encoders_proto"], +) + +java_library( + name = "encoders", + srcs = glob(["*.java"]), + deps = [ + ":encoders_java_proto", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "@maven//:com_google_guava_guava", + ], +) diff --git a/java/com/cowlark/fluxengine/encoders/Encoder.java b/java/com/cowlark/fluxengine/encoders/Encoder.java new file mode 100644 index 000000000..6aa52c178 --- /dev/null +++ b/java/com/cowlark/fluxengine/encoders/Encoder.java @@ -0,0 +1,68 @@ +package com.cowlark.fluxengine.encoders; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.google.common.collect.ImmutableList; +import java.util.List; + +/** + * A track encoder, ported from lib/encoders/encoders.{h,cc}. + */ +public abstract class Encoder +{ + private final double diskRotationalPeriodNs; + + public Encoder(double diskRotationalPeriodNs) + { + this.diskRotationalPeriodNs = diskRotationalPeriodNs; + } + + public static Encoder create(ConfigProto config) + { + throw new FluxEngineException("encoders are not implemented yet"); + } + + public Sector getSector(CylinderHead ch, Image image, int sectorId) + { + return image.get(ch.cylinder(), ch.head(), sectorId); + } + + public ImmutableList collectSectors(LogicalTrackLayout ltl, Image image) + { + ImmutableList.Builder sectors = ImmutableList.builder(); + + for (int sectorId : ltl.diskSectorOrder) + { + Sector sector = getSector( + new CylinderHead(ltl.logicalCylinder, ltl.logicalHead), + image, + sectorId); + if (sector == null) + throw new FluxEngineException(String.format( + "sector %d.%d.%d is missing from the image", + ltl.logicalCylinder, + ltl.logicalHead, + sectorId)); + sectors.add(sector); + } + + return sectors.build(); + } + + public abstract Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image); + + public double calculatePhysicalClockPeriodNs(double targetClockPeriodNs, + double targetRotationalPeriodNs) + { + if (diskRotationalPeriodNs == 0) + throw new FluxEngineException( + "you must set --drive.rotational_period_ms as it can't be autodetected"); + + return targetClockPeriodNs * (diskRotationalPeriodNs / targetRotationalPeriodNs); + } +} diff --git a/java/com/cowlark/fluxengine/encoders/encoders.proto b/java/com/cowlark/fluxengine/encoders/encoders.proto new file mode 100644 index 000000000..cfdb3de74 --- /dev/null +++ b/java/com/cowlark/fluxengine/encoders/encoders.proto @@ -0,0 +1,36 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.encoders"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/arch/agat/agat.proto"; +import "com/cowlark/fluxengine/arch/amiga/amiga.proto"; +import "com/cowlark/fluxengine/arch/apple2/apple2.proto"; +import "com/cowlark/fluxengine/arch/brother/brother.proto"; +import "com/cowlark/fluxengine/arch/c64/c64.proto"; +import "com/cowlark/fluxengine/arch/ibm/ibm.proto"; +import "com/cowlark/fluxengine/arch/macintosh/macintosh.proto"; +import "com/cowlark/fluxengine/arch/micropolis/micropolis.proto"; +import "com/cowlark/fluxengine/arch/northstar/northstar.proto"; +import "com/cowlark/fluxengine/arch/tartu/tartu.proto"; +import "com/cowlark/fluxengine/arch/tids990/tids990.proto"; +import "com/cowlark/fluxengine/arch/victor9k/victor9k.proto"; + +message EncoderProto +{ + oneof format + { + IbmEncoderProto ibm = 3; + BrotherEncoderProto brother = 4; + AmigaEncoderProto amiga = 5; + MacintoshEncoderProto macintosh = 6; + Tids990EncoderProto tids990 = 7; + Commodore64EncoderProto c64 = 8; + NorthstarEncoderProto northstar = 9; + MicropolisEncoderProto micropolis = 10; + Victor9kEncoderProto victor9k = 11; + Apple2EncoderProto apple2 = 12; + AgatEncoderProto agat = 13; + TartuEncoderProto tartu = 14; + } +} diff --git a/java/com/cowlark/fluxengine/external/A2R.java b/java/com/cowlark/fluxengine/external/A2R.java new file mode 100644 index 000000000..e856d699f --- /dev/null +++ b/java/com/cowlark/fluxengine/external/A2R.java @@ -0,0 +1,35 @@ +package com.cowlark.fluxengine.external; + +/** + * A2R (AppleSauce) format definitions, ported from lib/external/a2r.h. + * + *

The canonical reference for the A2R format is: + * https://applesaucefdc.com/a2r2-reference/ All data is stored little-endian. + * + *

Note: The first chunk begins at byte offset 8, not 12 as given in the + * a2r2 reference version 2.0.1. + */ +public final class A2R +{ + public static final int CHUNK_INFO = 0x4F464E49; + public static final int CHUNK_STRM = 0x4D525453; + public static final int CHUNK_META = 0x4154454D; + + public static final int INFO_CHUNK_VERSION = 1; + + public static final int DISK_525 = 1; + public static final int DISK_35 = 2; + + public static final int TIMING = 1; + public static final int BITS = 2; + public static final int XTIMING = 3; + + public static final int NS_PER_TICK = 125; + + public static final byte[] FILEHEADER = + {'A', '2', 'R', '2', (byte) 0xff, (byte) 0x0a, (byte) 0x0d, (byte) 0x0a}; + + private A2R() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/external/BUILD.bazel b/java/com/cowlark/fluxengine/external/BUILD.bazel new file mode 100644 index 000000000..c54e640dd --- /dev/null +++ b/java/com/cowlark/fluxengine/external/BUILD.bazel @@ -0,0 +1,25 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "fl2_proto", + srcs = ["fl2.proto"], + strip_import_prefix = "/java/", + deps = ["@com_google_protobuf//:descriptor_proto"], +) + +java_proto_library( + name = "fl2_java_proto", + deps = [":fl2_proto"], +) + +java_library( + name = "external", + srcs = glob(["*.java"]), + deps = [ + "//java/com/cowlark/fluxengine/core", + ], +) diff --git a/java/com/cowlark/fluxengine/external/Crc.java b/java/com/cowlark/fluxengine/external/Crc.java new file mode 100644 index 000000000..0034dbb91 --- /dev/null +++ b/java/com/cowlark/fluxengine/external/Crc.java @@ -0,0 +1,95 @@ +package com.cowlark.fluxengine.external; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; + +/** + * CRC helpers, ported from lib/core/crc.{h,cc}. + */ +public final class Crc +{ + public static final int CCITT_POLY = 0x1021; + public static final int MODBUS_POLY = 0x8005; + public static final int MODBUS_POLY_REF = 0xa001; + public static final int BROTHER_POLY = 0x000201; + + private Crc() + { + } + + public static int crc16(int poly, int init, Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + + int crc = init; + while (!br.eof()) + { + crc ^= br.read8() << 8; + for (int i = 0; i < 8; i++) + crc = (crc & 0x8000) != 0 ? ((crc << 1) ^ poly) : (crc << 1); + crc &= 0xffff; + } + + return crc; + } + + public static int crc16(int poly, Bytes bytes) + { + return crc16(poly, 0xffff, bytes); + } + + public static int crc16ref(int poly, int init, Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + + int crc = init; + while (!br.eof()) + { + crc ^= br.read8(); + for (int i = 0; i < 8; i++) + crc = (crc & 0x0001) != 0 ? ((crc >> 1) ^ poly) : (crc >> 1); + } + + return crc; + } + + public static int crc16ref(int poly, Bytes bytes) + { + return crc16ref(poly, 0xffff, bytes); + } + + public static int sumBytes(Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + int sum = 0; + while (!br.eof()) + sum += br.read8(); + return sum; + } + + public static int xorBytes(Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + int result = 0; + while (!br.eof()) + result ^= br.read8(); + return result; + } + + /* Thanks to user202729 on StackOverflow for miraculously reverse + * engineering this. */ + public static int crcbrother(Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + + int crc = br.read8(); + while (!br.eof()) + { + for (int i = 0; i < 8; i++) + crc = (crc & 0x800000) != 0 ? ((crc << 1) ^ BROTHER_POLY) : (crc << 1); + crc ^= br.read8(); + } + + return crc & 0xFFFFFF; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/external/FluxEngine.java b/java/com/cowlark/fluxengine/external/FluxEngine.java new file mode 100644 index 000000000..2e6fd31ed --- /dev/null +++ b/java/com/cowlark/fluxengine/external/FluxEngine.java @@ -0,0 +1,186 @@ +package com.cowlark.fluxengine.external; + +/** + * Wire protocol definitions for the FluxEngine hardware. + */ +public final class FluxEngine +{ + public static final int FLUXENGINE_PROTOCOL_VERSION = 17; + + public static final int FLUXENGINE_VID = 0x1209; + public static final int FLUXENGINE_PID = 0x6e00; + public static final int FLUXENGINE_ID = (FLUXENGINE_VID << 16) | FLUXENGINE_PID; + + /* libusb uses these numbers */ + public static final int FLUXENGINE_DATA_OUT_EP = 0x01; + public static final int FLUXENGINE_DATA_IN_EP = 0x82; + public static final int FLUXENGINE_CMD_OUT_EP = 0x03; + public static final int FLUXENGINE_CMD_IN_EP = 0x84; + + /* the PSoC code uses these, sigh */ + public static final int FLUXENGINE_DATA_OUT_EP_NUM = FLUXENGINE_DATA_OUT_EP & 0x0f; + public static final int FLUXENGINE_DATA_IN_EP_NUM = FLUXENGINE_DATA_IN_EP & 0x0f; + public static final int FLUXENGINE_CMD_OUT_EP_NUM = FLUXENGINE_CMD_OUT_EP & 0x0f; + public static final int FLUXENGINE_CMD_IN_EP_NUM = FLUXENGINE_CMD_IN_EP & 0x0f; + + public static final int SIDE_SIDEA = 0 << 0; + public static final int SIDE_SIDEB = 1 << 0; + + public static final int DRIVE_0 = 0; + public static final int DRIVE_1 = 1; + public static final int DRIVE_DD = 0 << 1; + public static final int DRIVE_HD = 1 << 1; + + public static final int FRAME_SIZE = 64; + public static final int TICK_FREQUENCY = 12000000; + public static final int TICKS_PER_US = TICK_FREQUENCY / 1000000; + public static final int PRECOMPENSATION_THRESHOLD_TICKS = (int) (2.25 * TICKS_PER_US); + public static final int TICKS_PER_MS = TICK_FREQUENCY / 1000; + public static final double NS_PER_TICK = 1000000000.0 / TICK_FREQUENCY; + public static final double US_PER_TICK = 1000000.0 / TICK_FREQUENCY; + public static final double MS_PER_TICK = 1000.0 / TICK_FREQUENCY; + + public static final int F_FRAME_ERROR = 0; + public static final int F_FRAME_DEBUG = 1; + public static final int F_FRAME_GET_VERSION_CMD = 2; + public static final int F_FRAME_GET_VERSION_REPLY = 3; + public static final int F_FRAME_SEEK_CMD = 4; + public static final int F_FRAME_SEEK_REPLY = 5; + public static final int F_FRAME_MEASURE_SPEED_CMD = 6; + public static final int F_FRAME_MEASURE_SPEED_REPLY = 7; + public static final int F_FRAME_BULK_WRITE_TEST_CMD = 8; + public static final int F_FRAME_BULK_WRITE_TEST_REPLY = 9; + public static final int F_FRAME_BULK_READ_TEST_CMD = 10; + public static final int F_FRAME_BULK_READ_TEST_REPLY = 11; + public static final int F_FRAME_READ_CMD = 12; + public static final int F_FRAME_READ_REPLY = 13; + public static final int F_FRAME_WRITE_CMD = 14; + public static final int F_FRAME_WRITE_REPLY = 15; + public static final int F_FRAME_ERASE_CMD = 16; + public static final int F_FRAME_ERASE_REPLY = 17; + public static final int F_FRAME_RECALIBRATE_CMD = 18; + public static final int F_FRAME_RECALIBRATE_REPLY = 19; + public static final int F_FRAME_SET_DRIVE_CMD = 20; + public static final int F_FRAME_SET_DRIVE_REPLY = 21; + public static final int F_FRAME_MEASURE_VOLTAGES_CMD = 22; + public static final int F_FRAME_MEASURE_VOLTAGES_REPLY = 23; + + public static final int F_ERROR_NONE = 0; + public static final int F_ERROR_BAD_COMMAND = 1; + public static final int F_ERROR_UNDERRUN = 2; + public static final int F_ERROR_INVALID_VALUE = 3; + public static final int F_ERROR_INTERNAL = 4; + + public static final int F_INDEX_REAL = 0; + public static final int F_INDEX_300 = 1; + public static final int F_INDEX_360 = 2; + + public static final int F_BIT_PULSE = 0x80; + public static final int F_BIT_INDEX = 0x40; + public static final int F_DESYNC = 0x00; /* obsolete */ + public static final int F_EOF = 0x100; /* synthetic, only produced by library */ + + private FluxEngine() + { + } + + public static class FrameHeader + { + public int type; + public int size; + } + + public static class AnyFrame + { + public FrameHeader f; + } + + public static class ErrorFrame + { + public FrameHeader f; + public int error; + } + + public static class DebugFrame + { + public FrameHeader f; + public byte[] payload = new byte[60]; + } + + public static class VersionFrame + { + public FrameHeader f; + public int version; + } + + public static class SeekFrame + { + public FrameHeader f; + public int track; + } + + public static class MeasureSpeedFrame + { + public FrameHeader f; + public int hardSectorCount; + } + + public static class SpeedFrame + { + public FrameHeader f; + public int periodMs; + } + + public static class ReadFrame + { + public FrameHeader f; + public int side; + public int synced; + public int milliseconds; + public int hardsecThresholdMs; + } + + public static class WriteFrame + { + public FrameHeader f; + public int side; + public long bytesToWrite; + public int hardsecThresholdMs; + } + + public static class EraseFrame + { + public FrameHeader f; + public int side; + public int hardsecThresholdMs; + } + + public static class SetDriveFrame + { + public FrameHeader f; + public int drive; + public int highDensity; + public int indexMode; + } + + public static class Voltages + { + public int logic0Mv; + public int logic1Mv; + } + + public static class VoltagesFrame + { + public FrameHeader f; + public Voltages outputBothOff = new Voltages(); + public Voltages outputDrive0Selected = new Voltages(); + public Voltages outputDrive1Selected = new Voltages(); + public Voltages outputDrive0Running = new Voltages(); + public Voltages outputDrive1Running = new Voltages(); + public Voltages inputBothOff = new Voltages(); + public Voltages inputDrive0Selected = new Voltages(); + public Voltages inputDrive1Selected = new Voltages(); + public Voltages inputDrive0Running = new Voltages(); + public Voltages inputDrive1Running = new Voltages(); + } +} diff --git a/java/com/cowlark/fluxengine/external/FmMfm.java b/java/com/cowlark/fluxengine/external/FmMfm.java new file mode 100644 index 000000000..4aee10782 --- /dev/null +++ b/java/com/cowlark/fluxengine/external/FmMfm.java @@ -0,0 +1,142 @@ +package com.cowlark.fluxengine.external; + +import com.cowlark.fluxengine.core.BitReader; +import com.cowlark.fluxengine.core.BitWriter; +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; + +/** + * FM and MFM encode/decode helpers, ported from lib/decoders/fmmfm.cc. + * The {@code cursor} and {@code lastBit} parameters of the encoding functions + * are carried in single-element arrays to provide the in/out semantics of the + * C++ references. + */ +public final class FmMfm +{ + private FmMfm() + { + } + + /* + * FM is dumb as rocks, consisting on regular clock pulses with data pulses + * in the gaps. 0x00 is: + * + * X-X-X-X-X-X-X-X- + * + * 0xff is: + * + * XXXXXXXXXXXXXXXX + * + * So we just need to extract all the odd bits. + * + * MFM and M2FM are slightly more complicated, where the first bit of each + * pair can be either 0 or 1... but the second bit is always the data bit, + * and at this point we simply don't care what the first bit is, so + * decoding MFM uses just the same code! + */ + public static Bytes decodeFmMfm(Bits bits) + { + Bytes bytes = new Bytes(0); + ByteWriter bw = new ByteWriter(bytes); + + int bitcount = 0; + int fifo = 0; + int i = 0; + while (i < bits.size()) + { + i++; /* skip clock bit */ + if (i >= bits.size()) + break; + fifo = (fifo << 1) | (bits.getBit(i++) ? 1 : 0); + + bitcount++; + if (bitcount == 8) + { + bw.write8(fifo); + bitcount = 0; + } + } + + if (bitcount != 0) + { + fifo <<= 8 - bitcount; + bw.write8(fifo); + } + + return bytes; + } + + public static void encodeFm(Bits bits, Bits.Cursor cursor, Bytes input) + { + if (bits.size() == 0) + return; + int len = bits.size() - 1; + + for (int i = 0; i < input.size(); i++) + { + int b = input.getByte(i) & 0xff; + for (int j = 0; j < 8; j++) + { + boolean bit = (b & 0x80) != 0; + b <<= 1; + + if (cursor.get() >= len) + return; + + bits.set(cursor.get(), true); + cursor.advance(); + bits.set(cursor.get(), bit); + cursor.advance(); + } + } + } + + public static void encodeMfm(Bits bits, Bits.Cursor cursor, Bytes data, boolean[] lastBit) + { + if (bits.size() == 0) + return; + int len = bits.size() - 1; + + for (int i = 0; i < data.size(); i++) + { + int b = data.getByte(i) & 0xff; + for (int j = 0; j < 8; j++) + { + boolean bit = (b & 0x80) != 0; + b <<= 1; + + if (cursor.get() >= len) + return; + + bits.set(cursor.get(), !lastBit[0] && !bit); + cursor.advance(); + bits.set(cursor.get(), bit); + cursor.advance(); + lastBit[0] = bit; + } + } + } + + public static Bytes encodeMfm(Bytes data, boolean[] lastBit) + { + ByteReader br = new ByteReader(data); + BitReader bitr = new BitReader(br); + Bytes out = new Bytes(0); + ByteWriter bw = new ByteWriter(out); + BitWriter bitw = new BitWriter(bw); + + while (bitr.hasNext()) + { + boolean bit = bitr.next(); + + bitw.push(!lastBit[0] && !bit); + bitw.push(bit); + lastBit[0] = bit; + } + + bitw.flush(); + return out; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/external/GreaseweazleUtils.java b/java/com/cowlark/fluxengine/external/GreaseweazleUtils.java new file mode 100644 index 000000000..cdce700ab --- /dev/null +++ b/java/com/cowlark/fluxengine/external/GreaseweazleUtils.java @@ -0,0 +1,199 @@ +package com.cowlark.fluxengine.external; + +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_INDEX; +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_PULSE; +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; + +/** + * Flux stream conversion helpers, ported from lib/external/greaseweazle.cc. + */ +public final class GreaseweazleUtils +{ + public static final int CMD_GET_INFO = 0; + public static final int CMD_SEEK = 2; + public static final int CMD_HEAD = 3; + public static final int CMD_MOTOR = 6; + public static final int CMD_READ_FLUX = 7; + public static final int CMD_WRITE_FLUX = 8; + public static final int CMD_GET_FLUX_STATUS = 9; + public static final int CMD_SELECT = 12; + public static final int CMD_SET_BUS_TYPE = 14; + public static final int CMD_SET_PIN = 15; + public static final int CMD_ERASE_FLUX = 17; + public static final int CMD_SOURCE_BYTES = 18; + public static final int CMD_SINK_BYTES = 19; + + public static final int ACK_OKAY = 0; + public static final int ACK_BAD_COMMAND = 1; + public static final int ACK_NO_INDEX = 2; + public static final int ACK_NO_TRK0 = 3; + public static final int ACK_FLUX_OVERFLOW = 4; + public static final int ACK_FLUX_UNDERFLOW = 5; + public static final int ACK_WRPROT = 6; + public static final int ACK_NO_UNIT = 7; + public static final int ACK_NO_BUS = 8; + public static final int ACK_BAD_UNIT = 9; + public static final int ACK_BAD_PIN = 10; + public static final int ACK_BAD_CYLINDER = 11; + + public static final int GETINFO_FIRMWARE = 0; + + public static final int FLUXOP_INDEX = 1; + public static final int FLUXOP_SPACE = 2; + + public static final int BAUD_NORMAL = 9600; + public static final int BAUD_CLEAR_COMMS = 10000; + + private GreaseweazleUtils() + { + } + + public static Bytes fluxEngineToGreaseweazle(Bytes fldata, double clock) + { + Bytes out = new Bytes(0); + ByteWriter bw = new ByteWriter(out); + ByteReader br = new ByteReader(fldata); + long ticksFl = 0; + long ticksGw = 0; + + while (!br.eof()) + { + int b = br.read8(); + ticksFl += b & 0x3f; + if ((b & F_BIT_PULSE) != 0) + { + long newTicksGw = (long) (ticksFl * NS_PER_TICK / clock); + long delta = newTicksGw - ticksGw; + if (delta < 250) + bw.write8((int) delta); + else + { + long high = (delta - 250) / 255; + if (high < 5) + { + bw.write8((int) (250 + high)); + bw.write8((int) (1 + (delta - 250) % 255)); + } else + { + bw.write8(255); + bw.write8(FLUXOP_SPACE); + write28(bw, delta - 249); + bw.write8(249); + } + } + ticksGw = newTicksGw; + } + } + bw.write8(0); /* end of stream */ + return out; + } + + public static Bytes greaseweazleToFluxEngine(Bytes gwdata, double clock) + { + Bytes out = new Bytes(0); + ByteWriter bw = new ByteWriter(out); + ByteReader br = new ByteReader(gwdata); + long ticksGw = 0; + long lastEventFl = 0; + long indexGw = -1; + + while (!br.eof()) + { + int b = br.read8(); + if (b == 0) + break; + + int event = 0; + if (b == 255) + { + switch (br.read8()) + { + case FLUXOP_INDEX: + indexGw = ticksGw + read28(br); + break; + + case FLUXOP_SPACE: + ticksGw += read28(br); + break; + + default: + throw new FluxEngineException("bad opcode in Greaseweazle stream"); + } + } else + { + if (b < 250) + ticksGw += b; + else + { + long delta = 250 + (b - 250) * 255 + br.read8() - 1; + ticksGw += delta; + } + event = F_BIT_PULSE; + } + + if (event != 0) + { + long indexFl = Math.round(indexGw * clock / NS_PER_TICK); + long ticksFl = Math.round(ticksGw * clock / NS_PER_TICK); + if (indexGw != -1) + { + if (indexFl < ticksFl) + { + long deltaFl = indexFl - lastEventFl; + while (deltaFl > 0x3f) + { + bw.write8(0x3f); + deltaFl -= 0x3f; + } + bw.write8((int) (deltaFl | F_BIT_INDEX)); + lastEventFl = indexFl; + indexGw = -1; + } else if (indexFl == ticksFl) + event |= F_BIT_INDEX; + } + + long deltaFl = ticksFl - lastEventFl; + while (deltaFl > 0x3f) + { + bw.write8(0x3f); + deltaFl -= 0x3f; + } + bw.write8((int) (deltaFl | event)); + lastEventFl = ticksFl; + } + } + + return out; + } + + /* Left-truncates at the first index mark, so the resulting data is aligned + * at the index. */ + public static Bytes stripPartialRotation(Bytes fldata) + { + for (int i = 0; i < fldata.size(); i++) + { + if ((fldata.getByte(i) & F_BIT_INDEX) != 0) + return fldata.slice(i, fldata.size() - i); + } + return fldata; + } + + private static void write28(ByteWriter out, long val) + { + out.write8(1 | (int) (val << 1) & 0xff); + out.write8(1 | (int) (val >> 6) & 0xff); + out.write8(1 | (int) (val >> 13) & 0xff); + out.write8(1 | (int) (val >> 20) & 0xff); + } + + private static long read28(ByteReader in) + { + return (long) ((in.read8() & 0xfe) >> 1) | (long) (in.read8() & 0xfe) << 6 | + (long) (in.read8() & 0xfe) << 13 | (long) (in.read8() & 0xfe) << 20; + } +} diff --git a/java/com/cowlark/fluxengine/external/Scp.java b/java/com/cowlark/fluxengine/external/Scp.java new file mode 100644 index 000000000..eefd1dd79 --- /dev/null +++ b/java/com/cowlark/fluxengine/external/Scp.java @@ -0,0 +1,40 @@ +package com.cowlark.fluxengine.external; + +/** + * Constants and structures for the SCP flux file format, ported from + * lib/external/scp.h. + */ +public final class Scp +{ + public static final int SCP_FLAG_INDEXED = (1 << 0); + public static final int SCP_FLAG_96TPI = (1 << 1); + public static final int SCP_FLAG_360RPM = (1 << 2); + public static final int SCP_FLAG_NORMALIZED = (1 << 3); + public static final int SCP_FLAG_READWRITE = (1 << 4); + public static final int SCP_FLAG_FOOTER = (1 << 5); + + /* Size of the file header, including the 168 track offsets. */ + public static final int SCP_HEADER_SIZE = 16 + 168 * 4; + + /* Size of a track header (the 'TRK' id plus 5 revolution records). */ + public static final int SCP_TRACK_SIZE = 4 + 5 * 12; + + public static int trackno(int strack) + { + return strack >> 1; + } + + public static int headno(int strack) + { + return strack & 1; + } + + public static int strackno(int track, int side) + { + return (track << 1) | side; + } + + private Scp() + { + } +} diff --git a/java/com/cowlark/fluxengine/external/fl2.proto b/java/com/cowlark/fluxengine/external/fl2.proto new file mode 100644 index 000000000..4edb10cbb --- /dev/null +++ b/java/com/cowlark/fluxengine/external/fl2.proto @@ -0,0 +1,52 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.external"; +option java_multiple_files = true; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions +{ + optional bool isflux = 60000 [default = false]; +} + +enum FluxMagic { + MAGIC = 0x466c7578; +} + +enum FluxFileVersion { + VERSION_1 = 1; + VERSION_2 = 2; +} + +message TrackFluxProto { + optional int32 track = 1; + optional int32 head = 2; + repeated bytes flux = 3 [(isflux) = true]; +} + +enum DriveType { + DRIVETYPE_UNKNOWN = 0; + DRIVETYPE_40TRACK = 1; + DRIVETYPE_80TRACK = 2; + DRIVETYPE_APPLE2 = 3; +} + +enum FormatType { + FORMATTYPE_UNKNOWN = 0; + FORMATTYPE_40TRACK = 1; + FORMATTYPE_80TRACK = 2; +} + +// NEXT: 8 +message FluxFileProto { + optional int32 magic = 1; + optional FluxFileVersion version = 2; + repeated TrackFluxProto track = 3; + optional double rotational_period_ms = 4; + optional DriveType drive_type = 6 [default = DRIVETYPE_UNKNOWN]; + optional FormatType format_type = 7 [default = FORMATTYPE_UNKNOWN]; + + reserved 5; +} + diff --git a/java/com/cowlark/fluxengine/fluxengine-gui.properties b/java/com/cowlark/fluxengine/fluxengine-gui.properties new file mode 100644 index 000000000..aef5a7e79 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxengine-gui.properties @@ -0,0 +1 @@ +main-class=com.cowlark.fluxengine.gui.Gui diff --git a/java/com/cowlark/fluxengine/fluxsink/A2RFluxSink.java b/java/com/cowlark/fluxengine/fluxsink/A2RFluxSink.java new file mode 100644 index 000000000..347af1c57 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/A2RFluxSink.java @@ -0,0 +1,233 @@ +package com.cowlark.fluxengine.fluxsink; + +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_INDEX; +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_PULSE; +import static com.cowlark.fluxengine.external.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/A2RFluxSinkFactory.java b/java/com/cowlark/fluxengine/fluxsink/A2RFluxSinkFactory.java new file mode 100644 index 000000000..8e4075c6e --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/A2RFluxSinkFactory.java @@ -0,0 +1,36 @@ +package com.cowlark.fluxengine.fluxsink; + +import com.cowlark.fluxengine.config.ConfigProto; + +/** + * A factory for A2R flux sinks, ported from lib/fluxsink/a2rfluxsink.cc. + */ +public class A2RFluxSinkFactory extends FluxSinkFactory +{ + private final String filename; + private final ConfigProto config; + + public A2RFluxSinkFactory(String filename, ConfigProto config) + { + this.filename = filename; + this.config = config; + } + + @Override + public FluxSink create() + { + return new A2RFluxSink(filename, config); + } + + @Override + public String getPath() + { + return filename; + } + + @Override + public String toString() + { + return "a2r(" + filename + ")"; + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/AuFluxSink.java b/java/com/cowlark/fluxengine/fluxsink/AuFluxSink.java new file mode 100644 index 000000000..43e4edcff --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/AuFluxSink.java @@ -0,0 +1,86 @@ +package com.cowlark.fluxengine.fluxsink; + +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_INDEX; +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_PULSE; +import static com.cowlark.fluxengine.external.FluxEngine.TICK_FREQUENCY; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.Logger; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.FluxmapReader; +import com.cowlark.fluxengine.decoders.DecoderProto; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * A flux sink which writes Sun .au audio files, ported from + * lib/fluxsink/aufluxsink.cc. + */ +public class AuFluxSink extends FluxSink +{ + private final String directory; + private final boolean indexMarkers; + + public AuFluxSink(String directory, boolean indexMarkers) + { + this.directory = directory; + this.indexMarkers = indexMarkers; + } + + @Override + public void addFlux(int track, int head, Fluxmap fluxmap) + { + Logger.logf("Warning: do not play these files, or you will break your " + + "speakers and/or ears!"); + + int totalTicks = fluxmap.ticks() + 2; + int channels = indexMarkers ? 2 : 1; + + try + { + Files.createDirectories(Path.of(directory)); + } catch (IOException e) + { + throw new FluxEngineException("cannot create directory '" + directory + "'"); + } + + Bytes data = new Bytes(totalTicks * channels); + for (int i = 0; i < data.size(); i++) + data.setByte(i, (byte) 0x80); + + FluxmapReader fmr = new FluxmapReader(fluxmap, DecoderProto.getDefaultInstance()); + long timestamp = 0; + while (!fmr.eof()) + { + FluxmapReader.Event event = fmr.getNextEvent(); + if (fmr.eof()) + break; + timestamp += event.ticks(); + + if ((event.event() & F_BIT_PULSE) != 0) + data.setByte((int) timestamp * channels, (byte) 0x7f); + if (indexMarkers && (event.event() & F_BIT_INDEX) != 0) + data.setByte((int) timestamp * channels + 1, (byte) 0x7f); + } + + /* Write header */ + Bytes header = new Bytes(24); + header.writer() + .writeBe32(0x2e736e64) + .writeBe32(24) + .writeBe32(totalTicks * channels) + .writeBe32(2) /* 8-bit PCM */.writeBe32(TICK_FREQUENCY) + .writeBe32(channels); /* channels */ + + String filename = String.format("%s/c%02d.h%01d.au", directory, track, head); + try + { + Files.write(Path.of(filename), header.concat(data).toByteArray()); + } catch (IOException e) + { + throw new FluxEngineException("cannot open output file"); + } + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/AuFluxSinkFactory.java b/java/com/cowlark/fluxengine/fluxsink/AuFluxSinkFactory.java new file mode 100644 index 000000000..fb9c06b87 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/AuFluxSinkFactory.java @@ -0,0 +1,34 @@ +package com.cowlark.fluxengine.fluxsink; + +/** + * A factory for Sun .au flux sinks, ported from lib/fluxsink/aufluxsink.cc. + */ +public class AuFluxSinkFactory extends FluxSinkFactory +{ + private final String directory; + private final boolean indexMarkers; + + public AuFluxSinkFactory(String directory, boolean indexMarkers) + { + this.directory = directory; + this.indexMarkers = indexMarkers; + } + + @Override + public FluxSink create() + { + return new AuFluxSink(directory, indexMarkers); + } + + @Override + public String getPath() + { + return directory; + } + + @Override + public String toString() + { + return "au(" + directory + ")"; + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/BUILD.bazel b/java/com/cowlark/fluxengine/fluxsink/BUILD.bazel new file mode 100644 index 000000000..6e4b21dda --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/BUILD.bazel @@ -0,0 +1,35 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "fluxsink_proto", + srcs = ["fluxsink.proto"], + strip_import_prefix = "/java/", + deps = ["//java/com/cowlark/fluxengine/config:common_proto"], +) + +java_proto_library( + name = "fluxsink_java_proto", + deps = [":fluxsink_proto"], +) + +java_library( + name = "fluxsink", + srcs = glob(["*.java"]), + deps = [ + ":fluxsink_java_proto", + "//java/com/cowlark/fluxengine/config:common_java_proto", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "//java/com/cowlark/fluxengine/external", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "//java/com/cowlark/fluxengine/usb", + "@com_google_protobuf//java/core", + "@maven//:org_apache_commons_commons_lang3", + ], +) diff --git a/java/com/cowlark/fluxengine/fluxsink/Fl2FluxSink.java b/java/com/cowlark/fluxengine/fluxsink/Fl2FluxSink.java new file mode 100644 index 000000000..99c470213 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/Fl2FluxSink.java @@ -0,0 +1,90 @@ +package com.cowlark.fluxengine.fluxsink; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.Logger; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.external.FluxFileProto; +import com.cowlark.fluxengine.external.FluxFileVersion; +import com.cowlark.fluxengine.external.FluxMagic; +import com.cowlark.fluxengine.external.TrackFluxProto; +import com.google.protobuf.ByteString; +import org.apache.commons.lang3.tuple.Pair; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A flux sink which writes an FL2 flux file, ported from + * lib/fluxsink/fl2fluxsink.cc. + */ +public class Fl2FluxSink extends FluxSink +{ + private final String filename; + private final ConfigProto config; + private final Map, List> data = new HashMap<>(); + + public Fl2FluxSink(String filename, ConfigProto config) + { + this.filename = filename; + this.config = config; + + try + { + Path path = Path.of(filename); + Files.write(path, new byte[0]); + Files.delete(path); + } catch (IOException e) + { + throw new FluxEngineException("cannot open output file"); + } + } + + public static void saveFl2File(String filename, FluxFileProto.Builder proto) + { + proto.setMagic(FluxMagic.MAGIC.getNumber()); + proto.setVersion(FluxFileVersion.VERSION_2); + + try + { + Files.write(Path.of(filename), proto.build().toByteArray()); + } catch (IOException e) + { + throw new FluxEngineException("unable to write output file '" + filename + "'"); + } + } + + @Override + public void addFlux(int track, int head, Fluxmap fluxmap) + { + data.computeIfAbsent(Pair.of(track, head), k -> new ArrayList<>()).add(fluxmap.rawBytes()); + } + + @Override + public void close() + { + Logger.logf("FL2: writing " + filename); + + FluxFileProto.Builder proto = FluxFileProto.newBuilder(); + for (Map.Entry, List> e : data.entrySet()) + { + TrackFluxProto.Builder track = TrackFluxProto.newBuilder(); + track.setTrack(e.getKey().getLeft()); + track.setHead(e.getKey().getRight()); + for (Bytes fluxBytes : e.getValue()) + track.addFlux(ByteString.copyFrom(fluxBytes.toByteArray())); + proto.addTrack(track); + } + + proto.setRotationalPeriodMs(config.getDrive().getRotationalPeriodMs()); + proto.setDriveType(config.getDrive().getDriveType()); + proto.setFormatType(config.getLayout().getFormatType()); + + saveFl2File(filename, proto); + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/Fl2FluxSinkFactory.java b/java/com/cowlark/fluxengine/fluxsink/Fl2FluxSinkFactory.java new file mode 100644 index 000000000..04b840488 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/Fl2FluxSinkFactory.java @@ -0,0 +1,36 @@ +package com.cowlark.fluxengine.fluxsink; + +import com.cowlark.fluxengine.config.ConfigProto; + +/** + * A factory for FL2 flux sinks, ported from lib/fluxsink/fl2fluxsink.cc. + */ +public class Fl2FluxSinkFactory extends FluxSinkFactory +{ + private final String filename; + private final ConfigProto config; + + public Fl2FluxSinkFactory(String filename, ConfigProto config) + { + this.filename = filename; + this.config = config; + } + + @Override + public FluxSink create() + { + return new Fl2FluxSink(filename, config); + } + + @Override + public String getPath() + { + return filename; + } + + @Override + public String toString() + { + return "fl2(" + filename + ")"; + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/FluxSink.java b/java/com/cowlark/fluxengine/fluxsink/FluxSink.java new file mode 100644 index 000000000..54dab4c75 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/FluxSink.java @@ -0,0 +1,26 @@ +package com.cowlark.fluxengine.fluxsink; + +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.Fluxmap; + +/** + * A destination for flux data, ported from lib/fluxsink/fluxsink.h. + */ +public abstract class FluxSink implements AutoCloseable +{ + /* Writes a fluxmap to a track and side. */ + public abstract void addFlux(int track, int side, Fluxmap fluxmap); + + public void addFlux(CylinderHead location, Fluxmap fluxmap) + { + addFlux(location.cylinder(), location.head(), fluxmap); + } + + /* Flushes any buffered data. The C++ writes this in the destructor; Java + * has no destructor, so this must be called explicitly once all tracks + * have been written. */ + @Override + public void close() + { + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/FluxSinkFactory.java b/java/com/cowlark/fluxengine/fluxsink/FluxSinkFactory.java new file mode 100644 index 000000000..613446367 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/FluxSinkFactory.java @@ -0,0 +1,77 @@ +package com.cowlark.fluxengine.fluxsink; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; + +/** + * Factory for creating flux sinks, ported from lib/fluxsink/fluxsink.h. + */ +public abstract class FluxSinkFactory implements AutoCloseable +{ + public static FluxSinkFactory create(ConfigProto config) + { + if (!config.hasFluxSink()) + throw new FluxEngineException("no flux sink configured"); + return create(config, config.getFluxSink()); + } + + public static FluxSinkFactory create(ConfigProto config, FluxSinkProto sinkConfig) + { + switch (sinkConfig.getType()) + { + case FLUXTYPE_DRIVE: + return new HardwareFluxSinkFactory(config); + case FLUXTYPE_A2R: + return new A2RFluxSinkFactory(sinkConfig.getA2R().getFilename(), config); + case FLUXTYPE_AU: + return new AuFluxSinkFactory( + sinkConfig.getAu().getDirectory(), + sinkConfig.getAu().getIndexMarkers()); + case FLUXTYPE_VCD: + return new VcdFluxSinkFactory(sinkConfig.getVcd().getDirectory()); + case FLUXTYPE_SCP: + return new ScpFluxSinkFactory( + sinkConfig.getScp().getFilename(), + sinkConfig.getScp().getTypeByte(), + sinkConfig.getScp().getAlignWithIndex(), + config); + case FLUXTYPE_FLUX: + return createFl2FluxSinkFactory(sinkConfig.getFl2(), config); + default: + throw new FluxEngineException("no flux sink specified"); + } + } + + public static Fl2FluxSinkFactory createFl2FluxSinkFactory(Fl2FluxSinkProto config, + ConfigProto fullConfig) + { + return new Fl2FluxSinkFactory(config.getFilename(), fullConfig); + } + + public static Fl2FluxSinkFactory createFl2FluxSinkFactory(String filename, + ConfigProto fullConfig) + { + return new Fl2FluxSinkFactory(filename, fullConfig); + } + + @Override + public void close() throws Exception + { + } + + /* Creates a writer object. */ + public abstract FluxSink create(); + + /* Returns whether this is writing to real hardware or not. */ + public boolean isHardware() + { + return false; + } + + /* Returns the path (filename or directory) being written to, if there is + * one. */ + public String getPath() + { + return null; + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/HardwareFluxSink.java b/java/com/cowlark/fluxengine/fluxsink/HardwareFluxSink.java new file mode 100644 index 000000000..6d5137188 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/HardwareFluxSink.java @@ -0,0 +1,44 @@ +package com.cowlark.fluxengine.fluxsink; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.usb.UsbDevice; +import com.cowlark.fluxengine.usb.UsbFactory; + +/** + * A flux sink which writes to a real floppy drive, ported from + * lib/fluxsink/hardwarefluxsink.cc. + */ +public class HardwareFluxSink extends FluxSink +{ + private final ConfigProto config; + private final UsbDevice device; + + public HardwareFluxSink(ConfigProto config) + { + this(config, UsbFactory.reconnect(config)); + } + + HardwareFluxSink(ConfigProto config, UsbDevice device) + { + this.config = config; + this.device = device; + } + + @Override + public void addFlux(int track, int side, Fluxmap fluxmap) + { + device.setDrive( + config.getDrive().getDrive(), + config.getDrive().getHighDensity(), + config.getDrive().getIndexMode().getNumber()); + device.seek(track); + device.write(side, fluxmap.rawBytes(), config.getDrive().getHardSectorThresholdNs()); + } + + @Override + public void close() + { + device.close(); + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/HardwareFluxSinkFactory.java b/java/com/cowlark/fluxengine/fluxsink/HardwareFluxSinkFactory.java new file mode 100644 index 000000000..ed4215074 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/HardwareFluxSinkFactory.java @@ -0,0 +1,34 @@ +package com.cowlark.fluxengine.fluxsink; + +import com.cowlark.fluxengine.config.ConfigProto; + +/** + * A factory for hardware flux sinks, ported from lib/fluxsink/hardwarefluxsink.cc. + */ +public class HardwareFluxSinkFactory extends FluxSinkFactory +{ + private final ConfigProto config; + + public HardwareFluxSinkFactory(ConfigProto config) + { + this.config = config; + } + + @Override + public FluxSink create() + { + return new HardwareFluxSink(config); + } + + @Override + public boolean isHardware() + { + return true; + } + + @Override + public String toString() + { + return "hardware"; + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/ScpFluxSink.java b/java/com/cowlark/fluxengine/fluxsink/ScpFluxSink.java new file mode 100644 index 000000000..b843bacef --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/ScpFluxSink.java @@ -0,0 +1,201 @@ +package com.cowlark.fluxengine.fluxsink; + +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_INDEX; +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_PULSE; +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; +import static com.cowlark.fluxengine.external.Scp.SCP_FLAG_96TPI; +import static com.cowlark.fluxengine.external.Scp.SCP_FLAG_INDEXED; +import static com.cowlark.fluxengine.external.Scp.SCP_HEADER_SIZE; +import static com.cowlark.fluxengine.external.Scp.SCP_TRACK_SIZE; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.ByteReader; +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.DriveType; +import com.cowlark.fluxengine.external.Scp; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * A flux sink which writes an SCP flux file, ported from + * lib/fluxsink/scpfluxsink.cc. + */ +public class ScpFluxSink extends FluxSink +{ + private final String filename; + private final int typeByte; + private final boolean alignWithIndex; + private final ConfigProto config; + /* The 688-byte file header. */ + private final byte[] fileheader = new byte[SCP_HEADER_SIZE]; + private final Bytes trackdata = new Bytes(0); + + public ScpFluxSink(String filename, int typeByte, boolean alignWithIndex, ConfigProto config) + { + this.filename = filename; + this.typeByte = typeByte; + this.alignWithIndex = alignWithIndex; + this.config = config; + + // FIXME: should use a passed-in DiskLayout object. + DiskLayout diskLayout = DiskLayout.createDiskLayout(config); + int minCylinder = diskLayout.minPhysicalCylinder; + int maxCylinder = diskLayout.maxPhysicalCylinder; + int minHead = diskLayout.minPhysicalHead; + int maxHead = diskLayout.maxPhysicalHead; + + fileheader[0] = 'S'; + fileheader[1] = 'C'; + fileheader[2] = 'P'; + fileheader[3] = 0x18; /* Version 1.8 of the spec */ + fileheader[4] = (byte) typeByte; + fileheader[6] = (byte) Scp.strackno(minCylinder, minHead); + fileheader[7] = (byte) Scp.strackno(maxCylinder, maxHead); + int flags = SCP_FLAG_INDEXED; + if (config.getDrive().getDriveType() == DriveType.DRIVETYPE_APPLE2) + throw new FluxEngineException("you can't write Apple II flux images to SCP files yet"); + if (config.getDrive().getDriveType() != DriveType.DRIVETYPE_40TRACK) + flags |= SCP_FLAG_96TPI; + fileheader[8] = (byte) flags; + fileheader[9] = 0; /* cell width */ + if ((minHead == 0) && (maxHead == 0)) + fileheader[10] = 1; + else if ((minHead == 1) && (maxHead == 1)) + fileheader[10] = 2; + else + fileheader[10] = 0; + + Logger.logf("SCP: writing " + (((flags & SCP_FLAG_96TPI) != 0) ? 96 : 48) + " tpi " + + ((minHead == maxHead) ? "single sided" : "double sided") + " file containing " + + (fileheader[7] - fileheader[6] + 1) + " tracks"); + } + + private static void writeLe32(byte[] dest, int offset, int v) + { + dest[offset] = (byte) v; + dest[offset + 1] = (byte) (v >> 8); + dest[offset + 2] = (byte) (v >> 16); + dest[offset + 3] = (byte) (v >> 24); + } + + private static int appendChecksum(int checksum, Bytes bytes) + { + ByteReader br = new ByteReader(bytes); + while (!br.eof()) + checksum += br.read8(); + return checksum; + } + + @Override + public void addFlux(int track, int head, Fluxmap fluxmap) + { + ByteWriter trackdataWriter = trackdata.writer(); + trackdataWriter.seekToEnd(); + int strack = Scp.strackno(track, head); + + if (strack >= 168) + { + Logger.logf("SCP: cannot write track " + track + " head " + head + + ", there are not enough Track Data Headers."); + return; + } + /* ScpTrack: 'TRK' id, strack, then 5 revolution records. */ + byte[] trackHeader = new byte[SCP_TRACK_SIZE]; + trackHeader[0] = 'T'; + trackHeader[1] = 'R'; + trackHeader[2] = 'K'; + trackHeader[3] = (byte) strack; + + FluxmapReader fmr = new FluxmapReader(fluxmap, DecoderProto.getDefaultInstance()); + Bytes fluxdata = new Bytes(0); + ByteWriter fluxdataWriter = fluxdata.writer(); + + int revolution = -1; /* -1 indicates that we are before the first index pulse */ + if (alignWithIndex) + { + fmr.skipToEvent(F_BIT_INDEX); + revolution = 0; + } + long revTicks = 0; + long totalTicks = 0; + long ticksSinceLastPulse = 0; + int startOffset = 0; + while (revolution < 5) + { + FluxmapReader.Event event = fmr.getNextEvent(); + long ticks = event.ticks(); + + ticksSinceLastPulse += ticks; + totalTicks += ticks; + revTicks += ticks; + + /* if we haven't output any revolutions yet by the end of the + * track, assume that the whole track is one rev also discard + * any duplicate index pulses */ + if (((fmr.eof() && revolution <= 0) || + (((event.event() & F_BIT_INDEX) != 0) && revTicks > 0))) + { + if (fmr.eof() && revolution == -1) + revolution = 0; + if (revolution >= 0) + { + int revOffset = 4 + revolution * 12; + writeLe32(trackHeader, revOffset + 8, startOffset + SCP_TRACK_SIZE); + writeLe32(trackHeader, revOffset + 4, (fluxdataWriter.pos() - startOffset) / 2); + writeLe32(trackHeader, revOffset, (int) (revTicks * NS_PER_TICK / 25)); + } + revolution++; + revTicks = 0; + startOffset = fluxdataWriter.pos(); + } + if (fmr.eof()) + break; + + if ((event.event() & F_BIT_PULSE) != 0) + { + long t = (long) (ticksSinceLastPulse * NS_PER_TICK / 25); + while (t >= 0x10000) + { + fluxdataWriter.writeBe16(0); + t -= 0x10000; + } + fluxdataWriter.writeBe16((int) t); + ticksSinceLastPulse = 0; + } + } + + fileheader[5] = (byte) revolution; + writeLe32(fileheader, 16 + strack * 4, trackdataWriter.pos() + SCP_HEADER_SIZE); + trackdataWriter.write(trackHeader); + trackdataWriter.write(fluxdata); + } + + @Override + public void close() + { + int checksum = 0; + checksum = appendChecksum( + checksum, + new Bytes(java.util.Arrays.copyOfRange(fileheader, 0x10, fileheader.length))); + checksum = appendChecksum(checksum, trackdata); + writeLe32(fileheader, 12, checksum); + + Logger.logf("SCP: writing output file"); + Bytes out = new Bytes(fileheader).concat(trackdata); + try + { + Files.write(Path.of(filename), out.toByteArray()); + } catch (IOException e) + { + throw new FluxEngineException("cannot open output file"); + } + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/ScpFluxSinkFactory.java b/java/com/cowlark/fluxengine/fluxsink/ScpFluxSinkFactory.java new file mode 100644 index 000000000..d7743749e --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/ScpFluxSinkFactory.java @@ -0,0 +1,43 @@ +package com.cowlark.fluxengine.fluxsink; + +import com.cowlark.fluxengine.config.ConfigProto; + +/** + * A factory for SCP flux sinks, ported from lib/fluxsink/scpfluxsink.cc. + */ +public class ScpFluxSinkFactory extends FluxSinkFactory +{ + private final String filename; + private final int typeByte; + private final boolean alignWithIndex; + private final ConfigProto config; + + public ScpFluxSinkFactory(String filename, + int typeByte, + boolean alignWithIndex, + ConfigProto config) + { + this.filename = filename; + this.typeByte = typeByte; + this.alignWithIndex = alignWithIndex; + this.config = config; + } + + @Override + public FluxSink create() + { + return new ScpFluxSink(filename, typeByte, alignWithIndex, config); + } + + @Override + public String getPath() + { + return filename; + } + + @Override + public String toString() + { + return "scp(" + filename + ")"; + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/VcdFluxSink.java b/java/com/cowlark/fluxengine/fluxsink/VcdFluxSink.java new file mode 100644 index 000000000..fe3cad435 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/VcdFluxSink.java @@ -0,0 +1,86 @@ +package com.cowlark.fluxengine.fluxsink; + +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_INDEX; +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_PULSE; +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; + +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.FluxmapReader; +import com.cowlark.fluxengine.decoders.DecoderProto; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * A flux sink which writes VCD (Value Change Dump) files, ported from + * lib/fluxsink/vcdfluxsink.cc. + */ +public class VcdFluxSink extends FluxSink +{ + private final String directory; + + public VcdFluxSink(String directory) + { + this.directory = directory; + } + + @Override + public void addFlux(int track, int head, Fluxmap fluxmap) + { + try + { + Files.createDirectories(Path.of(directory)); + } catch (IOException e) + { + throw new FluxEngineException("cannot create directory '" + directory + "'"); + } + + StringBuilder sb = new StringBuilder(); + sb.append("$timescale 1ns $end\n"); + sb.append("$var wire 1 i index $end\n"); + sb.append("$var wire 1 p pulse $end\n"); + sb.append("$upscope $end\n"); + sb.append("$enddefinitions $end\n"); + sb.append("$dumpvars 0i 0p $end\n"); + + FluxmapReader fmr = new FluxmapReader(fluxmap, DecoderProto.getDefaultInstance()); + long timestamp = 0; + long lasttimestamp = 0; + while (!fmr.eof()) + { + FluxmapReader.Event event = fmr.getNextEvent(); + if (fmr.eof()) + break; + + long newtimestamp = timestamp + event.ticks(); + if (newtimestamp != lasttimestamp) + { + sb.append("\n#"); + sb.append((long) ((lasttimestamp + 1) * NS_PER_TICK)); + sb.append(" 0i 0p\n"); + timestamp = newtimestamp; + sb.append("#"); + sb.append((long) (timestamp * NS_PER_TICK)); + sb.append(" "); + } + + if ((event.event() & F_BIT_PULSE) != 0) + sb.append("1p "); + if ((event.event() & F_BIT_INDEX) != 0) + sb.append("1i "); + + lasttimestamp = timestamp; + } + sb.append("\n"); + + String filename = String.format("%s/c%02d.h%01d.vcd", directory, track, head); + try + { + Files.write(Path.of(filename), sb.toString().getBytes()); + } catch (IOException e) + { + throw new FluxEngineException("cannot open output file"); + } + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/VcdFluxSinkFactory.java b/java/com/cowlark/fluxengine/fluxsink/VcdFluxSinkFactory.java new file mode 100644 index 000000000..ef498ed85 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/VcdFluxSinkFactory.java @@ -0,0 +1,32 @@ +package com.cowlark.fluxengine.fluxsink; + +/** + * A factory for VCD flux sinks, ported from lib/fluxsink/vcdfluxsink.cc. + */ +public class VcdFluxSinkFactory extends FluxSinkFactory +{ + private final String directory; + + public VcdFluxSinkFactory(String directory) + { + this.directory = directory; + } + + @Override + public FluxSink create() + { + return new VcdFluxSink(directory); + } + + @Override + public String getPath() + { + return directory; + } + + @Override + public String toString() + { + return "vcd(" + directory + ")"; + } +} diff --git a/java/com/cowlark/fluxengine/fluxsink/fluxsink.proto b/java/com/cowlark/fluxengine/fluxsink/fluxsink.proto new file mode 100644 index 000000000..f9d0fff64 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsink/fluxsink.proto @@ -0,0 +1,45 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.fluxsink"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message HardwareFluxSinkProto {} + +message AuFluxSinkProto { + optional string directory = 1 [default = "aufiles", (help) = "directory to write .au files to"]; + optional bool index_markers = 2 [default = true, (help) = "show index markers in the right-hand channel"]; +} + +message A2RFluxSinkProto { + optional string filename = 1 [default = "flux.a2r", (help) = ".a2r file to write to"]; +} + +message VcdFluxSinkProto { + optional string directory = 1 [default = "vcdfiles", (help) = "directory to write .vcd files to"]; +} + +message ScpFluxSinkProto { + optional string filename = 2 [default = "flux.scp", (help) = ".scp file to write to"]; + optional bool align_with_index = 3 [default = false, (help) = "discard data before the first index pulse"]; + optional int32 type_byte = 4 [default = 0xff, (help) = "set the SCP disk type byte"]; +} + +message Fl2FluxSinkProto { + optional string filename = 1 [default = "flux.fl2", (help) = ".fl2 file to write to"]; +} + +// Next: 10 +message FluxSinkProto { + optional FluxSourceSinkType type = 9 + [default = FLUXTYPE_NOT_SET, (help) = "flux sink type"]; + + optional HardwareFluxSinkProto drive = 2; + optional A2RFluxSinkProto a2r = 8; + optional AuFluxSinkProto au = 3; + optional VcdFluxSinkProto vcd = 4; + optional ScpFluxSinkProto scp = 5; + optional Fl2FluxSinkProto fl2 = 6; +} + diff --git a/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java b/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java new file mode 100644 index 000000000..08298fc74 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/A2RFluxSource.java @@ -0,0 +1,167 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.config.ConfigBuilder; +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 flux file, ported from + * lib/fluxsource/a2rfluxsource.cc. + */ +public class A2RFluxSource extends FluxSource +{ + private final TreeMap v2data = new TreeMap<>(); + private final A2rFluxSourceProto config; + private final Bytes data; + protected ConfigProto extraConfig; + private int version; + + public A2RFluxSource(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 void adjustConfig(ConfigBuilder configBuilder) + { + configBuilder.mergeConfig(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; + } + } + + @Override + public void recalibrate() + { + } + + 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; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/fluxsource/A2RFluxSourceIterator.java b/java/com/cowlark/fluxengine/fluxsource/A2RFluxSourceIterator.java new file mode 100644 index 000000000..2d29a97b1 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/A2RFluxSourceIterator.java @@ -0,0 +1,66 @@ +package com.cowlark.fluxengine.fluxsource; + +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.Fluxmap; +import java.util.List; + +/** + * Iterator over the flux revolutions of one track in an A2R file, ported from + * lib/fluxsource/a2rfluxsource.cc. + */ +class A2RFluxSourceIterator implements FluxSourceIterator +{ + private final List flux; + private final double index; + private int count; + + A2RFluxSourceIterator(List flux, double index) + { + this.flux = flux; + this.index = index; + } + + @Override + public boolean hasNext() + { + return count != flux.size(); + } + + @Override + public Fluxmap next() + { + double index = this.index; + Bytes asbytes = flux.get(count++); + ByteReader br = new ByteReader(asbytes); + + Fluxmap fluxmap = new Fluxmap(); + while (!br.eof()) + { + long aticks = 0; + for (; ; ) + { + int i = br.read8(); + aticks += i; + if (i != 0xff) + break; + } + + double interval = aticks * 125; + if ((index >= 0) && (index < interval)) + { + fluxmap.appendInterval((int) index); + fluxmap.appendIndex(); + interval -= index; + } + index -= interval; + + fluxmap.appendInterval((int) (interval / NS_PER_TICK)); + fluxmap.appendPulse(); + } + + return fluxmap; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/fluxsource/BUILD.bazel b/java/com/cowlark/fluxengine/fluxsource/BUILD.bazel new file mode 100644 index 000000000..8e07aa8c8 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/BUILD.bazel @@ -0,0 +1,43 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library", "java_plugin") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +java_plugin( + name = "lombok_plugin", + generates_api = True, + processor_class = "lombok.launch.AnnotationProcessorHider$AnnotationProcessor", + deps = ["@maven//:org_projectlombok_lombok"], +) + +proto_library( + name = "fluxsource_proto", + srcs = ["fluxsource.proto"], + strip_import_prefix = "/java/", + deps = ["//java/com/cowlark/fluxengine/config:common_proto"], +) + +java_proto_library( + name = "fluxsource_java_proto", + deps = [":fluxsource_proto"], +) + +java_library( + name = "fluxsource", + srcs = glob(["*.java"]), + plugins = [":lombok_plugin"], + deps = [ + ":fluxsource_java_proto", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:common_java_proto", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/external", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "//java/com/cowlark/fluxengine/usb", + "@com_google_protobuf//java/core", + "@maven//:org_projectlombok_lombok", + ], +) diff --git a/java/com/cowlark/fluxengine/fluxsource/EmptyFluxSourceIterator.java b/java/com/cowlark/fluxengine/fluxsource/EmptyFluxSourceIterator.java new file mode 100644 index 000000000..a843c34b1 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/EmptyFluxSourceIterator.java @@ -0,0 +1,22 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; + +/** + * An iterator over no flux at all, ported from lib/fluxsource/fluxsource.h. + */ +public class EmptyFluxSourceIterator implements FluxSourceIterator +{ + @Override + public boolean hasNext() + { + return false; + } + + @Override + public Fluxmap next() + { + throw new FluxEngineException("no flux to read"); + } +} diff --git a/java/com/cowlark/fluxengine/fluxsource/EraseFluxSource.java b/java/com/cowlark/fluxengine/fluxsource/EraseFluxSource.java new file mode 100644 index 000000000..82901aa8a --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/EraseFluxSource.java @@ -0,0 +1,38 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.data.Fluxmap; + +/** + * A flux source which produces no flux, ported from + * lib/fluxsource/erasefluxsource.cc. + */ +public class EraseFluxSource extends TrivialFluxSource +{ + protected ConfigProto extraConfig; + + public EraseFluxSource(EraseFluxSourceProto config) + { + ConfigProto.Builder builder = ConfigProto.newBuilder(); + builder.getDriveBuilder().setTracks("c0-255h0-1"); + extraConfig = builder.build(); + } + + @Override + public void adjustConfig(ConfigBuilder configBuilder) + { + configBuilder.mergeConfig(extraConfig); + } + + @Override + public Fluxmap readSingleFlux(FluxReadParameters parameters) + { + return null; + } + + @Override + public void recalibrate() + { + } +} diff --git a/java/com/cowlark/fluxengine/fluxsource/Fl2FluxSource.java b/java/com/cowlark/fluxengine/fluxsource/Fl2FluxSource.java new file mode 100644 index 000000000..b6c2bf522 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/Fl2FluxSource.java @@ -0,0 +1,133 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +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.FluxFileProto; +import com.cowlark.fluxengine.external.FluxFileVersion; +import com.cowlark.fluxengine.external.TrackFluxProto; +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * A flux source which reads an FL2 flux file, ported from + * lib/fluxsource/fl2fluxsource.cc. + */ +public class Fl2FluxSource extends FluxSource +{ + private final FluxFileProto proto; + protected ConfigProto extraConfig; + + public Fl2FluxSource(Fl2FluxSourceProto config) + { + proto = loadFl2File(config.getFilename()); + + ConfigProto.Builder builder = ConfigProto.newBuilder(); + builder.getDriveBuilder().setRotationalPeriodMs(proto.getRotationalPeriodMs()); + if (proto.hasDriveType()) + builder.getDriveBuilder().setDriveType(proto.getDriveType()); + + List chs = new ArrayList<>(); + for (TrackFluxProto trackFlux : proto.getTrackList()) + chs.add(new CylinderHead(trackFlux.getTrack(), trackFlux.getHead())); + builder.getDriveBuilder().setTracks(Locations.convertCylinderHeadsToString(chs)); + + extraConfig = builder.build(); + } + + public static FluxFileProto loadFl2File(String filename) + { + Bytes data; + try + { + data = new Bytes(Files.readAllBytes(Path.of(filename))); + } catch (IOException e) + { + throw new FluxEngineException( + "cannot open input file '" + filename + "': " + e.getMessage()); + } + + if (data.size() >= 16 && + new String(data.slice(0, 16).toByteArray(), StandardCharsets.US_ASCII).equals( + "SQLite format 3")) + throw new FluxEngineException( + "this flux file is too old; please use the upgrade-flux-file tool to upgrade " + + "it"); + + FluxFileProto proto; + try + { + proto = FluxFileProto.parseFrom(data.toByteArray()); + } catch (InvalidProtocolBufferException e) + { + throw new FluxEngineException("unable to read input file '" + filename + "'"); + } + + return upgradeFluxFile(proto); + } + + private static FluxFileProto upgradeFluxFile(FluxFileProto proto) + { + if (proto.getVersion() == FluxFileVersion.VERSION_1) + { + /* Change a flux datastream with multiple segments separated by + * F_DESYNC into multiple flux segments. */ + FluxFileProto.Builder builder = proto.toBuilder(); + for (int i = 0; i < proto.getTrackCount(); i++) + { + TrackFluxProto track = proto.getTrack(i); + if (track.getFluxCount() != 0) + { + Fluxmap oldFlux = new Fluxmap(new Bytes(track.getFlux(0).toByteArray())); + TrackFluxProto.Builder trackBuilder = track.toBuilder(); + trackBuilder.clearFlux(); + for (Fluxmap flux : oldFlux.split()) + trackBuilder.addFlux(ByteString.copyFrom(flux.rawBytes().toByteArray())); + builder.setTrack(i, trackBuilder.build()); + } + } + builder.setVersion(FluxFileVersion.VERSION_2); + proto = builder.build(); + } + + if (proto.getVersion().getNumber() > FluxFileVersion.VERSION_2.getNumber()) + throw new FluxEngineException("this is a version " + proto.getVersion().getNumber() + + " flux file, but this build of the client can only handle up to version " + + FluxFileVersion.VERSION_2.getNumber() + " --- please upgrade"); + return proto; + } + + @Override + public void adjustConfig(ConfigBuilder configBuilder) + { + configBuilder.mergeConfig(extraConfig); + } + + @Override + public FluxSourceIterator readFlux(FluxReadParameters parameters) + { + for (TrackFluxProto trackFlux : proto.getTrackList()) + { + if (trackFlux.getTrack() == parameters.cylinder() && + trackFlux.getHead() == parameters.head()) + return new Fl2FluxSourceIterator(trackFlux); + } + + return new EmptyFluxSourceIterator(); + } + + @Override + public void recalibrate() + { + } +} diff --git a/java/com/cowlark/fluxengine/fluxsource/Fl2FluxSourceIterator.java b/java/com/cowlark/fluxengine/fluxsource/Fl2FluxSourceIterator.java new file mode 100644 index 000000000..7b5ac6d37 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/Fl2FluxSourceIterator.java @@ -0,0 +1,32 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.external.TrackFluxProto; + +/** + * Iterator over the flux segments of one track in an FL2 file, ported from + * lib/fluxsource/fl2fluxsource.cc. + */ +public class Fl2FluxSourceIterator implements FluxSourceIterator +{ + private final TrackFluxProto proto; + private int count; + + public Fl2FluxSourceIterator(TrackFluxProto proto) + { + this.proto = proto; + } + + @Override + public boolean hasNext() + { + return count < proto.getFluxCount(); + } + + @Override + public Fluxmap next() + { + return new Fluxmap(new Bytes(proto.getFlux(count++).toByteArray())); + } +} diff --git a/java/com/cowlark/fluxengine/fluxsource/FluxReadParameters.java b/java/com/cowlark/fluxengine/fluxsource/FluxReadParameters.java new file mode 100644 index 000000000..fcfd89e70 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/FluxReadParameters.java @@ -0,0 +1,14 @@ +package com.cowlark.fluxengine.fluxsource; + +import lombok.Builder; + +/** + * The parameters for reading flux from a track, passed to + * {@link FluxSource#readFlux}. + */ +@Builder(setterPrefix = "set") +public record FluxReadParameters + (int cylinder, int head, boolean syncWithIndex, double readTimeNs, + double hardSectorThresholdNs) +{ +} diff --git a/java/com/cowlark/fluxengine/fluxsource/FluxSource.java b/java/com/cowlark/fluxengine/fluxsource/FluxSource.java new file mode 100644 index 000000000..e83b7804b --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/FluxSource.java @@ -0,0 +1,84 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.config.FluxSourceSinkType; +import com.cowlark.fluxengine.core.FluxEngineException; + +/** + * A source of flux data, ported from lib/fluxsource/fluxsource.{h,cc}. + */ +public abstract class FluxSource implements AutoCloseable +{ + public static FluxSource create(ConfigProto config) + { + if (config.getFluxSource().getType() == FluxSourceSinkType.FLUXTYPE_DRIVE) + return new HardwareFluxSource(config); + return create(config.getFluxSource()); + } + + public static FluxSource create(FluxSourceProto config) + { + switch (config.getType()) + { + case FLUXTYPE_DRIVE: + return notImplemented("drive"); + case FLUXTYPE_ERASE: + return new EraseFluxSource(config.getErase()); + case FLUXTYPE_KRYOFLUX: + return new KryofluxFluxSource(config.getKryoflux()); + case FLUXTYPE_TEST_PATTERN: + return notImplemented("test pattern"); + case FLUXTYPE_SCP: + return new ScpFluxSource(config.getScp()); + case FLUXTYPE_A2R: + return new A2RFluxSource(config.getA2R()); + case FLUXTYPE_CWF: + return notImplemented("cwf"); + case FLUXTYPE_DMK: + return notImplemented("dmk"); + case FLUXTYPE_FLUX: + return new Fl2FluxSource(config.getFl2()); + case FLUXTYPE_FLX: + return notImplemented("flx"); + default: + return null; + } + } + + private static FluxSource notImplemented(String name) + { + throw new FluxEngineException(name + " flux source is not implemented yet"); + } + + @Override + public void close() throws Exception + { + } + + /* Adjusts the current configuration based on the contents of this flux source. */ + public void adjustConfig(ConfigBuilder configBuilder) + { + } + + /* Read flux from a given cylinder and head. */ + public abstract FluxSourceIterator readFlux(FluxReadParameters parameters); + + /* Recalibrates; seeks to cylinder 0 and ensures the head is in the right + * place. */ + public void recalibrate() + { + } + + /* Seeks to a given cylinder (without recalibrating). */ + public void seek(int cylinder) + { + } + + /* Is this real hardware? If so, then flux can be read indefinitely (among + * other things). */ + public boolean isHardware() + { + return false; + } +} diff --git a/java/com/cowlark/fluxengine/fluxsource/FluxSourceIterator.java b/java/com/cowlark/fluxengine/fluxsource/FluxSourceIterator.java new file mode 100644 index 000000000..a61de6ad1 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/FluxSourceIterator.java @@ -0,0 +1,14 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.data.Fluxmap; + +/** + * Iterator over the flux maps of one track, ported from + * lib/fluxsource/fluxsource.h. + */ +public interface FluxSourceIterator +{ + boolean hasNext(); + + Fluxmap next(); +} diff --git a/java/com/cowlark/fluxengine/fluxsource/HardwareFluxSource.java b/java/com/cowlark/fluxengine/fluxsource/HardwareFluxSource.java new file mode 100644 index 000000000..f48d61b52 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/HardwareFluxSource.java @@ -0,0 +1,75 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.usb.UsbDevice; +import com.cowlark.fluxengine.usb.UsbFactory; + +/** + * A flux source which reads from real hardware, ported from + * lib/fluxsource/hardwarefluxsource.cc. + */ +public class HardwareFluxSource extends FluxSource +{ + private final ConfigProto config; + private final UsbDevice device; + + public HardwareFluxSource(ConfigProto config) + { + this(config, UsbFactory.reconnect(config)); + } + + /* Package-private for testing. */ + HardwareFluxSource(ConfigProto config, UsbDevice device) + { + this.config = config; + this.device = device; + } + + @Override + public FluxSourceIterator readFlux(FluxReadParameters parameters) + { + return new FluxSourceIterator() + { + @Override + public boolean hasNext() + { + return true; + } + + @Override + public Fluxmap next() + { + device.seek(parameters.cylinder()); + + Bytes data = device.read( + parameters.head(), + parameters.syncWithIndex(), + parameters.readTimeNs(), + parameters.hardSectorThresholdNs()); + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendBytes(data); + return fluxmap; + } + }; + } + + @Override + public void recalibrate() + { + device.recalibrate(); + } + + @Override + public void seek(int track) + { + device.seek(track); + } + + @Override + public boolean isHardware() + { + return true; + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/fluxsource/KryofluxFluxSource.java b/java/com/cowlark/fluxengine/fluxsource/KryofluxFluxSource.java new file mode 100644 index 000000000..5535c7cfe --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/KryofluxFluxSource.java @@ -0,0 +1,66 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Kryoflux; +import com.cowlark.fluxengine.data.Locations; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A flux source which reads raw Kryoflux stream files, ported from + * lib/fluxsource/kryofluxfluxsource.cc. + */ +public class KryofluxFluxSource extends TrivialFluxSource +{ + private static final Pattern FILENAME_REGEX = + Pattern.compile(".*[^0-9]([0-9]+)\\.([0-9]+)\\.raw"); + + private final String path; + protected ConfigProto extraConfig; + + public KryofluxFluxSource(KryofluxFluxSourceProto config) + { + path = config.getDirectory(); + + List chs = new ArrayList<>(); + File[] files = new File(path).listFiles(); + if (files != null) + { + for (File f : files) + { + Matcher m = FILENAME_REGEX.matcher(f.getName()); + if (m.matches()) + chs.add(new CylinderHead( + Integer.parseInt(m.group(1)), + Integer.parseInt(m.group(2)))); + } + } + + ConfigProto.Builder builder = ConfigProto.newBuilder(); + builder.getDriveBuilder().setTracks(Locations.convertCylinderHeadsToString(chs)); + extraConfig = builder.build(); + } + + @Override + public void adjustConfig(ConfigBuilder configBuilder) + { + configBuilder.mergeConfig(extraConfig); + } + + @Override + public Fluxmap readSingleFlux(FluxReadParameters parameters) + { + return Kryoflux.readStream(path, parameters.cylinder(), parameters.head()); + } + + @Override + public void recalibrate() + { + } +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/fluxsource/ScpFluxSource.java b/java/com/cowlark/fluxengine/fluxsource/ScpFluxSource.java new file mode 100644 index 000000000..d2022b68e --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/ScpFluxSource.java @@ -0,0 +1,163 @@ +package com.cowlark.fluxengine.fluxsource; + +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; +import static com.cowlark.fluxengine.external.Scp.SCP_FLAG_96TPI; + +import com.cowlark.fluxengine.config.ConfigBuilder; +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.core.Logger; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Locations; +import com.cowlark.fluxengine.external.DriveType; +import com.cowlark.fluxengine.external.Scp; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * A flux source which reads an SCP flux file, ported from + * lib/fluxsource/scpfluxsource.cc. + */ +public class ScpFluxSource extends TrivialFluxSource +{ + private final Bytes data; + protected ConfigProto extraConfig; + private final double resolution; + private final int heads; + private final int startTrack; + private final int endTrack; + private final int flags; + private final int revolutions; + private final int[] trackOffsets = new int[168]; + + public ScpFluxSource(ScpFluxSourceProto config) + { + data = readFile(config.getFilename()); + + ByteReader br = new ByteReader(data); + byte[] fileId = br.read(3).toByteArray(); + if ((fileId[0] != 'S') || (fileId[1] != 'C') || (fileId[2] != 'P')) + throw new FluxEngineException("input not a SCP file"); + + br.read8(); /* version */ + br.read8(); /* type */ + revolutions = br.read8(); + startTrack = Scp.trackno(br.read8()); + endTrack = Scp.trackno(br.read8()); + flags = br.read8(); + int cellWidth = br.read8(); + heads = br.read8(); + int resolutionByte = br.read8(); + br.skip(4); /* checksum */ + + for (int i = 0; i < 168; i++) + trackOffsets[i] = br.readLe32(); + + if ((cellWidth != 0) && (cellWidth != 16)) + throw new FluxEngineException("currently only 16-bit cells in SCP files are supported"); + + resolution = 25.0 * (resolutionByte + 1); + + int startSide = (heads == 2) ? 1 : 0; + int endSide = (heads == 1) ? 0 : 1; + + List chs = new ArrayList<>(); + for (int cylinder = startTrack; cylinder <= endTrack; cylinder++) + for (int head = startSide; head <= endSide; head++) + chs.add(new CylinderHead(cylinder, head)); + + ConfigProto.Builder builder = ConfigProto.newBuilder(); + builder.getDriveBuilder() + .setDriveType((flags & SCP_FLAG_96TPI) != 0 ? + DriveType.DRIVETYPE_80TRACK : + DriveType.DRIVETYPE_40TRACK); + builder.getDriveBuilder().setTracks(Locations.convertCylinderHeadsToString(chs)); + extraConfig = builder.build(); + + Logger.logf("SCP tracks %d-%d, heads %d-%d", startTrack, endTrack, startSide, endSide); + Logger.logf("SCP sample resolution: %d ns", (int) resolution); + } + + 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()); + } + } + + @Override + public void adjustConfig(ConfigBuilder configBuilder) + { + configBuilder.mergeConfig(extraConfig); + } + + @Override + public Fluxmap readSingleFlux(FluxReadParameters parameters) + { + int strack = Scp.strackno(parameters.cylinder(), parameters.head()); + if (strack >= 168) + return new Fluxmap(); + int offset = trackOffsets[strack]; + if (offset == 0) + return new Fluxmap(); + + ByteReader br = new ByteReader(data); + br.seek(offset); + byte[] trackId = br.read(3).toByteArray(); + if ((trackId[0] != 'T') || (trackId[1] != 'R') || (trackId[2] != 'K')) + throw new FluxEngineException("corrupt SCP file"); + br.read8(); /* strack */ + + int[] revsLength = new int[revolutions]; + int[] revsOffset = new int[revolutions]; + for (int revolution = 0; revolution < revolutions; revolution++) + { + br.skip(4); /* index */ + revsLength[revolution] = br.readLe32(); + revsOffset[revolution] = br.readLe32(); + } + + Fluxmap fluxmap = new Fluxmap(); + long pending = 0; + for (int revolution = 0; revolution < revolutions; revolution++) + { + if (revolution != 0) + fluxmap.appendIndex(); + + int dataLength = revsLength[revolution]; + int dataOffset = revsOffset[revolution]; + + ByteReader dbr = new ByteReader(data); + dbr.seek(dataOffset + offset); + for (int cell = 0; cell < dataLength; cell++) + { + int interval = dbr.readBe16(); + if (interval != 0) + { + fluxmap.appendInterval((int) ((interval + pending) * resolution / NS_PER_TICK)); + fluxmap.appendPulse(); + pending = 0; + } else + pending += 0x10000; + } + } + + return fluxmap; + } + + @Override + public void recalibrate() + { + } +} diff --git a/java/com/cowlark/fluxengine/fluxsource/TrivialFluxSource.java b/java/com/cowlark/fluxengine/fluxsource/TrivialFluxSource.java new file mode 100644 index 000000000..2ce393a77 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/TrivialFluxSource.java @@ -0,0 +1,18 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.data.Fluxmap; + +/** + * A flux source which provides a single flux map per track, ported from + * lib/fluxsource/fluxsource.h. + */ +public abstract class TrivialFluxSource extends FluxSource +{ + @Override + public FluxSourceIterator readFlux(FluxReadParameters parameters) + { + return new TrivialFluxSourceIterator(this, parameters); + } + + public abstract Fluxmap readSingleFlux(FluxReadParameters parameters); +} diff --git a/java/com/cowlark/fluxengine/fluxsource/TrivialFluxSourceIterator.java b/java/com/cowlark/fluxengine/fluxsource/TrivialFluxSourceIterator.java new file mode 100644 index 000000000..c008f1e30 --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/TrivialFluxSourceIterator.java @@ -0,0 +1,33 @@ +package com.cowlark.fluxengine.fluxsource; + +import com.cowlark.fluxengine.data.Fluxmap; + +/** + * Iterator over the single flux map provided by a TrivialFluxSource, ported + * from lib/fluxsource/fluxsource.cc. + */ +public class TrivialFluxSourceIterator implements FluxSourceIterator +{ + private final TrivialFluxSource fluxSource; + private final FluxReadParameters parameters; + private boolean done; + + public TrivialFluxSourceIterator(TrivialFluxSource fluxSource, FluxReadParameters parameters) + { + this.fluxSource = fluxSource; + this.parameters = parameters; + } + + @Override + public boolean hasNext() + { + return !done; + } + + @Override + public Fluxmap next() + { + done = true; + return fluxSource.readSingleFlux(parameters); + } +} diff --git a/java/com/cowlark/fluxengine/fluxsource/fluxsource.proto b/java/com/cowlark/fluxengine/fluxsource/fluxsource.proto new file mode 100644 index 000000000..c6ff73eba --- /dev/null +++ b/java/com/cowlark/fluxengine/fluxsource/fluxsource.proto @@ -0,0 +1,66 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.fluxsource"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message HardwareFluxSourceProto {} + +message TestPatternFluxSourceProto { + optional double interval_us = 1 [default = 4.0, (help) = "interval between pulses"]; + optional double sequence_length_ms = 2 [default = 166.0, (help) = "length of test sequence"]; +} + +message EraseFluxSourceProto {} + +message KryofluxFluxSourceProto { + optional string directory = 1 [(help) = "path to Kryoflux stream directory"]; +} + +message ScpFluxSourceProto { + optional string filename = 1 [default = "flux.scp", + (help) = ".scp file to read flux from"]; +} + +message A2rFluxSourceProto { + optional string filename = 1 [default = "flux.a2r", + (help) = ".a2r file to read flux from"]; +} + +message CwfFluxSourceProto { + optional string filename = 1 [default = "flux.cwf", + (help) = ".cwf file to read flux from"]; +} + +message DmkFluxSourceProto { + optional string directory = 1 [ + (help) = "path to DMK directory"]; +} + +message Fl2FluxSourceProto { + optional string filename = 1 [default = "flux.fl2", + (help) = ".fl2 file to read flux from"]; +} + +message FlxFluxSourceProto { + optional string directory = 1 [(help) = "path to FLX stream directory"]; +} + +// NEXT: 13 +message FluxSourceProto { + optional FluxSourceSinkType type = 9 + [default = FLUXTYPE_NOT_SET, (help) = "flux source type"]; + + optional A2rFluxSourceProto a2r = 11; + optional CwfFluxSourceProto cwf = 7; + optional DmkFluxSourceProto dmk = 12; + optional EraseFluxSourceProto erase = 4; + optional Fl2FluxSourceProto fl2 = 8; + optional FlxFluxSourceProto flx = 10; + optional HardwareFluxSourceProto drive = 2; + optional KryofluxFluxSourceProto kryoflux = 5; + optional ScpFluxSourceProto scp = 6; + optional TestPatternFluxSourceProto test_pattern = 3; +} + diff --git a/java/com/cowlark/fluxengine/gui/AboutAction.java b/java/com/cowlark/fluxengine/gui/AboutAction.java new file mode 100644 index 000000000..3d819f315 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/AboutAction.java @@ -0,0 +1,18 @@ +package com.cowlark.fluxengine.gui; + +import javax.swing.AbstractAction; +import javax.swing.JOptionPane; +import java.awt.event.ActionEvent; + +class AboutAction extends AbstractAction +{ + @Override + public void actionPerformed(ActionEvent e) + { + JOptionPane.showMessageDialog( + null, + "FluxEngine\nA disk-flux reader/writer", + "About FluxEngine", + JOptionPane.INFORMATION_MESSAGE); + } +} diff --git a/java/com/cowlark/fluxengine/gui/ApplicationFrame.java b/java/com/cowlark/fluxengine/gui/ApplicationFrame.java new file mode 100644 index 000000000..b73f7e7eb --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/ApplicationFrame.java @@ -0,0 +1,74 @@ +package com.cowlark.fluxengine.gui; + +import static swingtree.UIFactoryMethods.button; +import static swingtree.UIFactoryMethods.of; +import static swingtree.UIFactoryMethods.panel; +import static swingtree.UIFactoryMethods.scrollPane; +import static swingtree.UIFactoryMethods.splitPane; +import static swingtree.UIFactoryMethods.tab; +import static swingtree.UIFactoryMethods.tabbedPane; +import static swingtree.UILayoutConstants.BOTTOM; +import static swingtree.UILayoutConstants.LEFT; +import static swingtree.UILayoutConstants.RIGHT; +import static swingtree.UILayoutConstants.TOP; + +import swingtree.UI; +import javax.swing.JFrame; + +public class ApplicationFrame extends JFrame +{ + private final ConfigurationPanel configurationPanel; + private final VisualiserPanel visualiserPanel; + private final ImagePanel imagePanel; + private final LogPanel logPanel; + private final SummaryPanel summaryPanel; + private final StatusbarPanel statusbarPanel; + + private final ImagerViewModel model; + + ApplicationFrame(ImagerViewModel model) + { + this.model = model; + statusbarPanel = new StatusbarPanel(model); + summaryPanel = new SummaryPanel(); + logPanel = new LogPanel(); + imagePanel = new ImagePanel(); + visualiserPanel = new VisualiserPanel(); + configurationPanel = new ConfigurationPanel(model); + + UI.of(this) + .withOnCloseOperation(UI.OnWindowClose.DISPOSE) + .onClose(it -> System.exit(0)) + .peek(frame -> { + frame.setJMenuBar(ApplicationMenu.createMenu()); + frame.setSize(1280, 720); + frame.setLocationRelativeTo(null); + }) + .add(panel("fill, wrap 1").add( + "grow, push", splitPane(UI.Align.HORIZONTAL).add( + LEFT, + tabbedPane().add(tab("Configuration").add(scrollPane().add(of( + configurationPanel))))).add( + RIGHT, + splitPane(UI.Align.VERTICAL).peek(pane -> pane.setResizeWeight(1.0)) + .add( + TOP, + tabbedPane().add(tab("Visualiser").add(of( + visualiserPanel))) + .add(tab("Image").add(of(imagePanel))) + .add(tab("Log").add(of(logPanel)))) + .add( + BOTTOM, + tabbedPane().add(tab("Summary").add(panel( + "fillx, wrap 1, aligny center").add("growx, h 100!", + of(summaryPanel)).add( + "growx", + panel("wrap 3, alignx center").add(button( + "Read disk").onClick(model::onReadDisk)) + .add(button("Reread disk").onClick( + model::onRereadDisk)) + .add(button("Write disk").onClick( + model::onWriteDisk)))))))) + .add("growx", statusbarPanel)); + } +} diff --git a/java/com/cowlark/fluxengine/gui/ApplicationMenu.java b/java/com/cowlark/fluxengine/gui/ApplicationMenu.java new file mode 100644 index 000000000..8a3a46b0c --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/ApplicationMenu.java @@ -0,0 +1,146 @@ +package com.cowlark.fluxengine.gui; + +import static swingtree.UIFactoryMethods.menu; +import static swingtree.UIFactoryMethods.menuItem; +import static swingtree.UIFactoryMethods.of; +import static swingtree.UIFactoryMethods.separator; + +import swingtree.UI; +import swingtree.UIForMenuItem; +import javax.swing.AbstractAction; +import javax.swing.Action; +import javax.swing.JMenuItem; +import javax.swing.KeyStroke; +import javax.swing.UIManager; +import javax.swing.text.DefaultEditorKit; +import javax.swing.text.JTextComponent; +import java.awt.KeyboardFocusManager; +import java.awt.Toolkit; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; + +public class ApplicationMenu +{ + public static UI.MenuBar createMenu() + { + installMacAboutHandler(); + + return of(new UI.MenuBar()).add(menu("File").add(menuItem("About FluxEngine...").onClick(it -> UiUtils.fireAction(new AboutAction(), + it.getComponent()))) + .add(separator()) + .add(menuItem("Exit").onClick(it -> System.exit(0)))) + .add(menu("Edit").add(actionMenuItem( + "Cut", + "cut", + new DefaultEditorKit.CutAction(), + shortcut(KeyEvent.VK_X))) + .add(actionMenuItem( + "Copy", + "copy", + new DefaultEditorKit.CopyAction(), + shortcut(KeyEvent.VK_C))) + .add(actionMenuItem( + "Paste", + "paste", + new DefaultEditorKit.PasteAction(), + shortcut(KeyEvent.VK_V))) + .add(actionMenuItem( + "Delete", + "delete", + new DeleteAction(), + shortcut(KeyEvent.VK_DELETE)))) + .get(UI.MenuBar.class); + + } + + /* On macOS, wires the application menu's About item to AboutAction. The + * com.apple.eawt API is macOS-only, so this is done reflectively to keep + * the code compiling on other platforms. */ + private static void installMacAboutHandler() + { + if (!System.getProperty("os.name").toLowerCase().contains("mac")) + return; + + try + { + Class applicationClass = Class.forName("com.apple.eawt.Application"); + Class aboutHandlerClass = Class.forName("com.apple.eawt.AboutHandler"); + + Object application = applicationClass.getMethod("getApplication").invoke(null); + Object handler = java.lang.reflect.Proxy.newProxyInstance( + ApplicationMenu.class.getClassLoader(), + new Class[]{aboutHandlerClass}, + (proxy, method, args) -> { + if (method.getName().equals("handleAbout")) + new AboutAction().actionPerformed(null); + return null; + }); + + applicationClass.getMethod("setAboutHandler", aboutHandlerClass) + .invoke(application, handler); + } catch (ReflectiveOperationException e) + { + /* The Mac integration isn't available; ignore. */ + } + } + + /* Returns a platform-standard menu accelerator KeyStroke for the given key + * code (Cmd on macOS, Ctrl elsewhere). */ + static KeyStroke shortcut(int keyCode) + { + return KeyStroke.getKeyStroke( + keyCode, + Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx()); + } + + /* Returns the standard platform icon for the given action, or null if the + * look-and-feel doesn't provide one. */ + static javax.swing.Icon actionIcon(String name) + { + return UIManager.getIcon("Actions." + name); + } + + /* Builds a menu item bound to the given action, setting the label, icon, + * and accelerator from the action's properties. */ + static UIForMenuItem actionMenuItem(String name, + String iconName, + Action action, + KeyStroke keyStroke) + { + action.putValue(Action.NAME, name); + javax.swing.Icon icon = actionIcon(iconName); + if (icon != null) + action.putValue(Action.SMALL_ICON, icon); + action.putValue(Action.ACCELERATOR_KEY, keyStroke); + return menuItem(name).peek(item -> item.setAction(action)); + } + + /* Returns the text component which currently has keyboard focus, if any. */ + static JTextComponent focusedTextComponent() + { + java.awt.Component focusOwner = + KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(); + return focusOwner instanceof JTextComponent component ? component : null; + } + + /* An action which deletes the selected content of the focused text + * component. */ + static class DeleteAction extends AbstractAction + { + @Override + public void actionPerformed(ActionEvent e) + { + JTextComponent component = focusedTextComponent(); + if (component == null) + return; + + Action delete = component.getActionMap().get(DefaultEditorKit.deleteNextCharAction); + if (delete != null) + delete.actionPerformed(new ActionEvent( + component, + ActionEvent.ACTION_PERFORMED, + null)); + } + } + +} \ No newline at end of file diff --git a/java/com/cowlark/fluxengine/gui/BUILD.bazel b/java/com/cowlark/fluxengine/gui/BUILD.bazel new file mode 100644 index 000000000..a5ef6a376 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/BUILD.bazel @@ -0,0 +1,25 @@ +load("@rules_java//java:defs.bzl", "java_binary", "java_library", "java_plugin") + +package(default_visibility = ["//visibility:public"]) + +java_plugin( + name = "lombok_plugin", + generates_api = True, + processor_class = "lombok.launch.AnnotationProcessorHider$AnnotationProcessor", + deps = ["@maven//:org_projectlombok_lombok"], +) + +java_library( + name = "gui", + srcs = glob(["*.java"]), + plugins = [":lombok_plugin"], + deps = [ + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/data", + "@maven//:com_formdev_flatlaf", + "@maven//:com_google_guava_guava", + "@maven//:io_github_globaltcad_sprouts", + "@maven//:io_github_globaltcad_swing_tree", + "@maven//:org_projectlombok_lombok", + ], +) diff --git a/java/com/cowlark/fluxengine/gui/ConfigurationPanel.java b/java/com/cowlark/fluxengine/gui/ConfigurationPanel.java new file mode 100644 index 000000000..9a6b30059 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/ConfigurationPanel.java @@ -0,0 +1,70 @@ +package com.cowlark.fluxengine.gui; + +import static com.google.common.collect.ImmutableMap.toImmutableMap; +import static swingtree.UI.label; +import static swingtree.UI.of; +import static swingtree.UI.panel; +import static swingtree.UIFactoryMethods.comboBox; +import static swingtree.UIFactoryMethods.separator; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.data.Formats; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import sprouts.From; +import sprouts.Pair; +import sprouts.Viewable; +import swingtree.UIForPanel; +import javax.swing.JPanel; + +public class ConfigurationPanel extends JPanel +{ + private static ImmutableMap formatData = Formats.all() + .stream() + .map(it -> Pair.of(it, Formats.get(it))) + .filter(p -> !p.second().getIsExtension()) + .collect(toImmutableMap(Pair::first, Pair::second)); + + private final ImagerViewModel model; + + public ConfigurationPanel(ImagerViewModel model) + { + this.model = model; + + /* Rebuild whenever the format changes (and once at startup). */ + Viewable.cast(model.getFormat()).onChange(From.ALL, it -> rebuildUi()); + rebuildUi(); + } + + /* Removes the existing UI and recreates it. */ + private void rebuildUi() + { + removeAll(); + + UIForPanel panel = of(this).withLayout("wrap 2, insets 5"); + + panel = panel.add("span 2, growx, wrap", namedSeparator("Format properties")) + .add(label("Format:")) + .add( + "growx, pushx", comboBox( + model.getFormat(), + ImmutableList.copyOf(formatData.keySet()), + ConfigurationPanel::formatRenderer).onSelection(it -> model.getFormat() + .set(From.VIEW, (String) it.get().getSelectedItem()))) + .add("span 2, growx, wrap", namedSeparator("Device properties")) + .add(label("Device:")) + .add("growx, pushx", comboBox("1", "2")); + } + + private static UIForPanel namedSeparator(String label) + { + return panel("fillx, insets 5 0").add("w 10!", separator()) + .add(label(label)) + .add("growx, pushx", separator()); + } + + private static String formatRenderer(String format) + { + return formatData.get(format).getShortname(); + } +} diff --git a/java/com/cowlark/fluxengine/gui/Gui.java b/java/com/cowlark/fluxengine/gui/Gui.java new file mode 100644 index 000000000..0ee54deb3 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/Gui.java @@ -0,0 +1,40 @@ +package com.cowlark.fluxengine.gui; + +import com.formdev.flatlaf.FlatDarkLaf; +import com.google.common.collect.ImmutableList; +import swingtree.threading.EventProcessor; +import javax.swing.UIManager; +import java.util.prefs.Preferences; + +/** + * The FluxEngine GUI, ported from src/gui/main.cc. + */ +public class Gui +{ + private final Preferences preferences = Preferences.userNodeForPackage(Gui.class); + private PreferencesReaderWriter preferencesReaderWriter = + new PreferencesReaderWriter(preferences); + private ImagerViewModel model = new ImagerViewModel(preferencesReaderWriter); + + public void run(ImmutableList args) throws Exception + { + UIManager.setLookAndFeel(new FlatDarkLaf()); + System.setProperty("apple.laf.useScreenMenuBar", "true"); + + ApplicationFrame frame = new ApplicationFrame(model); + frame.show(); + + EventProcessor.DECOUPLED.join(); + } + + public static void main(String[] args) + { + try + { + new Gui().run(ImmutableList.copyOf(args)); + } catch (Exception e) + { + throw new RuntimeException(e); + } + } +} diff --git a/java/com/cowlark/fluxengine/gui/ImagePanel.java b/java/com/cowlark/fluxengine/gui/ImagePanel.java new file mode 100644 index 000000000..1e3158e6f --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/ImagePanel.java @@ -0,0 +1,10 @@ +package com.cowlark.fluxengine.gui; + +import javax.swing.JPanel; + +public class ImagePanel extends JPanel +{ + public ImagePanel() + { + } +} diff --git a/java/com/cowlark/fluxengine/gui/ImagerViewModel.java b/java/com/cowlark/fluxengine/gui/ImagerViewModel.java new file mode 100644 index 000000000..4191fddd1 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/ImagerViewModel.java @@ -0,0 +1,57 @@ +package com.cowlark.fluxengine.gui; + +import static com.cowlark.fluxengine.gui.PreferencesReaderWriter.FORMAT; + +import com.cowlark.fluxengine.data.Image; +import com.google.common.collect.ImmutableMap; +import lombok.Getter; +import sprouts.From; +import sprouts.Var; +import sprouts.Viewable; +import swingtree.ComponentDelegate; +import javax.swing.JButton; +import java.awt.event.ActionEvent; + +public class ImagerViewModel +{ + private final PreferencesReaderWriter preferencesReaderWriter; + + @Getter private Var statusMessage = Var.of("Ready"); + @Getter private Var format; + @Getter private Var> options = Var.of(ImmutableMap.of()); + @Getter private Var diskImage = Var.of(new Image()); + @Getter private Var busy = Var.of(false); + + ImagerViewModel(PreferencesReaderWriter preferencesReaderWriter) + { + this.preferencesReaderWriter = preferencesReaderWriter; + + format = Var.of(preferencesReaderWriter.getPreference(FORMAT, "ibm")); + options.set(preferencesReaderWriter.getOptionsForFormat(format.get())); + + /* Viewable.cast reinterprets the property itself as a Viewable, so the + * listener lives exactly as long as the property (unlike view(), which + * returns a weakly-held view that must be kept in a field). */ + Viewable.cast(format).onChange( + From.VIEW, + it -> preferencesReaderWriter.setPreference( + FORMAT, + it.currentValue().orElseThrowUnchecked())); + } + + void onReadDisk(ComponentDelegate delegate) + { + } + + void onRereadDisk(ComponentDelegate delegate) + { + } + + void onWriteDisk(ComponentDelegate delegate) + { + } + + void onEmergencyStop(ComponentDelegate delegate) + { + } +} diff --git a/java/com/cowlark/fluxengine/gui/LogPanel.java b/java/com/cowlark/fluxengine/gui/LogPanel.java new file mode 100644 index 000000000..277ce1142 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/LogPanel.java @@ -0,0 +1,10 @@ +package com.cowlark.fluxengine.gui; + +import javax.swing.JPanel; + +public class LogPanel extends JPanel +{ + public LogPanel() + { + } +} diff --git a/java/com/cowlark/fluxengine/gui/PreferencesReaderWriter.java b/java/com/cowlark/fluxengine/gui/PreferencesReaderWriter.java new file mode 100644 index 000000000..8c236d486 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/PreferencesReaderWriter.java @@ -0,0 +1,64 @@ +package com.cowlark.fluxengine.gui; + +import static com.google.common.collect.ImmutableMap.toImmutableMap; + +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableMap; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.prefs.Preferences; +import java.util.stream.Collectors; + +public class PreferencesReaderWriter +{ + static final String FORMAT = "format"; + + private final Preferences preferences; + + PreferencesReaderWriter(Preferences preferences) + { + this.preferences = preferences; + } + + ImmutableMap getOptionsForFormat(String format) + { + String optionsString = preferences.get("format_" + format, ""); + Map rawMap = Splitter.on('&') + .omitEmptyStrings() + .trimResults() + .withKeyValueSeparator(Splitter.on('=').limit(2)) + .split(optionsString); + + // Decode URL-encoded keys and values + return rawMap.entrySet().stream().collect(toImmutableMap( + e -> URLDecoder.decode(e.getKey(), StandardCharsets.UTF_8), + e -> URLDecoder.decode(e.getValue(), StandardCharsets.UTF_8), + (existing, replacement) -> existing)); + } + + void setOptionsForFormat(String format, ImmutableMap options) + { + String optionsString = options.entrySet() + .stream() + .map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue())) + .collect(Collectors.joining("&")); + preferences.put("format_" + format, optionsString); + } + + String getPreference(String name, String defaultValue) + { + return preferences.get(name, defaultValue); + } + + void setPreference(String name, String value) + { + preferences.put(name, value); + } + + private static String encode(String value) + { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/java/com/cowlark/fluxengine/gui/StatusbarPanel.java b/java/com/cowlark/fluxengine/gui/StatusbarPanel.java new file mode 100644 index 000000000..4b71c49d3 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/StatusbarPanel.java @@ -0,0 +1,20 @@ +package com.cowlark.fluxengine.gui; + +import static swingtree.UIFactoryMethods.button; +import static swingtree.UIFactoryMethods.label; +import static swingtree.UIFactoryMethods.of; + +import java.awt.Color; +import javax.swing.JPanel; + +public class StatusbarPanel extends JPanel +{ + StatusbarPanel(ImagerViewModel model) + { + of(this).withLayout("fillx, insets 2").add(label(model.getStatusMessage())).add( + "right", + button("Stop").isEnabledIf(model.getBusy()) + .withForeground(Color.RED) + .onClick(model::onEmergencyStop)); + } +} diff --git a/java/com/cowlark/fluxengine/gui/SummaryPanel.java b/java/com/cowlark/fluxengine/gui/SummaryPanel.java new file mode 100644 index 000000000..d18f37a3e --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/SummaryPanel.java @@ -0,0 +1,10 @@ +package com.cowlark.fluxengine.gui; + +import javax.swing.JPanel; + +public class SummaryPanel extends JPanel +{ + public SummaryPanel() + { + } +} diff --git a/java/com/cowlark/fluxengine/gui/UiUtils.java b/java/com/cowlark/fluxengine/gui/UiUtils.java new file mode 100644 index 000000000..d1e009faf --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/UiUtils.java @@ -0,0 +1,18 @@ +package com.cowlark.fluxengine.gui; + +import javax.swing.Action; +import java.awt.event.ActionEvent; + +public class UiUtils +{ + /* Fires the given action with the clicked component as its source, so that + * actions which resolve their target from the event source work correctly. + */ + static void fireAction(Action action, java.awt.Component source) + { + action.actionPerformed(new ActionEvent( + source, + ActionEvent.ACTION_PERFORMED, + (String) action.getValue(Action.ACTION_COMMAND_KEY))); + } +} diff --git a/java/com/cowlark/fluxengine/gui/VisualiserPanel.java b/java/com/cowlark/fluxengine/gui/VisualiserPanel.java new file mode 100644 index 000000000..ea21ded08 --- /dev/null +++ b/java/com/cowlark/fluxengine/gui/VisualiserPanel.java @@ -0,0 +1,10 @@ +package com.cowlark.fluxengine.gui; + +import javax.swing.JPanel; + +public class VisualiserPanel extends JPanel +{ + public VisualiserPanel() + { + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/BUILD.bazel b/java/com/cowlark/fluxengine/imagereader/BUILD.bazel new file mode 100644 index 000000000..039f88525 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/BUILD.bazel @@ -0,0 +1,36 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "imagereader_proto", + srcs = ["imagereader.proto"], + strip_import_prefix = "/java/", + deps = ["//java/com/cowlark/fluxengine/config:common_proto"], +) + +java_proto_library( + name = "imagereader_java_proto", + deps = [":imagereader_proto"], +) + +java_library( + name = "imagereader", + srcs = glob(["*.java"]), + deps = [ + ":imagereader_java_proto", + "//java/com/cowlark/fluxengine/arch:arch_java_proto", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/config:drive_java_proto", + "//java/com/cowlark/fluxengine/config:layout_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "//java/com/cowlark/fluxengine/encoders:encoders_java_proto", + "//java/com/cowlark/fluxengine/external", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "@maven//:com_google_guava_guava", + ], +) diff --git a/java/com/cowlark/fluxengine/imagereader/D64ImageReader.java b/java/com/cowlark/fluxengine/imagereader/D64ImageReader.java new file mode 100644 index 000000000..ec89e977f --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/D64ImageReader.java @@ -0,0 +1,80 @@ +package com.cowlark.fluxengine.imagereader; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.Logger; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Reads a D64 (Commodore 1541) sector image, ported from + * lib/imagereader/d64imagereader.cc. + */ +public class D64ImageReader extends ImageReader +{ + public D64ImageReader(ImageReaderProto config) + { + super(config); + } + + private static int sectorsPerTrack(int track) + { + if (track < 17) + return 21; + if (track < 24) + return 19; + if (track < 30) + return 18; + return 17; + } + + @Override + public Image readImage() + { + Bytes data; + try + { + data = new Bytes(Files.readAllBytes(Path.of(config.getFilename()))); + } catch (IOException e) + { + throw new FluxEngineException("cannot open input file"); + } + int inputFileSize = data.size(); + + int numCylinders = 39; + int numHeads = 1; + + Logger.logf("D64: reading image with " + numCylinders + " tracks, " + numHeads + " heads"); + + int offset = 0; + + Image image = new Image(); + for (int track = 0; track < 40; track++) + { + int numSectors = sectorsPerTrack(track); + for (int head = 0; head < numHeads; head++) + { + for (int sectorId = 0; sectorId < numSectors; sectorId++) + { + Sector sector = image.put(track, head, sectorId); + if (offset < inputFileSize) + { /* still data available sector OK */ + sector.status = Sector.Status.OK; + sector.data = data.slice(offset, 256); + offset += 256; + } else + { /* no more data in input file. Write sectors with status: + * DATA_MISSING */ + sector.status = Sector.Status.DATA_MISSING; + } + } + } + } + + image.calculateSize(); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/D88ImageReader.java b/java/com/cowlark/fluxengine/imagereader/D88ImageReader.java new file mode 100644 index 000000000..72bf71425 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/D88ImageReader.java @@ -0,0 +1,217 @@ +package com.cowlark.fluxengine.imagereader; + +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.core.Logger; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.external.FormatType; +import com.cowlark.fluxengine.ibm.IbmEncoderProto; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/* Reader based on this partial documentation of the D88 format: + * https://www.pc98.org/project/doc/d88.html + */ +public class D88ImageReader extends ImageReader +{ + public D88ImageReader(ImageReaderProto config) + { + super(config); + } + + @Override + public Image readImage() + { + Bytes data; + try + { + data = new Bytes(Files.readAllBytes(Path.of(config.getFilename()))); + } catch (IOException e) + { + throw new FluxEngineException("cannot open input file"); + } + + /* The DIM header technically has a bit field for sectors present, + * however it is currently ignored by this reader */ + Bytes header = data.slice(0, 0x24); /* read first entry of track table as well */ + + String diskName = header.slice(0, 0x16).toString(); + if (diskName.length() > 0 && diskName.charAt(0) != 0) + Logger.logf("D88: disk name: " + diskName); + + ByteReader headerReader = new ByteReader(header); + + int mediaFlag = headerReader.seek(0x1b).read8(); + int fileSize = data.size(); + + int diskSize = headerReader.seek(0x1c).readLe32(); + + if (diskSize > fileSize) + Logger.logf("D88: found multiple disk images. Only using first"); + + int trackTableEnd = headerReader.seek(0x20).readLe32(); + int trackTableSize = trackTableEnd - 0x20; + + ByteReader trackTableReader = new ByteReader(data.slice(0x20, trackTableSize)); + + ConfigProto.Builder extra = ConfigProto.newBuilder(); + IbmEncoderProto.Builder ibm = extra.getEncoderBuilder().getIbmBuilder(); + int clockRate = 500; + if (mediaFlag == 0x20) + { + extra.getDriveBuilder().setHighDensity(true); + extra.getLayoutBuilder().setFormatType(FormatType.FORMATTYPE_80TRACK); + } else + { + clockRate = 300; + extra.getDriveBuilder().setHighDensity(false); + extra.getLayoutBuilder().setFormatType(FormatType.FORMATTYPE_40TRACK); + } + + com.cowlark.fluxengine.config.LayoutProto.Builder layout = extra.getLayoutBuilder(); + Image image = new Image(); + ByteReader br = new ByteReader(data); + br.seek(0x20 + trackTableSize); + for (int track = 0; track < trackTableSize / 4; track++) + { + int trackOffset = trackTableReader.seek(track * 4).readLe32(); + if (trackOffset == 0) + continue; + + int currentTrackTrack = -1; + int currentSectorsInTrack = + 0xffff; /* don't know # of sectors until we read the first one */ + int trackSectorSize = -1; + int trackMfm = -1; + + IbmEncoderProto.TrackdataProto.Builder trackdata = ibm.addTrackdataBuilder(); + trackdata.setTargetClockPeriodUs(1e3 / clockRate); + trackdata.setTargetRotationalPeriodMs(167); + + com.cowlark.fluxengine.config.LayoutProto.LayoutdataProto.Builder layoutdata = + layout.addLayoutdataBuilder(); + com.cowlark.fluxengine.config.SectorListProto.Builder physical = + layoutdata.getPhysicalBuilder(); + + for (int sectorInTrack = 0; sectorInTrack < currentSectorsInTrack; sectorInTrack++) + { + ByteReader sectorHeaderReader = new ByteReader(br.read(0x10)); + int cyl = sectorHeaderReader.seek(0).read8(); + int head = sectorHeaderReader.seek(1).read8(); + int sectorId = sectorHeaderReader.seek(2).read8(); + int sectorSize = 128 << sectorHeaderReader.seek(3).read8(); + int sectorsInTrack = sectorHeaderReader.seek(4).readLe16(); + int fm = sectorHeaderReader.seek(6).read8(); + int ddam = sectorHeaderReader.seek(7).read8(); + int fddStatusCode = sectorHeaderReader.seek(8).read8(); + int rpm = sectorHeaderReader.seek(13).read8(); + int dataLength = sectorHeaderReader.seek(14).readLe16(); + if (dataLength < sectorSize) + { + dataLength = sectorSize; + } + /* D88 provides much more sector information that is currently + * ignored */ + if (ddam != 0) + throw new FluxEngineException("D88: nonzero ddam currently unsupported"); + if (rpm != 0) + throw new FluxEngineException( + "D88: 1.44MB 300rpm formats currently " + "unsupported"); + if (fddStatusCode != 0) + throw new FluxEngineException( + "D88: nonzero fdd status codes are currently unsupported"); + if (currentSectorsInTrack == 0xffff) + { + currentSectorsInTrack = sectorsInTrack; + } else if (currentSectorsInTrack != sectorsInTrack) + { + throw new FluxEngineException("D88: mismatched number of sectors in track"); + } + if (currentTrackTrack < 0) + { + currentTrackTrack = cyl; + } else if (currentTrackTrack != cyl) + { + throw new FluxEngineException( + "D88: all sectors in a track must belong to the same track"); + } + if (trackSectorSize < 0) + { + trackSectorSize = sectorSize; + /* this is the first sector we've read, use its settings for + * per-track data */ + + layoutdata.setTrack(cyl); + layoutdata.setSide(head); + layoutdata.setSectorSize(sectorSize); + + trackdata.setTrack(cyl); + trackdata.setHead(head); + trackdata.setUseFm(fm != 0); + if (fm != 0) + { + trackdata.setGapFillByte(0xffff); + trackdata.setIdamByte(0xf57e); + trackdata.setDamByte(0xf56f); + } + /* create timings to approximately match N88-BASIC */ + if (clockRate == 300) + { + if (sectorSize <= 256) + { + trackdata.setGap0(0x1b); + trackdata.setGap2(0x14); + trackdata.setGap3(0x1b); + } + } else + { + if (sectorSize <= 128) + { + trackdata.setGap0(0x1b); + trackdata.setGap2(0x09); + trackdata.setGap3(0x1b); + } else if (sectorSize <= 256) + { + trackdata.setGap0(0x36); + trackdata.setGap3(0x36); + } + } + } else if (trackSectorSize != sectorSize) + { + throw new FluxEngineException( + "D88: multiple sector sizes per track are currently unsupported"); + } + + Bytes sectorData = br.read(sectorSize); + br.skip(dataLength - sectorSize); + physical.addSector(sectorId); + Sector sector = image.put(cyl, head, sectorId); + sector.status = Sector.Status.OK; + sector.data = sectorData; + } + + if (mediaFlag != 0x20) + { + IbmEncoderProto.TrackdataProto.Builder trackdata2 = ibm.addTrackdataBuilder(); + trackdata2.setTargetClockPeriodUs(1e3 / clockRate); + trackdata2.setTargetRotationalPeriodMs(167); + } + } + + image.calculateSize(); + Geometry geometry = image.getGeometry(); + Logger.logf( + "D88: read " + geometry.numCylinders + " tracks, " + geometry.numHeads + " sides"); + + layout.setTracks(geometry.numCylinders); + layout.setSides(geometry.numHeads); + + extraConfig = extra.build(); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/DimImageReader.java b/java/com/cowlark/fluxengine/imagereader/DimImageReader.java new file mode 100644 index 000000000..25ab19eb3 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/DimImageReader.java @@ -0,0 +1,154 @@ +package com.cowlark.fluxengine.imagereader; + +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.core.Logger; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.EncoderProto; +import com.cowlark.fluxengine.ibm.IbmEncoderProto; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/* Reader based on this partial documentation of the DIM format: + * https://www.pc98.org/project/doc/dim.html + */ +public class DimImageReader extends ImageReader +{ + public DimImageReader(ImageReaderProto config, ConfigProto fullConfig) + { + super(config, fullConfig); + } + + @Override + public Image readImage() + { + Bytes data; + try + { + data = new Bytes(Files.readAllBytes(Path.of(config.getFilename()))); + } catch (IOException e) + { + throw new FluxEngineException("cannot open input file"); + } + + Bytes header = data.slice(0, 256); + if (!header.slice(0xAB, 13).equals(new Bytes("DIFC HEADER "))) + throw new FluxEngineException("DIM: could not find DIM header, is this a DIM file?"); + + /* the DIM header technically has a bit field for sectors present, + * however it is currently ignored by this reader */ + + int mediaByte = header.getByte(0) & 0xff; + int tracks; + int sectorsPerTrack; + int sectorSize; + switch (mediaByte) + { + case 0: + tracks = 77; + sectorsPerTrack = 8; + sectorSize = 1024; + break; + case 1: + tracks = 80; + sectorsPerTrack = 9; + sectorSize = 1024; + break; + case 2: + tracks = 80; + sectorsPerTrack = 15; + sectorSize = 512; + break; + case 3: + tracks = 80; + sectorsPerTrack = 18; + sectorSize = 512; + break; + default: + throw new FluxEngineException("DIM: unsupported media byte"); + } + + Image image = new Image(); + int trackCount = 0; + ByteReader br = new ByteReader(data.slice(256)); + for (int track = 0; track < tracks; track++) + { + if (br.eof()) + break; + + for (int side = 0; side < 2; side++) + { + for (int sectorId = 1; sectorId <= sectorsPerTrack; sectorId++) + { + Bytes sectorData = br.read(sectorSize); + + Sector sector = image.put(track, side, sectorId); + sector.status = Sector.Status.OK; + sector.data = sectorData; + } + } + + trackCount++; + } + + ConfigProto.Builder extra = ConfigProto.newBuilder(); + com.cowlark.fluxengine.config.LayoutProto.Builder layout = extra.getLayoutBuilder(); + if (fullConfig.getEncoder().getFormatCase() == EncoderProto.FormatCase.FORMAT_NOT_SET) + { + IbmEncoderProto.Builder ibm = extra.getEncoderBuilder().getIbmBuilder(); + IbmEncoderProto.TrackdataProto.Builder trackdata = ibm.addTrackdataBuilder(); + trackdata.setTargetClockPeriodUs(2); + + com.cowlark.fluxengine.config.LayoutProto.LayoutdataProto.Builder layoutdata = + layout.addLayoutdataBuilder(); + com.cowlark.fluxengine.config.SectorListProto.Builder physical = + layoutdata.getPhysicalBuilder(); + switch (mediaByte) + { + case 0x00: + Logger.logf("DIM: automatically setting format to 1.2MB (1024 byte sectors)"); + trackdata.setTargetRotationalPeriodMs(167); + layoutdata.setSectorSize(1024); + for (int i = 0; i < 9; i++) + physical.addSector(i); + break; + case 0x02: + Logger.logf("DIM: automatically setting format to 1.2MB (512 byte sectors)"); + trackdata.setTargetRotationalPeriodMs(167); + layoutdata.setSectorSize(512); + for (int i = 0; i < 15; i++) + physical.addSector(i); + break; + case 0x03: + Logger.logf("DIM: automatically setting format to 1.44MB"); + trackdata.setTargetRotationalPeriodMs(200); + layoutdata.setSectorSize(512); + for (int i = 0; i < 18; i++) + physical.addSector(i); + break; + default: + throw new FluxEngineException(String.format( + "DIM: unknown media byte 0x%02x, could not determine write " + + "profile automatically", mediaByte)); + } + + extra.getDecoderBuilder().getIbmBuilder(); + } + + image.calculateSize(); + Geometry geometry = image.getGeometry(); + Logger.logf("DIM: read " + geometry.numCylinders + " tracks, " + geometry.numHeads + + " sides, " + (data.size() - 256) / 1024 + " kB total"); + + layout.setTracks(geometry.numCylinders); + layout.setSides(geometry.numHeads); + + extraConfig = extra.build(); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/DiskCopyImageReader.java b/java/com/cowlark/fluxengine/imagereader/DiskCopyImageReader.java new file mode 100644 index 000000000..01bc3ce59 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/DiskCopyImageReader.java @@ -0,0 +1,137 @@ +package com.cowlark.fluxengine.imagereader; + +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.core.Logger; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Reads a DiskCopy (Mac) sector image, ported from + * lib/imagereader/diskcopyimagereader.cc. + */ +public class DiskCopyImageReader extends ImageReader +{ + public DiskCopyImageReader(ImageReaderProto config) + { + super(config); + } + + private static int sectorsPerTrack(int track, int numSectors, boolean mfm) + { + if (mfm) + return numSectors; + + if (track < 16) + return 12; + if (track < 32) + return 11; + if (track < 48) + return 10; + if (track < 64) + return 9; + return 8; + } + + @Override + public Image readImage() + { + Bytes data; + try + { + data = new Bytes(Files.readAllBytes(Path.of(config.getFilename()))); + } catch (IOException e) + { + throw new FluxEngineException("cannot open input file"); + } + ByteReader br = new ByteReader(data); + + br.seek(1); + String label = br.read(data.getByte(0) & 0xff).toString(); + + br.seek(0x40); + int dataSize = br.readBe32(); + + br.seek(0x50); + int encoding = br.read8(); + int formatByte = br.read8(); + + int numCylinders = 80; + int numHeads = 2; + int numSectors = 0; + boolean mfm = false; + + switch (encoding) + { + case 0: /* GCR CLV 400kB */ + numHeads = 1; + break; + + case 1: /* GCR CLV 800kB */ + break; + + case 2: /* MFM CAV 720kB */ + numSectors = 9; + mfm = true; + break; + + case 3: /* MFM CAV 1440kB */ + numSectors = 18; + mfm = true; + break; + + default: + throw new FluxEngineException( + "don't understand DiskCopy disks of type " + encoding); + } + + Logger.logf( + "DC42: reading image with " + numCylinders + " tracks, " + numHeads + " heads; " + + (mfm ? "MFM" : "GCR") + "; " + label); + + int dataPtr = 0x54; + int tagPtr = dataPtr + dataSize; + + Image image = new Image(); + for (int track = 0; track < numCylinders; track++) + { + int sectorCount = sectorsPerTrack(track, numSectors, mfm); + for (int head = 0; head < numHeads; head++) + { + for (int sectorId = 0; sectorId < sectorCount; sectorId++) + { + br.seek(dataPtr); + Bytes payload = br.read(512); + dataPtr += 512; + + br.seek(tagPtr); + Bytes tag = br.read(12); + tagPtr += 12; + + Sector sector = image.put(track, head, sectorId); + sector.status = Sector.Status.OK; + sector.data = payload.concat(tag); + } + } + } + + ConfigProto.Builder extra = ConfigProto.newBuilder(); + extra.getLayoutBuilder().addLayoutdataBuilder().setSectorSize(524); + extraConfig = extra.build(); + + Geometry geometry = new Geometry(); + geometry.numCylinders = numCylinders; + geometry.numHeads = numHeads; + geometry.numSectors = 12; + geometry.sectorSize = 512 + 12; + geometry.irregular = true; + image.setGeometry(geometry); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/FdiImageReader.java b/java/com/cowlark/fluxengine/imagereader/FdiImageReader.java new file mode 100644 index 000000000..9c1f2cc96 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/FdiImageReader.java @@ -0,0 +1,124 @@ +package com.cowlark.fluxengine.imagereader; + +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.core.Logger; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.EncoderProto; +import com.cowlark.fluxengine.ibm.IbmEncoderProto; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/* Reader based on this partial documentation of the FDI format: + * https://www.pc98.org/project/doc/hdi.html + */ +public class FdiImageReader extends ImageReader +{ + public FdiImageReader(ImageReaderProto config, ConfigProto fullConfig) + { + super(config, fullConfig); + } + + @Override + public Image readImage() + { + Bytes data; + try + { + data = new Bytes(Files.readAllBytes(Path.of(config.getFilename()))); + } catch (IOException e) + { + throw new FluxEngineException("cannot open input file"); + } + + ByteReader headerReader = new ByteReader(data.slice(0, 32)); + if (headerReader.seek(0).readLe32() != 0) + throw new FluxEngineException("FDI: could not find FDI header, is this a FDI file?"); + + /* we currently don't use fddType but it could be used to automatically + * select profile parameters in the future */ + int fddType = headerReader.seek(4).readLe32(); + int headerSize = headerReader.seek(0x08).readLe32(); + int sectorSize = headerReader.seek(0x10).readLe32(); + int sectorsPerTrack = headerReader.seek(0x14).readLe32(); + int sides = headerReader.seek(0x18).readLe32(); + int tracks = headerReader.seek(0x1c).readLe32(); + + ByteReader br = new ByteReader(data.slice(headerSize)); + + Image image = new Image(); + int trackCount = 0; + for (int track = 0; track < tracks; track++) + { + if (br.eof()) + break; + + for (int side = 0; side < sides; side++) + { + for (int sectorId = 1; sectorId <= sectorsPerTrack; sectorId++) + { + Bytes sectorData = br.read(sectorSize); + + Sector sector = image.put(track, side, sectorId); + sector.status = Sector.Status.OK; + sector.data = sectorData; + } + } + + trackCount++; + } + + ConfigProto.Builder extra = ConfigProto.newBuilder(); + com.cowlark.fluxengine.config.LayoutProto.Builder layout = extra.getLayoutBuilder(); + if (fullConfig.getEncoder().getFormatCase() == EncoderProto.FormatCase.FORMAT_NOT_SET) + { + IbmEncoderProto.Builder ibm = extra.getEncoderBuilder().getIbmBuilder(); + IbmEncoderProto.TrackdataProto.Builder trackdata = ibm.addTrackdataBuilder(); + trackdata.setTargetClockPeriodUs(2); + + com.cowlark.fluxengine.config.LayoutProto.LayoutdataProto.Builder layoutdata = + layout.addLayoutdataBuilder(); + com.cowlark.fluxengine.config.SectorListProto.Builder physical = + layoutdata.getPhysicalBuilder(); + switch (fddType) + { + case 0x90: + Logger.logf("FDI: automatically setting format to 1.2MB (1024 byte sectors)"); + trackdata.setTargetRotationalPeriodMs(167); + layoutdata.setSectorSize(1024); + for (int i = 0; i < 9; i++) + physical.addSector(i); + break; + + case 0x30: + Logger.logf("FDI: automatically setting format to 1.44MB"); + trackdata.setTargetRotationalPeriodMs(200); + layoutdata.setSectorSize(512); + for (int i = 0; i < 18; i++) + physical.addSector(i); + break; + + default: + throw new FluxEngineException(String.format( + "FDI: unknown fdd type 0x%02x, could not determine write " + + "profile automatically", fddType)); + } + } + + image.calculateSize(); + Geometry geometry = image.getGeometry(); + Logger.logf("FDI: read " + geometry.numCylinders + " tracks, " + geometry.numHeads + + " sides, " + (data.size() - headerSize) / 1024 + " kB total"); + + layout.setTracks(geometry.numCylinders); + layout.setSides(geometry.numHeads); + + extraConfig = extra.build(); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/ImageReader.java b/java/com/cowlark/fluxengine/imagereader/ImageReader.java new file mode 100644 index 000000000..2b4d09240 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/ImageReader.java @@ -0,0 +1,84 @@ +package com.cowlark.fluxengine.imagereader; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Image; + +/** + * Reads sector images from disk, ported from + * lib/imagereader/imagereader.{h,cc}. + */ +public abstract class ImageReader implements AutoCloseable +{ + protected final ImageReaderProto config; + protected final ConfigProto fullConfig; + protected ConfigProto extraConfig = ConfigProto.getDefaultInstance(); + + public ImageReader(ImageReaderProto config) + { + this(config, ConfigProto.getDefaultInstance()); + } + + public ImageReader(ImageReaderProto config, ConfigProto fullConfig) + { + this.config = config; + this.fullConfig = fullConfig; + } + + public static ImageReader create(ConfigProto config) + { + if (!config.hasImageReader()) + throw new FluxEngineException("no image reader configured"); + return create(config, config.getImageReader()); + } + + public static ImageReader create(ImageReaderProto config) + { + return create(ConfigProto.getDefaultInstance(), config); + } + + public static ImageReader create(ConfigProto fullConfig, ImageReaderProto config) + { + switch (config.getType()) + { + case IMAGETYPE_DIM: + return new DimImageReader(config, fullConfig); + case IMAGETYPE_D88: + return new D88ImageReader(config); + case IMAGETYPE_FDI: + return new FdiImageReader(config, fullConfig); + case IMAGETYPE_IMD: + return new ImdImageReader(config); + case IMAGETYPE_IMG: + return new ImgImageReader(config, fullConfig); + case IMAGETYPE_DISKCOPY: + return new DiskCopyImageReader(config); + case IMAGETYPE_JV3: + return new Jv3ImageReader(config); + case IMAGETYPE_D64: + return new D64ImageReader(config); + case IMAGETYPE_NFD: + return new NfdImageReader(config); + case IMAGETYPE_NSI: + return new NsiImageReader(config); + case IMAGETYPE_TD0: + return new Td0ImageReader(config); + default: + throw new FluxEngineException("bad input file config"); + } + } + + @Override + public void close() throws Exception + { + } + + /* Returns any extra config the image might want to contribute. */ + public ConfigProto getExtraConfig() + { + return extraConfig; + } + + /* Reads the image. */ + public abstract Image readImage(); +} diff --git a/java/com/cowlark/fluxengine/imagereader/ImdImageReader.java b/java/com/cowlark/fluxengine/imagereader/ImdImageReader.java new file mode 100644 index 000000000..1df8600b9 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/ImdImageReader.java @@ -0,0 +1,368 @@ +package com.cowlark.fluxengine.imagereader; + +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.core.Logger; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.encoders.EncoderProto; +import com.cowlark.fluxengine.ibm.IbmEncoderProto; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +public class ImdImageReader extends ImageReader +{ + private static final int SEC_CYL_MAP_FLAG = 0x80; + private static final int SEC_HEAD_MAP_FLAG = 0x40; + private static final int HEAD_MASK = 0x3F; + private static final int END_OF_FILE = 0x1A; + + public ImdImageReader(ImageReaderProto config) + { + super(config); + } + + private static int getModulationAndSpeed(int flags, boolean[] fm) + { + switch (flags) + { + case 0: /* 500 kbps FM */ + fm[0] = true; + return 500; + case 1: /* 300 kbps FM */ + fm[0] = true; + return 300; + case 2: /* 250 kbps FM */ + fm[0] = true; + return 250; + case 3: /* 500 kbps MFM */ + fm[0] = false; + return 500; + case 4: /* 300 kbps MFM */ + fm[0] = false; + return 300; + case 5: /* 250 kbps MFM */ + fm[0] = false; + return 250; + default: + throw new FluxEngineException( + "IMD: don't understand IMD disks with this modulation and speed " + flags); + } + } + + private static int getSectorSize(int flags) + { + switch (flags) + { + case 0: + return 128; + case 1: + return 256; + case 2: + return 512; + case 3: + return 1024; + case 4: + return 2048; + case 5: + return 4096; + case 6: + return 8192; + default: + throw new FluxEngineException("not reachable"); + } + } + + @Override + public Image readImage() + { + Bytes data; + try + { + data = new Bytes(Files.readAllBytes(Path.of(config.getFilename()))); + } catch (IOException e) + { + throw new FluxEngineException("IMD: cannot open input file"); + } + int inputFileSize = data.size(); + ByteReader br = new ByteReader(data); + Image image = new Image(); + int modeValue = 0; + int track = 0; + int head = 0; + int numSectors = 0; + int sectorSizeCode = 0; + + ConfigProto.Builder extra = ConfigProto.newBuilder(); + com.cowlark.fluxengine.config.LayoutProto.Builder layout = extra.getLayoutBuilder(); + + int n = 0; + int headerPtr = 0; + int modulationSpeed = 0; + int sectorSize = 0; + List sectorSkew = new ArrayList<>(); + + /* Read comment */ + StringBuilder comment = new StringBuilder(); + int b; + while ((b = br.read8()) != -1 && b != END_OF_FILE) + { + comment.append((char) b); + n++; + } + headerPtr = n; /* set pointer to after comment */ + Logger.logf("Comment in IMD file: " + comment); + + boolean[] fm = {false}; + int trackSectorSize = -1; + + for (; ; ) + { + if (headerPtr >= inputFileSize - 1) + { + break; + } + /* first read header */ + modeValue = br.read8(); + headerPtr++; + modulationSpeed = getModulationAndSpeed(modeValue, fm); + track = br.read8(); + headerPtr++; + head = br.read8(); + headerPtr++; + numSectors = br.read8(); + headerPtr++; + sectorSizeCode = br.read8(); + headerPtr++; + sectorSize = getSectorSize(sectorSizeCode); + + boolean blnOptionalCylinderMap = false; + boolean blnOptionalHeadMap = false; + List optionalsectorMap = new ArrayList<>(); + List optionalheadMap = new ArrayList<>(); + + /* The Sector Cylinder Map has one entry for each sector, and + * contains the logical Cylinder ID for the corresponding sector in + * the Sector Numbering Map. */ + if ((head & SEC_CYL_MAP_FLAG) != 0) + { + /* Read optional cylinder map */ + for (b = 0; b < numSectors; b++) + { + optionalsectorMap.add(br.read8()); + headerPtr++; + } + blnOptionalCylinderMap = true; + head = head ^ SEC_CYL_MAP_FLAG; + } + + /* Read optional sector head map */ + if ((head & SEC_HEAD_MAP_FLAG) != 0) + { + /* Read optional sector head map */ + for (b = 0; b < numSectors; b++) + { + optionalheadMap.add(br.read8()); + headerPtr++; + } + blnOptionalHeadMap = true; + head = head ^ SEC_HEAD_MAP_FLAG; + } + + /* read sector numbering map */ + sectorSkew.clear(); + boolean blnBase0 = false; /* check what first start number of the sector is */ + for (b = 0; b < numSectors; b++) + { + int t = br.read8(); + if (t == 0x00) + blnBase0 = true; + if (blnBase0) + { + t = t + 1; + } + sectorSkew.add(t); + headerPtr++; + } + + IbmEncoderProto.Builder ibm = extra.getEncoderBuilder().getIbmBuilder(); + IbmEncoderProto.TrackdataProto.Builder trackdata = ibm.addTrackdataBuilder(); + + com.cowlark.fluxengine.config.LayoutProto.LayoutdataProto.Builder layoutdata = + layout.addLayoutdataBuilder(); + + trackdata.setTargetClockPeriodUs(1e3 / modulationSpeed); + trackdata.setTargetRotationalPeriodMs(200); + if (trackSectorSize < 0) + { + trackSectorSize = sectorSize; + /* this is the first sector we've read, use its settings for + * per-track data */ + trackdata.setTrack(track); + trackdata.setHead(head); + trackdata.setUseFm(fm[0]); + + layoutdata.setTrack(track); + layoutdata.setSide(head); + layoutdata.setSectorSize(sectorSize); + } else if (trackSectorSize != sectorSize) + { + throw new FluxEngineException( + "IMD: multiple sector sizes per track are currently unsupported"); + } + + /* read the sectors */ + for (int s = 0; s < numSectors; s++) + { + Bytes sectordata = new Bytes(0); + Bytes compressed = new Bytes(sectorSize); + int sectorId = sectorSkew.get(s); + Sector sector = image.put(track, head, sectorId); + /* read the status of the sector */ + int statusSector = br.read8(); + headerPtr++; + + switch (statusSector) + { + case 0: /* Sector data unavailable - could not be read */ + sector.status = Sector.Status.MISSING; + break; + + case 1: /* Normal data: (Sector Size) bytes follow */ + sectordata = br.read(sectorSize); + headerPtr += sectorSize; + sector.data = sectordata; + sector.status = Sector.Status.OK; + break; + + case 2: /* Compressed: All bytes in sector have same value (xx) */ + compressed.setByte(0, (byte) br.read8()); + headerPtr++; + for (int k = 1; k < sectorSize; k++) + { + br.seek(headerPtr); + compressed.setByte(k, (byte) br.read8()); + } + sector.data = compressed; + sector.status = Sector.Status.OK; + break; + + case 3: /* Normal data with "Deleted-Data address mark" */ + sector.status = Sector.Status.DATA_MISSING; + sectordata = br.read(sectorSize); + headerPtr += sectorSize; + sector.data = sectordata; + break; + + case 4: /* Compressed with "Deleted-Data address mark" */ + compressed.setByte(0, (byte) br.read8()); + headerPtr++; + for (int k = 1; k < sectorSize; k++) + { + br.seek(headerPtr); + compressed.setByte(k, (byte) br.read8()); + } + sector.data = compressed; + sector.status = Sector.Status.DATA_MISSING; + break; + + case 5: /* Normal data read with data error */ + sectordata = br.read(sectorSize); + headerPtr += sectorSize; + sector.status = Sector.Status.BAD_CHECKSUM; + sector.data = sectordata; + break; + + case 6: /* Compressed read with data error */ + compressed.setByte(0, (byte) br.read8()); + headerPtr++; + for (int k = 1; k < sectorSize; k++) + { + br.seek(headerPtr); + compressed.setByte(k, (byte) br.read8()); + } + sector.data = compressed; + sector.status = Sector.Status.BAD_CHECKSUM; + break; + + case 7: /* Deleted data read with data error */ + sectordata = br.read(sectorSize); + headerPtr += sectorSize; + sector.status = Sector.Status.BAD_CHECKSUM; + sector.data = sectordata; + break; + + case 8: /* Compressed, Deleted read with data error */ + compressed.setByte(0, (byte) br.read8()); + headerPtr++; + for (int k = 1; k < sectorSize; k++) + { + br.seek(headerPtr); + compressed.setByte(k, (byte) br.read8()); + } + sector.data = compressed; + sector.status = Sector.Status.BAD_CHECKSUM; + break; + + default: + throw new FluxEngineException(String.format( + "IMD: Don't understand IMD files with sector status %d, " + + "track %d, sector %d", statusSector, track, s)); + } + + if (blnOptionalCylinderMap) + { + sector.location = new com.cowlark.fluxengine.data.LogicalLocation( + optionalsectorMap.get(s), + sector.location.logicalHead(), + sector.location.logicalSector()); + blnOptionalCylinderMap = false; + } else + sector.location = new com.cowlark.fluxengine.data.LogicalLocation( + track, + sector.location.logicalHead(), + sector.location.logicalSector()); + + if (blnOptionalHeadMap) + { + sector.location = + new com.cowlark.fluxengine.data.LogicalLocation( + sector.location.logicalCylinder(), + optionalheadMap.get(s), + sector.location.logicalSector()); + blnOptionalHeadMap = false; + } else + sector.location = + new com.cowlark.fluxengine.data.LogicalLocation( + sector.location.logicalCylinder(), + head, + sector.location.logicalSector()); + } + } + + if (extra.getEncoder().getFormatCase() != EncoderProto.FormatCase.FORMAT_NOT_SET) + Logger.logf("IMD: overriding configured format"); + + image.calculateSize(); + Geometry geometry = image.getGeometry(); + int headSize = numSectors * sectorSize; + int trackSize = headSize * (head + 1); + + Logger.logf("IMD: read " + (track + 1) + " tracks, " + (head + 1) + " heads; " + + (fm[0] ? "FM" : "MFM") + "; " + modulationSpeed + " kbps; " + numSectors + + " sectors; sectorsize " + sectorSize + "; " + (track + 1) * trackSize / 1024 + + " kB total."); + + layout.setTracks(geometry.numCylinders); + layout.setSides(geometry.numHeads); + + extraConfig = extra.build(); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/ImgImageReader.java b/java/com/cowlark/fluxengine/imagereader/ImgImageReader.java new file mode 100644 index 000000000..0fc02014d --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/ImgImageReader.java @@ -0,0 +1,77 @@ +package com.cowlark.fluxengine.imagereader; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.config.LayoutProto; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.Logger; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.DiskLayout; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Reads a raw (non-interleaved) sector image, ported from + * lib/imagereader/imgimagereader.cc. + */ +public class ImgImageReader extends ImageReader +{ + public ImgImageReader(ImageReaderProto config, ConfigProto fullConfig) + { + super(config, fullConfig); + } + + @Override + public Image readImage() + { + LayoutProto layout = fullConfig.getLayout(); + if (!layout.hasTracks() || !layout.hasSides()) + throw new FluxEngineException("IMG: bad configuration; did you remember to set the " + + "tracks, sides and trackdata fields in the layout?"); + + DiskLayout diskLayout = new DiskLayout(fullConfig); + boolean inFilesystemOrder = config.getImg().getFilesystemSectorOrder(); + Image image = new Image(); + + try (InputStream inputFile = Files.newInputStream(Path.of(config.getFilename()))) + { + Iterable locations = inFilesystemOrder ? + diskLayout.logicalLocationsInFilesystemOrder : + diskLayout.logicalLocations; + for (CylinderHead logicalLocation : locations) + { + LogicalTrackLayout ltl = diskLayout.layoutByLogicalLocation.get(logicalLocation); + + Iterable sectorOrder = + inFilesystemOrder ? ltl.filesystemSectorOrder : ltl.naturalSectorOrder; + for (int sectorId : sectorOrder) + { + byte[] buf = new byte[ltl.sectorSize]; + int read = inputFile.read(buf); + if (read == -1) + break; + + Sector sector = + image.put(logicalLocation.cylinder(), logicalLocation.head(), sectorId); + sector.status = Sector.Status.OK; + sector.data = new Bytes(buf); + } + } + } catch (IOException e) + { + throw new FluxEngineException("cannot open input file"); + } + + image.calculateSize(); + Geometry geometry = image.getGeometry(); + Logger.logf("IMG: read " + geometry.numCylinders + " tracks, " + geometry.numHeads + + " sides, " + geometry.totalBytes / 1024 + " kB total from " + config.getFilename()); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/Jv3ImageReader.java b/java/com/cowlark/fluxengine/imagereader/Jv3ImageReader.java new file mode 100644 index 000000000..9e4740b95 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/Jv3ImageReader.java @@ -0,0 +1,118 @@ +package com.cowlark.fluxengine.imagereader; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/* JV3 files are kinda weird. There's a fixed layout for up to 2901 sectors, + * which may appear in any order, followed by the same again for more sectors. + * To find the second data block you need to know the size of the first data + * block, which requires parsing it. + * + * https://www.tim-mann.org/trs80/dskconfig.html + */ +public class Jv3ImageReader extends ImageReader +{ + private static final int JV3_DENSITY = 0x80; /* 1=dden, 0=sden */ + private static final int JV3_DAM = 0x60; /* data address mark code */ + private static final int JV3_SIDE = 0x10; /* 0=side 0, 1=side 1 */ + private static final int JV3_ERROR = 0x08; /* 0=ok, 1=CRC error */ + private static final int JV3_NONIBM = 0x04; /* 0=normal, 1=short */ + private static final int JV3_SIZE = 0x03; /* in used sectors: 0=256,1=128,2=1024,3=512 + in free sectors: 0=512,1=1024,2=128,3=256 */ + + private static final int JV3_FREE = 0xFF; /* in track and sector fields of free sectors */ + private static final int JV3_FREEF = 0xFC; /* in flags field, or'd with size code */ + + public Jv3ImageReader(ImageReaderProto config) + { + super(config); + } + + private static int getSectorSize(int flags) + { + if ((flags & JV3_FREEF) == JV3_FREEF) + { + switch (flags & JV3_SIZE) + { + case 0: + return 512; + case 1: + return 1024; + case 2: + return 128; + case 3: + return 256; + } + } else + { + switch (flags & JV3_SIZE) + { + case 0: + return 256; + case 1: + return 128; + case 2: + return 1024; + case 3: + return 512; + } + } + throw new FluxEngineException("not reachable"); + } + + @Override + public Image readImage() + { + Bytes data; + try + { + data = new Bytes(Files.readAllBytes(Path.of(config.getFilename()))); + } catch (IOException e) + { + throw new FluxEngineException("cannot open input file"); + } + int inputFileSize = data.size(); + int headerPtr = 0; + Image image = new Image(); + for (; ; ) + { + int dataPtr = headerPtr + 2901 * 3 + 1; + if (dataPtr >= inputFileSize) + break; + + for (int i = 0; i < 2901; i++) + { + ByteReader headerReader = new ByteReader(data.slice(headerPtr, 3)); + int track = headerReader.seek(0).read8(); + int sectorId = headerReader.seek(1).read8(); + int flags = headerReader.seek(2).read8(); + int sectorSize = getSectorSize(flags); + if ((flags & JV3_FREEF) != JV3_FREEF) + { + Bytes sectorData = data.slice(dataPtr, sectorSize); + + int head = (flags & JV3_SIDE) != 0 ? 1 : 0; + Sector sector = image.put(track, head, sectorId); + sector.status = Sector.Status.OK; + sector.data = sectorData; + } + + headerPtr += 3; + dataPtr += sectorSize; + } + + /* dataPtr is now pointing at the beginning of the next chunk. */ + + headerPtr = dataPtr; + } + + image.calculateSize(); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/NfdImageReader.java b/java/com/cowlark/fluxengine/imagereader/NfdImageReader.java new file mode 100644 index 000000000..2b5f7063c --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/NfdImageReader.java @@ -0,0 +1,165 @@ +package com.cowlark.fluxengine.imagereader; + +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.core.Logger; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.external.FormatType; +import com.cowlark.fluxengine.ibm.IbmEncoderProto; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/* Reader based on this partial documentation of the D88 format: + * https://www.pc98.org/project/doc/d88.html + */ +public class NfdImageReader extends ImageReader +{ + public NfdImageReader(ImageReaderProto config) + { + super(config); + } + + @Override + public Image readImage() + { + Bytes data; + try + { + data = new Bytes(Files.readAllBytes(Path.of(config.getFilename()))); + } catch (IOException e) + { + throw new FluxEngineException("cannot open input file"); + } + + Bytes fileId = data.slice(0, 14); + if (fileId.equals(new Bytes("T98FDDIMAGE.R1"))) + { + throw new FluxEngineException("NFD: r1 images are not currently supported"); + } + if (!fileId.equals(new Bytes("T98FDDIMAGE.R0"))) + { + throw new FluxEngineException("NFD: could not find NFD header"); + } + + ByteReader headerReader = new ByteReader(data); + + int heads = headerReader.seek(0x115).read8(); + if (heads != 2) + { + throw new FluxEngineException("NFD: unsupported number of heads"); + } + + ConfigProto.Builder extra = ConfigProto.newBuilder(); + IbmEncoderProto.Builder ibm = extra.getEncoderBuilder().getIbmBuilder(); + com.cowlark.fluxengine.config.LayoutProto.Builder layout = extra.getLayoutBuilder(); + Logger.logf("NFD: HD 1.2MB mode"); + Logger.logf("NFD: forcing high density mode"); + extra.getDriveBuilder().setHighDensity(true); + extra.getLayoutBuilder().setFormatType(FormatType.FORMATTYPE_80TRACK); + + Image image = new Image(); + ByteReader br = new ByteReader(data); + br.seek(0x10a10); + for (int track = 0; track < 163; track++) + { + IbmEncoderProto.TrackdataProto.Builder trackdata = ibm.addTrackdataBuilder(); + trackdata.setTargetClockPeriodUs(2); + trackdata.setTargetRotationalPeriodMs(167); + + com.cowlark.fluxengine.config.LayoutProto.LayoutdataProto.Builder layoutdata = + layout.addLayoutdataBuilder(); + com.cowlark.fluxengine.config.SectorListProto.Builder physical = + layoutdata.getPhysicalBuilder(); + int currentTrackTrack = -1; + int currentTrackHead = -1; + int trackSectorSize = -1; + + for (int sectorInTrack = 0; sectorInTrack < 26; sectorInTrack++) + { + ByteReader sectorHeaderReader = + new ByteReader(data.slice( + 0x120 + track * 26 * 16 + sectorInTrack * 16, + 16)); + int cyl = sectorHeaderReader.seek(0).read8(); + int head = sectorHeaderReader.seek(1).read8(); + int sectorId = sectorHeaderReader.seek(2).read8(); + int sectorSize = 128 << sectorHeaderReader.seek(3).read8(); + int mfm = sectorHeaderReader.seek(4).read8(); + int ddam = sectorHeaderReader.seek(5).read8(); + int status = sectorHeaderReader.seek(6).read8(); + sectorHeaderReader.skip(9); /* skip ST0, ST1, ST2, PDA, reserved(5) */ + if (cyl == 0xFF) + continue; + if (ddam != 0) + throw new FluxEngineException("NFD: nonzero ddam currently unsupported"); + if (status != 0) + throw new FluxEngineException( + "NFD: nonzero fdd status codes are currently unsupported"); + if (currentTrackTrack < 0) + { + currentTrackTrack = cyl; + currentTrackHead = head; + } else if (currentTrackTrack != cyl) + { + throw new FluxEngineException( + "NFD: all sectors in a track must belong to the same track"); + } else if (currentTrackHead != head) + { + throw new FluxEngineException( + "NFD: all sectors in a track must belong to the same head"); + } + if (trackSectorSize < 0) + { + trackSectorSize = sectorSize; + /* this is the first sector we've read, use its settings for + * per-track data */ + trackdata.setTrack(cyl); + trackdata.setHead(head); + layoutdata.setTrack(cyl); + layoutdata.setSide(head); + layoutdata.setSectorSize(sectorSize); + trackdata.setUseFm(mfm == 0); + if (mfm == 0) + { + trackdata.setGapFillByte(0xffff); + trackdata.setIdamByte(0xf57e); + trackdata.setDamByte(0xf56f); + } + /* create timings to approximately match N88-BASIC */ + if (sectorSize <= 128) + { + trackdata.setGap0(0x1b); + trackdata.setGap2(0x09); + trackdata.setGap3(0x1b); + } else if (sectorSize <= 256) + { + trackdata.setGap0(0x36); + trackdata.setGap3(0x36); + } + } else if (trackSectorSize != sectorSize) + { + throw new FluxEngineException( + "NFD: multiple sector sizes per track are currently unsupported"); + } + Bytes sectorData = br.read(sectorSize); + physical.addSector(sectorId); + Sector sector = image.put(cyl, head, sectorId); + sector.status = Sector.Status.OK; + sector.data = sectorData; + } + } + + image.calculateSize(); + Geometry geometry = image.getGeometry(); + Logger.logf( + "NFD: read " + geometry.numCylinders + " tracks, " + geometry.numHeads + " sides"); + + extraConfig = extra.build(); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/NsiImageReader.java b/java/com/cowlark/fluxengine/imagereader/NsiImageReader.java new file mode 100644 index 000000000..3a1936e61 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/NsiImageReader.java @@ -0,0 +1,107 @@ +package com.cowlark.fluxengine.imagereader; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.Logger; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/* Image reader for Northstar floppy disk images */ +public class NsiImageReader extends ImageReader +{ + public NsiImageReader(ImageReaderProto config) + { + super(config); + } + + @Override + public Image readImage() + { + Bytes data; + try + { + data = new Bytes(Files.readAllBytes(Path.of(config.getFilename()))); + } catch (IOException e) + { + throw new FluxEngineException("cannot open input file"); + } + int fsize = data.size(); + + Logger.logf("NSI: Autodetecting geometry based on file size: " + fsize); + + int numCylinders = 35; + int numSectors = 10; + int numHeads = 2; + int sectorSize = 512; + + switch (fsize) + { + case 358400: + numHeads = 2; + sectorSize = 512; + break; + + case 179200: + numHeads = 1; + sectorSize = 512; + break; + + case 89600: + numHeads = 1; + sectorSize = 256; + break; + + default: + throw new FluxEngineException("NSI: unknown file size"); + } + + int trackSize = numSectors * sectorSize; + + Logger.logf("reading " + numCylinders + " tracks, " + numHeads + " heads, " + numSectors + + " sectors, " + sectorSize + " bytes per sector, " + + numCylinders * numHeads * trackSize / 1024 + " kB total"); + + Image image = new Image(); + ByteReader br = new ByteReader(data); + int sectorFileOffset; + + for (int head = 0; head < numHeads; head++) + { + for (int track = 0; track < numCylinders; track++) + { + for (int sectorId = 0; sectorId < numSectors; sectorId++) + { + if (head == 0) + { /* Head 0 is from track 0-34 */ + sectorFileOffset = track * trackSize + sectorId * sectorSize; + } else + { /* Head 1 is from track 70-35 */ + sectorFileOffset = (trackSize * numCylinders) + /* Skip over side 0 */ + ((numCylinders - track - 1) * trackSize) + (sectorId * + sectorSize); /* Sector offset from beginning of track. */ + } + + br.seek(sectorFileOffset); + Bytes sectorData = br.read(sectorSize); + + Sector sector = image.put(track, head, sectorId); + sector.status = Sector.Status.OK; + sector.data = sectorData; + } + } + } + + Geometry geometry = new Geometry(); + geometry.numCylinders = numCylinders; + geometry.numHeads = numHeads; + geometry.numSectors = numSectors; + geometry.sectorSize = sectorSize; + image.setGeometry(geometry); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/Td0ImageReader.java b/java/com/cowlark/fluxengine/imagereader/Td0ImageReader.java new file mode 100644 index 000000000..507f45183 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/Td0ImageReader.java @@ -0,0 +1,189 @@ +package com.cowlark.fluxengine.imagereader; + +import com.cowlark.fluxengine.core.ByteReader; +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.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.external.Crc; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/* The best description of the Teledisk format I've found is available here: + * + * https://web.archive.org/web/20210420230238/http://dunfield.classiccmp.org/img47321/td0notes.txt + */ +public class Td0ImageReader extends ImageReader +{ + private static final int TD0_ENCODING_RAW = 0; + private static final int TD0_ENCODING_REPEATED = 1; + private static final int TD0_ENCODING_RLE = 2; + + private static final int TD0_FLAG_DUPLICATE = 0x01; + private static final int TD0_FLAG_CRC_ERROR = 0x02; + private static final int TD0_FLAG_DELETED = 0x04; + private static final int TD0_FLAG_SKIPPED = 0x10; + private static final int TD0_FLAG_IDNODATA = 0x20; + private static final int TD0_FLAG_DATANOID = 0x40; + + public Td0ImageReader(ImageReaderProto config) + { + super(config); + } + + @Override + public Image readImage() + { + Bytes input; + try + { + input = new Bytes(Files.readAllBytes(Path.of(config.getFilename()))); + } catch (IOException e) + { + throw new FluxEngineException("cannot open input file"); + } + ByteReader br = new ByteReader(input); + + int signature = br.readBe16(); + br.skip(2); /* sequence and checksequence */ + int version = br.read8(); + br.skip(2); /* data rate, drive type */ + int stepping = br.read8(); + br.skip(1); /* sparse flag */ + int sides = (br.read8() == 1) ? 1 : 2; + int headerCrc = br.readLe16(); + + int gotCrc = Crc.crc16(0xa097, 0, input.slice(0, 10)); + if (gotCrc != headerCrc) + throw new FluxEngineException("TD0: header checksum mismatch"); + if (signature != 0x5444) + throw new FluxEngineException( + "TD0: unsupported file type (only uncompressed files are supported for now)"); + + String comment = "(no comment)"; + if ((stepping & 0x80) != 0) + { + /* Comment block */ + + br.skip(2); /* comment CRC */ + int length = br.readLe16(); + br.skip(6); /* timestamp */ + comment = br.read(length).toString(); + comment = comment.replace('\0', '\n'); + + /* Strip trailing whitespace */ + + int end = comment.length(); + while (end > 0 && Character.isWhitespace(comment.charAt(end - 1))) + end--; + comment = comment.substring(0, end); + } + + Logger.logf("TD0: TeleDisk " + version / 10 + "." + version % 10 + ": " + comment); + + int totalSize = 0; + Image image = new Image(); + for (; ; ) + { + /* Read track header */ + + int sectorCount = br.read8(); + if (sectorCount == 0xff) + break; + + int physicalCylinder = br.read8(); + int physicalHead = br.read8() & 1; + br.skip(1); /* crc */ + + for (int i = 0; i < sectorCount; i++) + { + /* Read sector */ + + int logicalCylinder = br.read8(); + int logicalHead = br.read8(); + int sectorId = br.read8(); + int sectorSizeEncoded = br.read8(); + int sectorSize = 128 << sectorSizeEncoded; + int flags = br.read8(); + br.skip(1); /* CRC */ + + int dataSize = br.readLe16(); + Bytes encodedData = br.read(dataSize); + ByteReader bre = new ByteReader(encodedData); + int encoding = bre.read8(); + + Bytes data; + if ((flags & (TD0_FLAG_SKIPPED | TD0_FLAG_IDNODATA)) == 0) + { + switch (encoding) + { + case TD0_ENCODING_RAW: + data = encodedData.slice(1); + break; + + case TD0_ENCODING_REPEATED: + { + data = new Bytes(0); + ByteWriter bw = data.writer(); + while (!bre.eof()) + { + int pattern = bre.readLe16(); + int count = bre.readLe16(); + while (count-- != 0) + bw.writeLe16(pattern); + } + break; + } + + case TD0_ENCODING_RLE: + { + data = new Bytes(0); + ByteWriter bw = data.writer(); + while (!bre.eof()) + { + int length = bre.read8() * 2; + if (length == 0) + { + /* Literal block */ + + length = bre.read8(); + bw.write(bre.read(length)); + } else + { + /* Repeated block */ + + int count = bre.read8(); + Bytes b = bre.read(length); + while (count-- != 0) + bw.write(b); + } + } + break; + } + + default: + data = new Bytes(0); + break; + } + } else + data = new Bytes(0); + + Sector sector = image.put(logicalCylinder, logicalHead, sectorId); + sector.status = Sector.Status.OK; + sector.data = data.slice(0, sectorSize); + totalSize += sectorSize; + } + } + + image.calculateSize(); + Geometry geometry = image.getGeometry(); + Logger.logf("TD0: found " + geometry.numCylinders + " tracks, " + geometry.numHeads + + " sides, " + geometry.numSectors + " sectors, " + geometry.sectorSize + + " bytes per sector, " + totalSize / 1024 + " kB total"); + return image; + } +} diff --git a/java/com/cowlark/fluxengine/imagereader/imagereader.proto b/java/com/cowlark/fluxengine/imagereader/imagereader.proto new file mode 100644 index 000000000..cd6c23d00 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagereader/imagereader.proto @@ -0,0 +1,45 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.imagereader"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message ImgInputOutputProto { + optional bool filesystem_sector_order = 1 [ + (help) = "read/write sector image in filesystem order", + default = false + ]; +} + +message DiskCopyInputProto {} +message ImdInputProto {} +message Jv3InputProto {} +message D64InputProto {} +message NsiInputProto {} +message Td0InputProto {} +message DimInputProto {} +message FdiInputProto {} +message D88InputProto {} +message NfdInputProto {} + +// NEXT_TAG: 14 +message ImageReaderProto +{ + optional string filename = 1 [(help) = "filename of input sector image"]; + + optional ImageReaderWriterType type = 13 + [default = IMAGETYPE_NOT_SET, (help) = "input image type"]; + + optional ImgInputOutputProto img = 2; + optional DiskCopyInputProto diskcopy = 3; + optional ImdInputProto imd = 4; + optional Jv3InputProto jv3 = 5; + optional D64InputProto d64 = 6; + optional NsiInputProto nsi = 7; + optional Td0InputProto td0 = 8; + optional DimInputProto dim = 9; + optional FdiInputProto fdi = 10; + optional D88InputProto d88 = 11; + optional NfdInputProto nfd = 12; +} diff --git a/java/com/cowlark/fluxengine/imagewriter/BUILD.bazel b/java/com/cowlark/fluxengine/imagewriter/BUILD.bazel new file mode 100644 index 000000000..52905e155 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagewriter/BUILD.bazel @@ -0,0 +1,33 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "imagewriter_proto", + srcs = ["imagewriter.proto"], + strip_import_prefix = "/java/", + deps = [ + "//java/com/cowlark/fluxengine/config:common_proto", + "//java/com/cowlark/fluxengine/imagereader:imagereader_proto", + ], +) + +java_proto_library( + name = "imagewriter_java_proto", + deps = [":imagewriter_proto"], +) + +java_library( + name = "imagewriter", + srcs = glob(["*.java"]), + deps = [ + ":imagewriter_java_proto", + "//java/com/cowlark/fluxengine/config:common_java_proto", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/imagereader:imagereader_java_proto", + ], +) diff --git a/java/com/cowlark/fluxengine/imagewriter/D64ImageWriter.java b/java/com/cowlark/fluxengine/imagewriter/D64ImageWriter.java new file mode 100644 index 000000000..93a3f1aa4 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagewriter/D64ImageWriter.java @@ -0,0 +1,57 @@ +package com.cowlark.fluxengine.imagewriter; + +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; + +/** + * Writes a D64 (Commodore 1541) sector image, ported from + * lib/imagewriter/d64imagewriter.cc. + */ +public class D64ImageWriter extends ImageWriter +{ + public D64ImageWriter(ImageWriterProto config) + { + super(config); + } + + private static int sectorsPerTrack(int track) + { + if (track < 17) + return 21; + if (track < 24) + return 19; + if (track < 30) + return 18; + return 17; + } + + @Override + public void writeImage(Image image) + { + System.out.println("D64: writing triangular image"); + + Bytes output = new Bytes(); + ByteWriter bw = output.writer(); + + int offset = 0; + for (int track = 0; track < 40; track++) + { + int sectorCount = sectorsPerTrack(track); + for (int sectorId = 0; sectorId < sectorCount; sectorId++) + { + Sector sector = image.get(track, 0, sectorId); + if (sector != null) + { + bw.seek(offset); + bw.write(sector.data); + } + + offset += 256; + } + } + + output.writeToFile(config.getFilename()); + } +} diff --git a/java/com/cowlark/fluxengine/imagewriter/D88ImageWriter.java b/java/com/cowlark/fluxengine/imagewriter/D88ImageWriter.java new file mode 100644 index 000000000..a46902113 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagewriter/D88ImageWriter.java @@ -0,0 +1,121 @@ +package com.cowlark.fluxengine.imagewriter; + +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Writes a D88 sector image, ported from lib/imagewriter/d88imagewriter.cc. + */ +public class D88ImageWriter extends ImageWriter +{ + public D88ImageWriter(ImageWriterProto config) + { + super(config); + } + + private static int countlZero(int value) + { + int count = 0; + while ((value & 0x80000000) == 0) + { + value <<= 1; + count++; + } + return count; + } + + @Override + public void writeImage(Image image) + { + Geometry geometry = image.getGeometry(); + + int tracks = geometry.numCylinders; + int sides = geometry.numHeads; + + Bytes header = new Bytes(); + ByteWriter headerWriter = header.writer(); + for (int i = 0; i < 26; i++) + { + headerWriter.write8(0x0); /* image name + reserved bytes */ + } + headerWriter.write8(0x00); /* not write protected */ + if (geometry.numCylinders > 42) + { + headerWriter.write8(0x20); /* 2HD */ + } else + { + headerWriter.write8(0x00); /* 2D */ + } + headerWriter.writeLe32(0); /* disk size (overridden at the end) */ + for (int i = 0; i < 164; i++) + { + headerWriter.writeLe32(0); /* track pointer (overridden in loop) */ + } + + Bytes output = header; + ByteWriter bw = output.writer(); + + int trackOffset = 688; + + for (int track = 0; track < geometry.numCylinders * geometry.numHeads; track++) + { + headerWriter.seek(0x20 + 4 * track); + headerWriter.writeLe32(trackOffset); + int side = track & 1; + List sectors = new ArrayList<>(); + for (int sectorId = geometry.firstSector; sectorId <= geometry.numSectors; sectorId++) + { + Sector sector = image.get(track >> 1, side, sectorId); + if (sector != null) + sectors.add(sector); + } + sectors.sort(Comparator.comparingInt(s -> s.position)); + for (Sector sector : sectors) + { + Bytes sectorBytes = new Bytes(); + ByteWriter sectorWriter = sectorBytes.writer(); + sectorWriter.write8(sector.location.logicalCylinder()); + sectorWriter.write8(sector.location.logicalHead()); + sectorWriter.write8(sector.location.logicalSector()); + sectorWriter.write8(24 - countlZero(sector.data.size())); + sectorWriter.writeLe16(sectors.size()); + sectorWriter.write8(0x00); /* always write mfm */ + sectorWriter.write8(0x00); /* always write not deleted data */ + if (sector.status == Sector.Status.BAD_CHECKSUM) + { + sectorWriter.write8(0xB0); + } else + { + sectorWriter.write8(0x00); + } + sectorWriter.write8(0x00); /* reserved */ + sectorWriter.write8(0x00); + sectorWriter.write8(0x00); + sectorWriter.write8(0x00); + sectorWriter.write8(0x00); + sectorWriter.writeLe16(sector.data.size()); + output = output.concat(sectorBytes); + output = output.concat(sector.data); + trackOffset += sectorBytes.size(); + trackOffset += sector.data.size(); + } + } + + headerWriter.seek(0x1c); + headerWriter.writeLe32(output.size()); + + output.writeToFile(config.getFilename()); + + System.out.printf( + "D88: wrote %d tracks, %d sides, %d kB total%n", + tracks, + sides, + output.size() / 1024); + } +} diff --git a/java/com/cowlark/fluxengine/imagewriter/DiskCopyImageWriter.java b/java/com/cowlark/fluxengine/imagewriter/DiskCopyImageWriter.java new file mode 100644 index 000000000..4f184a3bc --- /dev/null +++ b/java/com/cowlark/fluxengine/imagewriter/DiskCopyImageWriter.java @@ -0,0 +1,172 @@ +package com.cowlark.fluxengine.imagewriter; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import java.nio.charset.StandardCharsets; + +/** + * Writes a DiskCopy 4.2 sector image, ported from + * lib/imagewriter/diskcopyimagewriter.cc. + */ +public class DiskCopyImageWriter extends ImageWriter +{ + private static final String LABEL = "FluxEngine image"; + + public DiskCopyImageWriter(ImageWriterProto config) + { + super(config); + } + + private static void writeAndUpdateChecksum(ByteWriter bw, int[] checksum, Bytes data) + { + ByteReader br = data.iterator(); + while (!br.eof()) + { + int i = br.readBe16(); + checksum[0] += i; + checksum[0] = (checksum[0] >>> 1) | (checksum[0] << 31); + bw.writeBe16(i); + } + } + + @Override + public void writeImage(Image image) + { + Geometry geometry = image.getGeometry(); + + boolean mfm = false; + + switch (geometry.sectorSize) + { + case 524: + /* GCR disk */ + break; + + case 512: + /* MFM disk */ + mfm = true; + break; + + default: + throw new FluxEngineException( + "this image is not compatible with the DiskCopy 4.2 format"); + } + final boolean isMfm = mfm; + + System.out.println("DC42: writing DiskCopy 4.2 image"); + System.out.printf( + "DC42: %d tracks, %d sides, %d sectors, %d bytes per sector; %s%n", + geometry.numCylinders, + geometry.numHeads, + geometry.numSectors, + geometry.sectorSize, + isMfm ? "MFM" : "GCR"); + + java.util.function.IntUnaryOperator sectorsPerTrack = track -> { + if (isMfm) + return geometry.numSectors; + + if (track < 16) + return 12; + if (track < 32) + return 11; + if (track < 48) + return 10; + if (track < 64) + return 9; + return 8; + }; + + Bytes data = new Bytes(); + ByteWriter bw = data.writer(); + + /* Write the actual sector data. */ + + int[] dataChecksum = {0}; + int[] tagChecksum = {0}; + int offset = 0x54; + int sectorDataStart = offset; + for (int track = 0; track < geometry.numCylinders; track++) + { + for (int side = 0; side < geometry.numHeads; side++) + { + int sectorCount = sectorsPerTrack.applyAsInt(track); + for (int sectorId = 0; sectorId < sectorCount; sectorId++) + { + Sector sector = image.get(track, side, sectorId); + if (sector != null) + { + bw.seek(offset); + writeAndUpdateChecksum(bw, dataChecksum, sector.data.slice(0, 512)); + } + offset += 512; + } + } + } + int sectorDataEnd = offset; + if (!mfm) + { + for (int track = 0; track < geometry.numCylinders; track++) + { + for (int side = 0; side < geometry.numHeads; side++) + { + int sectorCount = sectorsPerTrack.applyAsInt(track); + for (int sectorId = 0; sectorId < sectorCount; sectorId++) + { + Sector sector = image.get(track, side, sectorId); + if (sector != null) + { + bw.seek(offset); + writeAndUpdateChecksum(bw, tagChecksum, sector.data.slice(512, 12)); + } + offset += 12; + } + } + } + } + int tagDataEnd = offset; + + /* Write the header. */ + + int encoding; + int format; + if (isMfm) + { + format = 0x22; + if (geometry.numSectors == 18) + encoding = 3; + else + encoding = 2; + } else + { + if (geometry.numHeads == 2) + { + encoding = 1; + format = 0x22; + } else + { + encoding = 0; + format = 0x02; + } + } + + bw.seek(0); + bw.write8(LABEL.getBytes(StandardCharsets.US_ASCII).length); + bw.write(LABEL.getBytes(StandardCharsets.US_ASCII)); + bw.seek(0x40); + bw.writeBe32(sectorDataEnd - sectorDataStart); /* data size */ + bw.writeBe32(tagDataEnd - sectorDataEnd); /* tag size */ + bw.writeBe32(dataChecksum[0]); /* data checksum */ + bw.writeBe32(tagChecksum[0]); /* tag checksum */ + bw.write8(encoding); /* encoding */ + bw.write8(format); /* format byte */ + bw.writeBe16(0x0100); /* magic number */ + + data.writeToFile(config.getFilename()); + } +} diff --git a/java/com/cowlark/fluxengine/imagewriter/ImageWriter.java b/java/com/cowlark/fluxengine/imagewriter/ImageWriter.java new file mode 100644 index 000000000..ce060be10 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagewriter/ImageWriter.java @@ -0,0 +1,211 @@ +package com.cowlark.fluxengine.imagewriter; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.config.ImageReaderWriterType; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Writes sector images to disk, ported from + * lib/imagewriter/imagewriter.{h,cc}. + */ +public abstract class ImageWriter implements AutoCloseable +{ + protected final ImageWriterProto config; + + public ImageWriter(ImageWriterProto config) + { + this.config = config; + } + + @Override + public void close() throws Exception + { + + } + + public static ImageWriter create(ConfigProto config) + { + if (!config.hasImageWriter()) + throw new FluxEngineException("no image writer configured"); + if (config.getImageWriter().getType() == ImageReaderWriterType.IMAGETYPE_IMG) + return new ImgImageWriter(config.getImageWriter(), config); + return create(config.getImageWriter()); + } + + public static ImageWriter create(ImageWriterProto config) + { + switch (config.getType()) + { + case IMAGETYPE_IMG: + return notImplemented("img"); + case IMAGETYPE_D64: + return new D64ImageWriter(config); + case IMAGETYPE_LDBS: + return notImplemented("ldbs"); + case IMAGETYPE_DISKCOPY: + return new DiskCopyImageWriter(config); + case IMAGETYPE_NSI: + return new NsiImageWriter(config); + case IMAGETYPE_RAW: + return new RawImageWriter(config); + case IMAGETYPE_D88: + return new D88ImageWriter(config); + case IMAGETYPE_IMD: + return new ImdImageWriter(config); + default: + throw new FluxEngineException("bad output image config"); + } + } + + private static ImageWriter notImplemented(String name) + { + throw new FluxEngineException(name + " image writer is not implemented yet"); + } + + protected ImageWriterProto getWriterConfig() + { + return config; + } + + public void writeCsv(Image image, String filename) + { + StringBuilder f = new StringBuilder(); + f.append("\"Physical track\",") + .append("\"Physical side\",") + .append("\"Logical sector\",") + .append("\"Logical track\",") + .append("\"Logical side\",") + .append("\"Clock (ns)\",") + .append("\"Header start (ns)\",") + .append("\"Header end (ns)\",") + .append("\"Data start (ns)\",") + .append("\"Data end (ns)\",") + .append("\"Raw data address (bytes)\",") + .append("\"User payload length (bytes)\",") + .append("\"Status\"") + .append("\n"); + + for (Sector sector : image) + { + f.append(sector.physicalLocation != null ? sector.physicalLocation.cylinder() : -1) + .append(','); + f.append(sector.physicalLocation != null ? sector.physicalLocation.head() : -1) + .append(','); + f.append(sector.location.logicalSector()).append(','); + f.append(sector.location.logicalCylinder()).append(','); + f.append(sector.location.logicalHead()).append(','); + f.append(sector.clockNs).append(','); + f.append(sector.headerStartTimeNs).append(','); + f.append(sector.headerEndTimeNs).append(','); + f.append(sector.dataStartTimeNs).append(','); + f.append(sector.dataEndTimeNs).append(','); + f.append(sector.position).append(','); + f.append(sector.data.size()).append(','); + f.append(Sector.statusToString(sector.status)); + f.append("\n"); + } + + try + { + Files.writeString(Path.of(filename), f.toString(), StandardCharsets.UTF_8); + } catch (IOException e) + { + throw new FluxEngineException("cannot open CSV report file"); + } + } + + public void printMap(Image image) + { + Geometry geometry = image.getGeometry(); + + int badSectors = 0; + int missingSectors = 0; + int totalSectors = 0; + + System.out.print(" Tracks -> "); + for (int i = 10; i < geometry.numCylinders; i += 10) + System.out.printf("%-10d", i / 10); + System.out.println(); + System.out.print("H.SS "); + for (int i = 0; i < geometry.numCylinders; i++) + System.out.print(i % 10); + System.out.println(); + + for (int side = 0; side < geometry.numHeads; side++) + { + int maxSector = geometry.firstSector + geometry.numSectors - 1; + for (int sectorId = 0; sectorId <= maxSector; sectorId++) + { + if (sectorId < geometry.firstSector) + continue; + + System.out.printf("%d.%2d ", side, sectorId); + for (int track = 0; track < geometry.numCylinders; track++) + { + Sector sector = image.get(track, side, sectorId); + if (sector == null) + { + System.out.print('X'); + missingSectors++; + } else + { + switch (sector.status) + { + case OK: + System.out.print('.'); + break; + + case BAD_CHECKSUM: + System.out.print('B'); + badSectors++; + break; + + case CONFLICT: + System.out.print('C'); + badSectors++; + break; + + default: + System.out.print(sector.status.ordinal()); + break; + } + } + totalSectors++; + } + System.out.println(); + } + } + int goodSectors = totalSectors - missingSectors - badSectors; + if (totalSectors == 0) + System.out.println("No sectors in output; skipping analysis"); + else + { + System.out.printf( + "Good sectors: %d/%d (%d%%)%n", + goodSectors, + totalSectors, + 100 * goodSectors / totalSectors); + System.out.printf( + "Missing sectors: %d/%d (%d%%)%n", + missingSectors, + totalSectors, + 100 * missingSectors / totalSectors); + System.out.printf( + "Bad sectors: %d/%d (%d%%)%n", + badSectors, + totalSectors, + 100 * badSectors / totalSectors); + } + } + + /* Writes a raw image. */ + + public abstract void writeImage(Image image); +} diff --git a/java/com/cowlark/fluxengine/imagewriter/ImdImageWriter.java b/java/com/cowlark/fluxengine/imagewriter/ImdImageWriter.java new file mode 100644 index 000000000..344bbbb8d --- /dev/null +++ b/java/com/cowlark/fluxengine/imagewriter/ImdImageWriter.java @@ -0,0 +1,363 @@ +package com.cowlark.fluxengine.imagewriter; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * Writes an IMD (ImageDisk) sector image, ported from + * lib/imagewriter/imdimagewriter.cc. + */ +public class ImdImageWriter extends ImageWriter +{ + private static final String LABEL = "IMD archive by fluxengine on"; + private static final int SEC_CYL_MAP_FLAG = 0x80; + private static final int SEC_HEAD_MAP_FLAG = 0x40; + private static final int END_OF_FILE = 0x1A; + + public ImdImageWriter(ImageWriterProto config) + { + super(config); + } + + private static int getModulationAndSpeed(int flags, ImdOutputProto.RecordingMode mode) + { + if (flags == 0) + { + throw new FluxEngineException( + "Can't write IMD files with this speed " + flags + ", and modulation " + mode + + ". Did you read a real disk?"); + } else + { + flags = (int) (1000000.0 / flags); + } + + if ((flags > 950) && (flags < 1050)) /* HD disk */ + { + /* 500 kbps */ + if (mode == ImdOutputProto.RecordingMode.RECMODE_FM) + { + return 0; + } else + { + return 3; + } + } else if ((flags > 1475) && (flags < 1575)) /* SD disk */ + { + /* 300 kbps */ + if (mode == ImdOutputProto.RecordingMode.RECMODE_FM) + { + return 1; + } else + { + return 4; + } + } else if ((flags > 1900) && (flags < 2100)) /* DD disk */ + { + /* 250 kbps */ + if (mode == ImdOutputProto.RecordingMode.RECMODE_FM) + { + return 2; + } else + { + return 5; + } + } else + { + throw new FluxEngineException( + "IMD: Can't write IMD files with this speed " + flags + ", and modulation " + + mode + ". Try another format."); + } + } + + private static int setSectorSize(int flags) + { + switch (flags) + { + case 128: + return 0; + case 256: + return 1; + case 512: + return 2; + case 1024: + return 3; + case 2048: + return 4; + case 4096: + return 5; + case 8192: + return 6; + } + throw new FluxEngineException("IMD: Sector size " + flags + + " not in standard range (128, 256, 512, 1024, 2048, 4096, 8192)."); + } + + @Override + public void writeImage(Image image) + { + Geometry geometry = image.getGeometry(); + int numHeads; + int numSectors; + int numBytes; + int numSectorsInTrack = 0; + + numHeads = geometry.numHeads; + numSectors = geometry.numSectors; + numBytes = geometry.sectorSize; + + Bytes imagenew = new Bytes(); + ByteWriter bw = imagenew.writer(); + + ImdOutputProto.DataRate dataRate = config.getImd().getDataRate(); + if (dataRate == ImdOutputProto.DataRate.RATE_GUESS) + { + dataRate = (geometry.numSectors > 10) ? + ImdOutputProto.DataRate.RATE_HD : + ImdOutputProto.DataRate.RATE_DD; + if (geometry.sectorSize <= 256) + dataRate = ImdOutputProto.DataRate.RATE_SD; + System.out.println("IMD: guessing data rate as " + dataRate); + } + + ImdOutputProto.RecordingMode recordingMode = config.getImd().getRecordingMode(); + if (recordingMode == ImdOutputProto.RecordingMode.RECMODE_GUESS) + { + recordingMode = ImdOutputProto.RecordingMode.RECMODE_MFM; + System.out.println("IMD: guessing recording mode as " + recordingMode); + } + + String comment = config.getImd().getComment(); + if (comment.length() == 0) + { + comment = LABEL; + comment = comment + " date: " + LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("E MMM d HH:mm:ss yyyy")); + } else + { + comment = "IMD " + comment; + } + bw.seek(0); + + bw.write(comment.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + bw.write8(END_OF_FILE); + String sectorSkew = ""; + int statusSector = 1; + boolean blnOptionalCylinderMap = false; + boolean blnOptionalHeadMap = false; + + /* Write the actual sector data. */ + for (int track = 0; track < geometry.numCylinders; track++) + { + for (int head = 0; head < numHeads; head++) + { + int sectorIdBase = 1; /* IMD starts sector numbering with 1 */ + int sectorId = 0; + int modeValue = 0; + int headerTrack = 0; + int headerHead = 0; + int headerNumSectors = 0; + int headerSectorSize = 0; + Sector sector = image.get(track, head, sectorId + 1); + if (sector == null) + { + /* sector 0 doesnt exist exit with error */ + statusSector = 0; + System.out.printf( + "IMD: sector %d not found on track %d, head %d%n", + sectorId + 1, + track, + head); + break; + } else + { + /* Get the header information */ + numBytes = sector.data.size(); + headerTrack = track; + headerHead = head; + headerSectorSize = setSectorSize(numBytes); + sectorSkew = ""; + numSectorsInTrack = 0; + double RATE = 0; + if (sector.clockNs > 0) + { + RATE = 1000000.0 / sector.clockNs; + } else + { + switch (dataRate) + { + case RATE_HD: + RATE = 1000; + break; + case RATE_SD: + RATE = 1500; + break; + case RATE_DD: + RATE = 2000; + break; + case RATE_GUESS: + break; + } + } + modeValue = getModulationAndSpeed((int) RATE, recordingMode); + } + /* determine number of sectors in track */ + for (int i = 0; i < numSectors; i++) + { + Sector s = image.get(track, head, i + 1); + if (s == null) + { + break; + } else + { + numSectorsInTrack++; + } + } + /* determine sector skew and if there are optional cylinder maps + * or head maps */ + for (int i = 0; i < numSectorsInTrack; i++) + { + Sector s = image.get(track, head, i + 1); + if (s == null) + { + break; + } else + { + sectorSkew = sectorSkew + (char) ((i + sectorIdBase) + '0'); + if (s.physicalLocation != null && + ((s.physicalLocation.cylinder() != s.location.logicalCylinder()) || + (s.physicalLocation.head() != s.location.logicalHead()))) + blnOptionalHeadMap = true; + } + } + bw.write8(modeValue); /* 1 byte ModeValue */ + bw.write8(track); /* 1 byte Cylinder */ + /* are there optional cylinder or head maps? */ + if (blnOptionalCylinderMap) + { + headerHead = headerHead ^ SEC_CYL_MAP_FLAG; + } + if (blnOptionalHeadMap) + { + headerHead = headerHead ^ SEC_HEAD_MAP_FLAG; + } + bw.write8(head); /* 1 byte Head */ + bw.write8(numSectorsInTrack); /* 1 byte number of sectors */ + bw.write8(headerSectorSize); /* 1 byte sector size */ + for (int i = 0; i < numSectorsInTrack; i++) + { + bw.write8((i + sectorIdBase)); /* sector numbering map */ + } + /* Write optional cylinder map */ + if (blnOptionalCylinderMap) + { + for (int i = 0; i < numSectorsInTrack; i++) + { + Sector s = image.get(track, head, i + 1); + bw.write8(s.location.logicalCylinder()); + } + } + + /* Write optional sector head map */ + if (blnOptionalHeadMap) + { + for (int i = 0; i < numSectorsInTrack; i++) + { + Sector s = image.get(track, head, i + 1); + bw.write8(s.location.logicalHead()); + } + } + /* Now read data and write to file */ + for (int i = 0; i < numSectorsInTrack; i++) + { + Sector s = image.get(track, head, i + 1); + boolean blnCompressable = false; + Bytes sectordata = new Bytes(numBytes); + Bytes compressed = new Bytes(1); + int byte0 = 0; + int bytePrevious = 0; + if (s == null) + { + statusSector = 0; + break; + } else + { + ByteReader br = s.data.iterator(); + int j; + /* determine if all bytes are the same -> compress */ + for (j = 0; j < numBytes; j++) + { + byte0 = br.read8(); + if (j == 0) + { + bytePrevious = byte0; + } + if (bytePrevious == byte0) + { + blnCompressable = true; + } else + { + blnCompressable = false; + break; + } + } + switch (s.status) + { + case MISSING: + statusSector = 0; + break; + + case OK: + if (blnCompressable) + { + statusSector = 2; + } else + { + statusSector = 1; + } + break; + case DATA_MISSING: + statusSector = 3; + break; + case BAD_CHECKSUM: + statusSector = 5; + break; + + default: + throw new FluxEngineException( + "IMD: Don't understand IMD files with sector status " + + statusSector); + } + bw.write8(statusSector); /* 1 byte status sector */ + if (blnCompressable) + { + bw.write8(byte0); + blnCompressable = false; + } else + { + bw.write(s.data); + } + numSectors = numSectorsInTrack; + } + blnOptionalCylinderMap = false; + blnOptionalHeadMap = false; + } + } + } + imagenew.writeToFile(config.getFilename()); + System.out.printf( + "IMD: Written %d tracks, %d heads, %d sectors, %d bytes per " + + "sector, %d kB total%n", + geometry.numCylinders, + numHeads, + numSectors, + numBytes, + imagenew.size() / 1024); + } +} diff --git a/java/com/cowlark/fluxengine/imagewriter/ImgImageWriter.java b/java/com/cowlark/fluxengine/imagewriter/ImgImageWriter.java new file mode 100644 index 000000000..c57ff1a98 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagewriter/ImgImageWriter.java @@ -0,0 +1,75 @@ +package com.cowlark.fluxengine.imagewriter; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.data.DiskLayout; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; + +/** + * Writes a raw (non-interleaved) sector image, ported from + * lib/imagewriter/imgimagewriter.cc. + */ +public class ImgImageWriter extends ImageWriter +{ + private final ConfigProto config; + + /* The img writer needs the full config to determine the layout; created + * via ImageWriter.create(ConfigProto). */ + public ImgImageWriter(ImageWriterProto writerConfig, ConfigProto config) + { + super(writerConfig); + this.config = config; + } + + @Override + public void writeImage(Image image) + { + Geometry geometry = image.getGeometry(); + + int tracks = config.getLayout().hasTracks() ? + config.getLayout().getTracks() : + geometry.numCylinders; + int sides = + config.getLayout().hasSides() ? config.getLayout().getSides() : geometry.numHeads; + + DiskLayout diskLayout = new DiskLayout(config); + boolean inFilesystemOrder = getWriterConfig().getImg().getFilesystemSectorOrder(); + + Bytes output = new Bytes(); + ByteWriter bw = output.writer(); + + Iterable locations = inFilesystemOrder ? + diskLayout.logicalLocationsInFilesystemOrder : + diskLayout.logicalLocations; + for (CylinderHead logicalLocation : locations) + { + LogicalTrackLayout ltl = diskLayout.layoutByLogicalLocation.get(logicalLocation); + + Iterable sectorOrder = + inFilesystemOrder ? ltl.filesystemSectorOrder : ltl.naturalSectorOrder; + for (int sectorId : sectorOrder) + { + Sector sector = + image.get(logicalLocation.cylinder(), logicalLocation.head(), sectorId); + if (sector != null) + bw.write(sector.data.slice(0, ltl.sectorSize)); + else + bw.pad(ltl.sectorSize); + } + } + + output.writeToFile(getWriterConfig().getFilename()); + + System.out.printf( + "IMG: wrote %d tracks, %d sides, %d kB total to %s%n", + tracks, + sides, + output.size() / 1024, + getWriterConfig().getFilename()); + } +} diff --git a/java/com/cowlark/fluxengine/imagewriter/NsiImageWriter.java b/java/com/cowlark/fluxengine/imagewriter/NsiImageWriter.java new file mode 100644 index 000000000..49331b3ba --- /dev/null +++ b/java/com/cowlark/fluxengine/imagewriter/NsiImageWriter.java @@ -0,0 +1,97 @@ +package com.cowlark.fluxengine.imagewriter; + +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; + +/** + * Writes an NSI (North Star) sector image, ported from + * lib/imagewriter/nsiimagewriter.cc. + */ +public class NsiImageWriter extends ImageWriter +{ + public NsiImageWriter(ImageWriterProto config) + { + super(config); + } + + @Override + public void writeImage(Image image) + { + Geometry geometry = image.getGeometry(); + boolean mixedDensity = false; + + int trackSize = geometry.numSectors * geometry.sectorSize; + + if (geometry.numCylinders * trackSize == 0) + { + System.out.println("No sectors in output; skipping .nsi image file generation."); + return; + } + + System.out.printf( + "Writing %d tracks, %d sides, %d sectors, %s (%d bytes/sector), " + "%d kB total%n", + geometry.numCylinders, + geometry.numHeads, + geometry.numSectors, + geometry.sectorSize == 256 ? "SD" : "DD", + geometry.sectorSize, + geometry.numCylinders * geometry.numHeads * geometry.numSectors * + geometry.sectorSize / 1024); + + Bytes output = new Bytes(geometry.numCylinders * geometry.numHeads * geometry.numSectors * + geometry.sectorSize); + ByteWriter bw = output.writer(); + + int sectorFileOffset; + for (int track = 0; track < geometry.numCylinders * geometry.numHeads; track++) + { + int side = (track < geometry.numCylinders) ? 0 : 1; + for (int sectorId = 0; sectorId < geometry.numSectors; sectorId++) + { + Sector sector = image.get(track % geometry.numCylinders, side, sectorId); + if (sector != null) + { + if (side == 0) + { /* Side 0 is from track 0-34 */ + sectorFileOffset = track * trackSize + sectorId * geometry.sectorSize; + } else + { /* Side 1 is from track 70-35 */ + sectorFileOffset = (geometry.sectorSize * geometry.numSectors * + geometry.numCylinders) + /* Skip over side 0 */ + ((geometry.numCylinders - 1) - (track % geometry.numCylinders)) * + (geometry.sectorSize * geometry.numSectors) + + (sectorId * geometry.sectorSize); + } + bw.seek(sectorFileOffset); + if ((geometry.sectorSize == 512) && (sector.data.size() == 256)) + { + /* North Star DOS provided an upgrade path for disks + * formatted as single-density to hold double-density + * data without reformatting. In this case, the four + * directory blocks will be single-density but other + * areas of the disk are double-density. This cannot be + * accurately represented using a .nsi file, so in these + * cases, we pad the sector to 512-bytes, filling with + * spaces. */ + if (!mixedDensity) + { + System.out.println("Warning: Disk contains mixed " + + "single/double-density sectors."); + } + mixedDensity = true; + bw.write(sector.data.slice(0, 256)); + bw.pad(256, ' '); + } else + { + bw.write(sector.data.slice(0, geometry.sectorSize)); + } + } + } + } + + output.writeToFile(config.getFilename()); + } +} diff --git a/java/com/cowlark/fluxengine/imagewriter/RawImageWriter.java b/java/com/cowlark/fluxengine/imagewriter/RawImageWriter.java new file mode 100644 index 000000000..232327a85 --- /dev/null +++ b/java/com/cowlark/fluxengine/imagewriter/RawImageWriter.java @@ -0,0 +1,67 @@ +package com.cowlark.fluxengine.imagewriter; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.Geometry; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Record; +import com.cowlark.fluxengine.data.Sector; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Writes a raw (flux-level) image, ported from + * lib/imagewriter/rawimagewriter.cc. + */ +public class RawImageWriter extends ImageWriter +{ + public RawImageWriter(ImageWriterProto config) + { + super(config); + } + + @Override + public void writeImage(Image image) + { + Geometry geometry = image.getGeometry(); + + int trackSize = geometry.numSectors * geometry.sectorSize; + + if (geometry.numCylinders * trackSize == 0) + { + System.out.println("RAW: no sectors in output; skipping image file generation."); + return; + } + + System.out.printf( + "RAW: writing %d tracks, %d sides%n", + geometry.numCylinders, + geometry.numHeads); + + Bytes output = new Bytes(); + + for (int track = 0; track < geometry.numCylinders * geometry.numHeads; track++) + { + int side = (track < geometry.numCylinders) ? 0 : 1; + + List records = new ArrayList<>(); + for (int sectorId = 0; sectorId < geometry.numSectors; sectorId++) + { + Sector sector = image.get(track % geometry.numCylinders, side, sectorId); + if (sector != null) + records.addAll(sector.records); + } + + records.sort(Comparator.comparingDouble(r -> r.startTimeNs)); + + for (Record record : records) + { + output = output.concat(record.rawData); + output = output.concat(new Bytes(3)); + } + output = output.concat(new Bytes(1)); + } + + output.writeToFile(config.getFilename()); + } +} diff --git a/java/com/cowlark/fluxengine/imagewriter/imagewriter.proto b/java/com/cowlark/fluxengine/imagewriter/imagewriter.proto new file mode 100644 index 000000000..6ca63fb5e --- /dev/null +++ b/java/com/cowlark/fluxengine/imagewriter/imagewriter.proto @@ -0,0 +1,85 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.imagewriter"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/imagereader/imagereader.proto"; +import "com/cowlark/fluxengine/config/common.proto"; + +message D64OutputProto {} + +message LDBSOutputProto +{ + enum DataRate + { + RATE_HD = 0; + RATE_DD = 1; + RATE_SD = 2; + RATE_ED = 3; + RATE_GUESS = -1; + } + + enum RecordingMode + { + RECMODE_MFM = 0; + RECMODE_FM = 1; + RECMODE_GCR_MAC = 0x12; + RECMODE_GCR_PRODOS = 0x14; + RECMODE_GCR_LISA = 0x22; + RECMODE_GUESS = -1; + } + + optional DataRate data_rate = 1 + [default = RATE_GUESS, (help) = "data rate to use in LDBS file"]; + optional RecordingMode recording_mode = 2 [ + default = RECMODE_GUESS, + (help) = "recording mode to use in LDBS file" + ]; +} + +message DiskCopyOutputProto {} +message NsiOutputProto {} +message RawOutputProto {} +message D88OutputProto {} +message ImdOutputProto +{ + enum DataRate + { + RATE_HD = 0; + RATE_DD = 1; + RATE_SD = 2; + RATE_GUESS = -1; + } + + enum RecordingMode + { + RECMODE_MFM = 0; + RECMODE_FM = 1; + RECMODE_GUESS = -1; + } + optional DataRate data_rate = 1 + [default = RATE_GUESS, (help) = "data rate to use in IMD file"]; + optional RecordingMode recording_mode = 2 [ + default = RECMODE_GUESS, + (help) = "recording mode (FM or MFM encoding) to use in IMD file" + ]; + optional string comment = 3 [(help) = "comment to set in IMD file"]; +} + +// NEXT_TAG: 12 +message ImageWriterProto +{ + optional string filename = 1 [(help) = "filename of output sector image"]; + + optional ImageReaderWriterType type = 10 + [default = IMAGETYPE_NOT_SET, (help) = "image writer type"]; + + optional ImgInputOutputProto img = 2; + optional D64OutputProto d64 = 3; + optional LDBSOutputProto ldbs = 4; + optional DiskCopyOutputProto diskcopy = 5; + optional NsiOutputProto nsi = 6; + optional RawOutputProto raw = 7; + optional D88OutputProto d88 = 8; + optional ImdOutputProto imd = 9; +} diff --git a/java/com/cowlark/fluxengine/usb/ApplesauceUsbDevice.java b/java/com/cowlark/fluxengine/usb/ApplesauceUsbDevice.java new file mode 100644 index 000000000..ad1ba69c5 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/ApplesauceUsbDevice.java @@ -0,0 +1,358 @@ +package com.cowlark.fluxengine.usb; + +import static com.cowlark.fluxengine.external.FluxEngine.F_BIT_PULSE; +import static com.cowlark.fluxengine.external.FluxEngine.NS_PER_TICK; + +import com.cowlark.fluxengine.core.ByteReader; +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.Fluxmap; +import com.cowlark.fluxengine.data.FluxmapReader; +import com.cowlark.fluxengine.decoders.DecoderProto; +import java.util.ArrayList; +import java.util.List; + +/** + * Applesauce floppy drive device, ported from lib/usb/applesauceusb.cc. + */ +class ApplesauceUsbDevice extends UsbDevice +{ + private final Serial serial; + private final ApplesauceProto config; + private boolean connected; + + ApplesauceUsbDevice(String port, ApplesauceProto config) + { + this.config = config; + this.serial = new Serial(port, 9600); + + String s = sendrecv("?"); + if (!s.equals("Applesauce")) + throw new FluxEngineException(String.format( + "Applesauce device not responding " + "(expected 'Applesauce', got '%s')", + s)); + + doCommand("client:v2"); + } + + private static long ssRandNext(long x) + { + return (x & 1) != 0 ? (x >> 1) ^ 0x80000062L : x >> 1; + } + + private static Bytes applesauceReadDataToFluxEngine(Bytes asdata, + double clock, + List indexMarks) + { + ByteReader br = new ByteReader(asdata); + Fluxmap fluxmap = new Fluxmap(); + int indexIt = 0; + fluxmap.appendIndex(); + + long totalTicks = 0; + while (!br.eof()) + { + int b = br.read8(); + fluxmap.appendInterval((int) (b * clock / NS_PER_TICK)); + if (b != 255) + fluxmap.appendPulse(); + + totalTicks += b; + if ((indexIt < indexMarks.size()) && (totalTicks > indexMarks.get(indexIt))) + { + fluxmap.appendIndex(); + indexIt++; + } + } + + return fluxmap.rawBytes(); + } + + private static Bytes fluxEngineToApplesauceWriteData(Bytes fldata) + { + Fluxmap fluxmap = new Fluxmap(fldata); + FluxmapReader fmr = new FluxmapReader(fluxmap, DecoderProto.getDefaultInstance()); + Bytes asdata = new Bytes(0); + ByteWriter bw = asdata.writer(); + + while (!fmr.eof()) + { + FluxmapReader.EventResult r = fmr.findEvent(F_BIT_PULSE); + long ticks = r.ticks(); + if (!r.found()) + break; + + long applesauceTicks = (long) (ticks * NS_PER_TICK); + while (applesauceTicks >= 0xffff) + { + bw.writeLe16(0xffff); + applesauceTicks -= 0xffff; + } + if (applesauceTicks == 0) + throw new FluxEngineException("bad data!"); + bw.writeLe16((int) applesauceTicks); + } + + bw.writeLe16(0); + return asdata; + } + + private static double getCurrentTime() + { + return System.nanoTime() / 1e9; + } + + private static List split(String s, char separator) + { + List result = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + for (int i = 0; i < s.length(); i++) + { + char c = s.charAt(i); + if (c == separator) + { + result.add(current.toString()); + current.setLength(0); + } else + current.append(c); + } + result.add(current.toString()); + return result; + } + + private String sendrecv(String command) + { + if (config.getVerbose()) + System.out.println("> " + command); + serial.writeLine(command); + String r = serial.readLine(); + if (config.getVerbose()) + System.out.println("< " + r); + return r; + } + + private void checkCommandResult(String result) + { + if (!result.equals(".")) + throw new FluxEngineException("low-level Applesauce error: '" + result + "'"); + } + + private void doCommand(String command) + { + checkCommandResult(sendrecv(command)); + } + + private String doCommandX(String command) + { + doCommand(command); + String r = serial.readLine(); + if (config.getVerbose()) + System.out.println("<< " + r); + return r; + } + + private void connect() + { + if (!connected) + { + try + { + doCommand("connect"); + doCommand("drive:enable"); + doCommand("motor:on"); + doCommand("head:zero"); + connected = true; + } catch (FluxEngineException e) + { + throw new FluxEngineException("Applesauce could not connect to a drive"); + } + } + } + + @Override + public void seek(int track) + { + if (track == 0) + doCommand("head:zero"); + else + doCommand(String.format("head:track%d", track)); + } + + @Override + public double getRotationalPeriod(int hardSectorCount) + { + if (hardSectorCount != 0) + throw new FluxEngineException( + "hard sectors are currently unsupported on the " + "Applesauce"); + + connect(); + try + { + double periodUs = Double.parseDouble(doCommandX("sync:?speed")); + serial.writeByte('X'); + String r = serial.readLine(); + if (config.getVerbose()) + System.out.println("<< " + r); + return periodUs * 1e3; + } catch (FluxEngineException e) + { + return 0; + } + } + + @Override + public void testBulkWrite() + { + int max = Integer.parseInt(sendrecv("data:?max")); + System.out.print("Writing data: "); + + doCommand(String.format("data:>%d", max)); + + Bytes junk = new Bytes(max); + long seed = 0; + for (int i = 0; i < max; i++) + { + junk.setByte(i, (byte) seed); + seed = ssRandNext(seed); + } + double startTime = getCurrentTime(); + serial.writeBytes(junk); + serial.readLine(); + double elapsedTime = getCurrentTime() - startTime; + + System.out.printf( + "transferred %d bytes from PC -> device in %d ms (%d kb/s)%n", + max, + (int) (elapsedTime * 1000.0), + (int) ((max / 1024.0) / elapsedTime)); + } + + @Override + public void testBulkRead() + { + int max = Integer.parseInt(sendrecv("data:?max")); + System.out.print("Reading data: "); + + doCommand(String.format("data:<%d", max)); + + double startTime = getCurrentTime(); + serial.readBytes(max); + double elapsedTime = getCurrentTime() - startTime; + + System.out.printf( + "transferred %d bytes from device -> PC in %d ms (%d kb/s)%n", + max, + (int) (elapsedTime * 1000.0), + (int) ((max / 1024.0) / elapsedTime)); + } + + @Override + public Bytes read(int side, boolean synced, double readTimeNs, double hardSectorThresholdNs) + { + if (hardSectorThresholdNs != 0.0) + throw new FluxEngineException( + "hard sectors are currently unsupported on the " + "Applesauce"); + boolean shortRead = readTimeNs < 400e6; + Logger.logf( + "applesauce: timed reads not supported; using read of %s revolutions", + shortRead ? "1.25" : "2.25"); + + connect(); + doCommand(String.format("head:side%d", side)); + doCommand("sync:on"); + doCommand("data:clear"); + String r = doCommandX(shortRead ? "disk:read" : "disk:readx"); + List rsplit = split(r, '|'); + if (rsplit.size() < 2) + throw new FluxEngineException( + "unrecognised Applesauce response to disk:read: '" + r + "'"); + + int bufferSize = Integer.parseInt(rsplit.get(0)); + double tickSize = Double.parseDouble(rsplit.get(1)) / 1e3; + + List indexMarks = new ArrayList<>(); + for (int i = 2; i < rsplit.size(); i++) + indexMarks.add(Integer.parseInt(rsplit.get(i))); + + doCommand(String.format("data:<%d", bufferSize)); + + Bytes rawData = serial.readBytes(bufferSize); + return applesauceReadDataToFluxEngine(rawData, tickSize, indexMarks); + } + + private void checkWritable() + { + if (sendrecv("disk:?write").equals("-")) + throw new FluxEngineException("cannot write --- disk is write protected"); + if (sendrecv("?safe").equals("+")) + throw new FluxEngineException("cannot write --- Applesauce 'safe' switch is on"); + if (sendrecv("?vers").compareTo("0300") < 0) + throw new FluxEngineException("cannot write --- need Applesauce firmware 2.0 or above"); + } + + @Override + public void write(int side, Bytes fldata, double hardSectorThresholdNs) + { + if (hardSectorThresholdNs != 0.0) + throw new FluxEngineException( + "hard sectors are currently unsupported on the " + "Applesauce"); + checkWritable(); + + connect(); + doCommand(String.format("head:side%d", side)); + doCommand("sync:on"); + doCommand("disk:wipe"); + doCommand("data:clear"); + doCommand("disk:wclear"); + + Bytes asdata = fluxEngineToApplesauceWriteData(fldata); + doCommand(String.format("data:>%d", asdata.size())); + serial.writeBytes(asdata); + checkCommandResult(serial.readLine()); + doCommand("disk:wcmd0,0"); + doCommand("disk:write"); + } + + @Override + public void erase(int side, double hardSectorThresholdNs) + { + if (hardSectorThresholdNs != 0.0) + throw new FluxEngineException( + "hard sectors are currently unsupported on the " + "Applesauce"); + checkWritable(); + + connect(); + doCommand(String.format("disk:side%d", side)); + doCommand("disk:wipe"); + } + + @Override + public void setDrive(int drive, boolean highDensity, int indexMode) + { + if (drive != 0) + throw new FluxEngineException("the Applesauce only supports drive 0"); + + connect(); + doCommand(String.format("dpc:density%s", highDensity ? "+" : "-")); + } + + @Override + public VoltageMeasurements measureVoltages() + { + throw new FluxEngineException("unsupported operation on the Applesauce"); + } + + @Override + public void close() + { + try + { + sendrecv("disconnect"); + } finally + { + serial.close(); + } + } +} diff --git a/java/com/cowlark/fluxengine/usb/BUILD.bazel b/java/com/cowlark/fluxengine/usb/BUILD.bazel new file mode 100644 index 000000000..7f5309342 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/BUILD.bazel @@ -0,0 +1,36 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_java//java:defs.bzl", "java_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "usb_proto", + srcs = ["usb.proto"], + strip_import_prefix = "/java/", + deps = ["//java/com/cowlark/fluxengine/config:common_proto"], +) + +java_proto_library( + name = "usb_java_proto", + deps = [":usb_proto"], +) + +java_library( + name = "usb", + srcs = glob(["*.java"]), + resources = ["//java:javax.usb.properties"], + deps = [ + ":usb_java_proto", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "//java/com/cowlark/fluxengine/external", + "@maven//:com_fazecast_jSerialComm", + "@maven//:com_google_guava_guava", + "@maven//:javax_usb_usb_api", + "@maven//:org_usb4java_usb4java_javax", + ], +) diff --git a/java/com/cowlark/fluxengine/usb/FluxEngineUsbDevice.java b/java/com/cowlark/fluxengine/usb/FluxEngineUsbDevice.java new file mode 100644 index 000000000..b09932327 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/FluxEngineUsbDevice.java @@ -0,0 +1,469 @@ +package com.cowlark.fluxengine.usb; + +import static com.cowlark.fluxengine.external.FluxEngine.FLUXENGINE_CMD_IN_EP; +import static com.cowlark.fluxengine.external.FluxEngine.FLUXENGINE_CMD_OUT_EP; +import static com.cowlark.fluxengine.external.FluxEngine.FLUXENGINE_DATA_IN_EP; +import static com.cowlark.fluxengine.external.FluxEngine.FLUXENGINE_DATA_OUT_EP; +import static com.cowlark.fluxengine.external.FluxEngine.FLUXENGINE_PROTOCOL_VERSION; +import static com.cowlark.fluxengine.external.FluxEngine.FRAME_SIZE; +import static com.cowlark.fluxengine.external.FluxEngine.F_ERROR_BAD_COMMAND; +import static com.cowlark.fluxengine.external.FluxEngine.F_ERROR_UNDERRUN; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_BULK_READ_TEST_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_BULK_READ_TEST_REPLY; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_BULK_WRITE_TEST_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_BULK_WRITE_TEST_REPLY; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_DEBUG; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_ERASE_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_ERASE_REPLY; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_ERROR; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_GET_VERSION_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_GET_VERSION_REPLY; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_MEASURE_SPEED_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_MEASURE_SPEED_REPLY; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_MEASURE_VOLTAGES_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_MEASURE_VOLTAGES_REPLY; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_READ_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_READ_REPLY; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_RECALIBRATE_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_RECALIBRATE_REPLY; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_SEEK_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_SEEK_REPLY; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_SET_DRIVE_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_SET_DRIVE_REPLY; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_WRITE_CMD; +import static com.cowlark.fluxengine.external.FluxEngine.F_FRAME_WRITE_REPLY; + +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import javax.usb.UsbConfiguration; +import javax.usb.UsbEndpoint; +import javax.usb.UsbException; +import javax.usb.UsbInterface; +import javax.usb.UsbPipe; +import java.util.List; + +/** + * FluxEngine floppy drive device, ported from lib/usb/fluxengineusb.cc. + */ +class FluxEngineUsbDevice extends UsbDevice +{ + private static final int MAX_TRANSFER = 32 * 1024; + + private final javax.usb.UsbDevice device; + private final UsbInterface usbInterface; + private final UsbPipe cmdOut; + private final UsbPipe cmdIn; + private final UsbPipe dataOut; + private final UsbPipe dataIn; + private final byte[] buffer = new byte[FRAME_SIZE]; + + FluxEngineUsbDevice(javax.usb.UsbDevice device) + { + this.device = device; + + UsbInterface iface = null; + try + { + for (Object o : device.getUsbConfigurations()) + { + UsbConfiguration config = (UsbConfiguration) o; + for (Object i : config.getUsbInterfaces()) + { + UsbInterface candidate = (UsbInterface) i; + if (candidate.getUsbEndpoints().size() >= 4) + iface = candidate; + } + } + if (iface == null) + throw new FluxEngineException("FluxEngine: no suitable USB interface found"); + + iface.claim(); + usbInterface = iface; + + List endpoints = iface.getUsbEndpoints(); + UsbPipe cOut = null; + UsbPipe cIn = null; + UsbPipe dOut = null; + UsbPipe dIn = null; + for (UsbEndpoint endpoint : endpoints) + { + int address = endpoint.getUsbEndpointDescriptor().bEndpointAddress() & 0xff; + UsbPipe pipe = endpoint.getUsbPipe(); + pipe.open(); + switch (address) + { + case FLUXENGINE_CMD_OUT_EP: + cOut = pipe; + break; + case FLUXENGINE_CMD_IN_EP: + cIn = pipe; + break; + case FLUXENGINE_DATA_OUT_EP: + dOut = pipe; + break; + case FLUXENGINE_DATA_IN_EP: + dIn = pipe; + break; + } + } + if (cOut == null || cIn == null || dOut == null || dIn == null) + throw new FluxEngineException("FluxEngine: could not open all USB pipes"); + cmdOut = cOut; + cmdIn = cIn; + dataOut = dOut; + dataIn = dIn; + } catch (UsbException e) + { + throw new FluxEngineException("FluxEngine: USB error: " + e.getMessage()); + } + + int version = getVersion(); + if (version != FLUXENGINE_PROTOCOL_VERSION) + throw new FluxEngineException(String.format( + "your FluxEngine firmware is at version %d but the client is for version %d; " + + "please upgrade", version, FLUXENGINE_PROTOCOL_VERSION)); + } + + private static double getCurrentTime() + { + return System.nanoTime() / 1e9; + } + + private static Voltages readVoltages(byte[] r, int ptr) + { + int logic0 = (r[ptr] & 0xff) | ((r[ptr + 1] & 0xff) << 8); + int logic1 = (r[ptr + 2] & 0xff) | ((r[ptr + 3] & 0xff) << 8); + return new Voltages(logic0, logic1); + } + + private void usbCmdSend(byte[] data) + { + try + { + cmdOut.syncSubmit(data); + } catch (UsbException e) + { + throw new FluxEngineException("FluxEngine: command send failed: " + e.getMessage()); + } + } + + private byte[] usbCmdRecv(int len) + { + byte[] data = new byte[len]; + try + { + cmdIn.syncSubmit(data); + } catch (UsbException e) + { + throw new FluxEngineException("FluxEngine: command recv failed: " + e.getMessage()); + } + return data; + } + + private void usbDataSend(Bytes bytes) + { + int ptr = 0; + while (ptr < bytes.size()) + { + int len = Math.min(bytes.size() - ptr, MAX_TRANSFER); + byte[] data = new byte[len]; + for (int i = 0; i < len; i++) + data[i] = (byte) bytes.getByte(ptr + i); + try + { + dataOut.syncSubmit(data); + } catch (UsbException e) + { + throw new FluxEngineException("FluxEngine: data send failed: " + e.getMessage()); + } + ptr += len; + } + } + + private Bytes usbDataRecv(int maxLength) + { + Bytes bytes = new Bytes(0); + ByteWriter bw = bytes.writer(); + int ptr = 0; + while (ptr < maxLength) + { + int len = Math.min(maxLength - ptr, MAX_TRANSFER); + byte[] data = new byte[len]; + int transferred; + try + { + transferred = dataIn.syncSubmit(data); + } catch (UsbException e) + { + throw new FluxEngineException("FluxEngine: data recv failed: " + e.getMessage()); + } + for (int i = 0; i < transferred; i++) + bw.write8(data[i] & 0xff); + ptr += transferred; + if (transferred < MAX_TRANSFER) + break; + } + return bytes; + } + + private void badReply() + { + int type = buffer[0] & 0xff; + if (type != F_FRAME_ERROR) + throw new FluxEngineException(String.format("bad USB reply 0x%2x", type)); + switch (buffer[1] & 0xff) + { + case F_ERROR_BAD_COMMAND: + throw new FluxEngineException("device did not understand command"); + + case F_ERROR_UNDERRUN: + throw new FluxEngineException("USB underrun (not enough bandwidth)"); + + default: + throw new FluxEngineException("unknown device error " + (buffer[1] & 0xff)); + } + } + + private byte[] awaitReply(int desired) + { + for (; ; ) + { + byte[] r = usbCmdRecv(FRAME_SIZE); + System.arraycopy(r, 0, buffer, 0, FRAME_SIZE); + int type = r[0] & 0xff; + if (type == F_FRAME_DEBUG) + { + /* The debug payload is a NUL-terminated string. */ + StringBuilder sb = new StringBuilder(); + for (int i = 2; i < r.length && r[i] != 0; i++) + sb.append((char) r[i]); + System.out.println("dev: " + sb); + continue; + } + if (type != desired) + badReply(); + return r; + } + } + + private int getVersion() + { + byte[] f = {F_FRAME_GET_VERSION_CMD, 2}; + usbCmdSend(f); + byte[] r = awaitReply(F_FRAME_GET_VERSION_REPLY); + return r[2] & 0xff; + } + + @Override + public void seek(int track) + { + byte[] f = {F_FRAME_SEEK_CMD, 3, (byte) track}; + usbCmdSend(f); + awaitReply(F_FRAME_SEEK_REPLY); + } + + @Override + public void recalibrate() + { + byte[] f = {F_FRAME_RECALIBRATE_CMD, 2}; + usbCmdSend(f); + awaitReply(F_FRAME_RECALIBRATE_REPLY); + } + + @Override + public double getRotationalPeriod(int hardSectorCount) + { + byte[] f = {F_FRAME_MEASURE_SPEED_CMD, 3, (byte) hardSectorCount}; + usbCmdSend(f); + + byte[] r = awaitReply(F_FRAME_MEASURE_SPEED_REPLY); + int periodMs = (r[2] & 0xff) | ((r[3] & 0xff) << 8); + return periodMs * 1000000.0; + } + + @Override + public void testBulkWrite() + { + byte[] f = {F_FRAME_BULK_WRITE_TEST_CMD, 2}; + usbCmdSend(f); + + /* These must match the device. */ + final int XSIZE = 64; + final int YSIZE = 256; + final int ZSIZE = 64; + + System.out.print("Reading data: "); + System.out.flush(); + double startTime = getCurrentTime(); + Bytes bulkBuffer = usbDataRecv(XSIZE * YSIZE * ZSIZE); + double elapsedTime = getCurrentTime() - startTime; + + System.out.println("transferred " + bulkBuffer.size() + " bytes from device -> PC in " + + (int) (elapsedTime * 1000.0) + " ms (" + + (int) ((bulkBuffer.size() / 1024.0) / elapsedTime) + " kB/s)"); + + for (int x = 0; x < XSIZE; x++) + for (int y = 0; y < YSIZE; y++) + for (int z = 0; z < ZSIZE; z++) + { + int offset = x * XSIZE * YSIZE + y * ZSIZE + z; + if ((bulkBuffer.getByte(offset) & 0xff) != (x + y + z) % 256) + throw new FluxEngineException(String.format( + "data transfer corrupted at " + "0x%x %d.%d.%d", + offset, + x, + y, + z)); + } + + awaitReply(F_FRAME_BULK_WRITE_TEST_REPLY); + } + + @Override + public void testBulkRead() + { + byte[] f = {F_FRAME_BULK_READ_TEST_CMD, 2}; + usbCmdSend(f); + + /* These must match the device. */ + final int XSIZE = 64; + final int YSIZE = 256; + final int ZSIZE = 64; + + Bytes bulkBuffer = new Bytes(XSIZE * YSIZE * ZSIZE); + for (int x = 0; x < XSIZE; x++) + for (int y = 0; y < YSIZE; y++) + for (int z = 0; z < ZSIZE; z++) + { + int offset = x * XSIZE * YSIZE + y * ZSIZE + z; + bulkBuffer.setByte(offset, (byte) (x + y + z)); + } + + System.out.print("Writing data: "); + System.out.flush(); + double startTime = getCurrentTime(); + usbDataSend(bulkBuffer); + double elapsedTime = getCurrentTime() - startTime; + + System.out.println("transferred " + bulkBuffer.size() + " bytes from PC -> device in " + + (int) (elapsedTime * 1000.0) + " ms (" + + (int) ((bulkBuffer.size() / 1024.0) / elapsedTime) + " kB/s)"); + + awaitReply(F_FRAME_BULK_READ_TEST_REPLY); + } + + @Override + public Bytes read(int side, boolean synced, double readTimeNs, double hardSectorThresholdNs) + { + Bytes f = new Bytes(0); + ByteWriter bw = f.writer(); + bw.write8(F_FRAME_READ_CMD); + bw.write8(6); + bw.write8(side); + bw.write8(synced ? 1 : 0); + int milliseconds = (int) (readTimeNs / 1e6); + bw.write8(milliseconds & 0xff); + bw.write8((milliseconds >> 8) & 0xff); + bw.write8((int) ((hardSectorThresholdNs + 5e5) / 1e6)); /* round to nearest ms */ + usbCmdSend(f.toByteArray()); + + Bytes buffer = usbDataRecv(1024 * 1024); + + awaitReply(F_FRAME_READ_REPLY); + return buffer; + } + + @Override + public void write(int side, Bytes bytes, double hardSectorThresholdNs) + { + int safelen = bytes.size() & ~(FRAME_SIZE - 1); + Bytes safeBytes = bytes.slice(0, safelen); + + Bytes f = new Bytes(0); + ByteWriter bw = f.writer(); + bw.write8(F_FRAME_WRITE_CMD); + bw.write8(7); + bw.write8(side); + bw.write8(safelen & 0xff); + bw.write8((safelen >> 8) & 0xff); + bw.write8((safelen >> 16) & 0xff); + bw.write8((safelen >> 24) & 0xff); + bw.write8((int) ((hardSectorThresholdNs + 5e5) / 1e6)); /* round to nearest ms */ + usbCmdSend(f.toByteArray()); + usbDataSend(safeBytes); + + awaitReply(F_FRAME_WRITE_REPLY); + } + + @Override + public void erase(int side, double hardSectorThresholdNs) + { + Bytes f = new Bytes(0); + ByteWriter bw = f.writer(); + bw.write8(F_FRAME_ERASE_CMD); + bw.write8(3); + bw.write8(side); + bw.write8((int) ((hardSectorThresholdNs + 5e5) / 1e6)); /* round to nearest ms */ + usbCmdSend(f.toByteArray()); + + awaitReply(F_FRAME_ERASE_REPLY); + } + + @Override + public void setDrive(int drive, boolean highDensity, int indexMode) + { + byte[] f = {F_FRAME_SET_DRIVE_CMD, + 5, + (byte) drive, + (byte) (highDensity ? 1 : 0), + (byte) indexMode}; + usbCmdSend(f); + awaitReply(F_FRAME_SET_DRIVE_REPLY); + } + + @Override + public VoltageMeasurements measureVoltages() + { + byte[] f = {F_FRAME_MEASURE_VOLTAGES_CMD, 2}; + usbCmdSend(f); + + byte[] r = awaitReply(F_FRAME_MEASURE_VOLTAGES_REPLY); + + VoltageMeasurements measurements = new VoltageMeasurements(); + int ptr = 2; + measurements.outputBothOff = readVoltages(r, ptr); + ptr += 4; + measurements.outputDrive0Selected = readVoltages(r, ptr); + ptr += 4; + measurements.outputDrive1Selected = readVoltages(r, ptr); + ptr += 4; + measurements.outputDrive0Running = readVoltages(r, ptr); + ptr += 4; + measurements.outputDrive1Running = readVoltages(r, ptr); + ptr += 4; + measurements.inputBothOff = readVoltages(r, ptr); + ptr += 4; + measurements.inputDrive0Selected = readVoltages(r, ptr); + ptr += 4; + measurements.inputDrive1Selected = readVoltages(r, ptr); + ptr += 4; + measurements.inputDrive0Running = readVoltages(r, ptr); + ptr += 4; + measurements.inputDrive1Running = readVoltages(r, ptr); + return measurements; + } + + @Override + public void close() + { + try + { + if (usbInterface.isClaimed()) + usbInterface.release(); + } catch (UsbException e) + { + throw new FluxEngineException("FluxEngine: USB error: " + e.getMessage()); + } + } +} diff --git a/java/com/cowlark/fluxengine/usb/GreaseweazleUsbDevice.java b/java/com/cowlark/fluxengine/usb/GreaseweazleUsbDevice.java new file mode 100644 index 000000000..6fac22f18 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/GreaseweazleUsbDevice.java @@ -0,0 +1,469 @@ +package com.cowlark.fluxengine.usb; + +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_BAD_COMMAND; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_BAD_CYLINDER; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_BAD_PIN; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_BAD_UNIT; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_FLUX_OVERFLOW; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_FLUX_UNDERFLOW; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_NO_BUS; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_NO_INDEX; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_NO_TRK0; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_NO_UNIT; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_OKAY; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.ACK_WRPROT; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.BAUD_CLEAR_COMMS; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.BAUD_NORMAL; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_ERASE_FLUX; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_GET_FLUX_STATUS; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_GET_INFO; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_HEAD; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_MOTOR; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_READ_FLUX; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_SEEK; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_SELECT; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_SET_BUS_TYPE; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_SET_PIN; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_SINK_BYTES; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_SOURCE_BYTES; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.CMD_WRITE_FLUX; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.FLUXOP_INDEX; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.FLUXOP_SPACE; +import static com.cowlark.fluxengine.external.GreaseweazleUtils.GETINFO_FIRMWARE; + +import com.cowlark.fluxengine.core.ByteReader; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.external.GreaseweazleUtils; +import com.google.common.util.concurrent.Uninterruptibles; +import java.time.Duration; + +/** + * Greaseweazle floppy drive device, ported from lib/usb/greaseweazleusb.cc. + */ +class GreaseweazleUsbDevice extends UsbDevice +{ + private final Serial serial; + private final GreaseweazleProto config; + private Version version; + private long clock; + private long revolutions; + + GreaseweazleUsbDevice(String port, GreaseweazleProto config) + { + this.config = config; + this.serial = new Serial(port, BAUD_NORMAL); + + int version = getVersion(); + if (version >= 29) + this.version = Version.V29; + else if (version >= 24) + this.version = Version.V24; + else if (version == 22) + this.version = Version.V22; + else + throw new FluxEngineException(String.format( + "only Greaseweazle firmware versions 22 and 24 or above are currently " + + "supported, but you have version %d. Please file a bug.", version)); + + /* Twiddle the baud rate, which indicates to the Greaseweazle that the + * data stream has been reset. */ + serial.setBaudRate(BAUD_CLEAR_COMMS); + Uninterruptibles.sleepUninterruptibly(Duration.ofMillis(100)); + serial.setBaudRate(BAUD_NORMAL); + + /* Configure the hardware. */ + doCommand(CMD_SET_BUS_TYPE, config.getBusType().getNumber()); + } + + private static String gwError(int e) + { + switch (e) + { + case ACK_OKAY: + return "OK"; + case ACK_BAD_COMMAND: + return "Bad command"; + case ACK_NO_INDEX: + return "No index"; + case ACK_NO_TRK0: + return "No track 0"; + case ACK_FLUX_OVERFLOW: + return "Overflow"; + case ACK_FLUX_UNDERFLOW: + return "Underflow"; + case ACK_WRPROT: + return "Write protected"; + case ACK_NO_UNIT: + return "No unit"; + case ACK_NO_BUS: + return "No bus"; + case ACK_BAD_UNIT: + return "Invalid unit"; + case ACK_BAD_PIN: + return "Invalid pin"; + case ACK_BAD_CYLINDER: + return "Invalid track"; + default: + return "Unknown error"; + } + } + + private static long ssRandNext(long x) + { + return (x & 1) != 0 ? (x >> 1) ^ 0x80000062L : x >> 1; + } + + private static double getCurrentTime() + { + return System.nanoTime() / 1e9; + } + + private int getVersion() + { + doCommand(CMD_GET_INFO, GETINFO_FIRMWARE); + + ByteReader response = serial.readBytes(32).reader(); + response.seek(4); + long freq = response.readLe32() & 0xffffffffL; + clock = 1000000000L / freq; + + response.seek(0); + return response.readBe16(); + } + + private long read28() + { + ByteReader buffer = new ByteReader(serial.readBytes(4)); + return (long) ((buffer.read8() & 0xfe) >> 1) | (long) (buffer.read8() & 0xfe) << 6 | + (long) (buffer.read8() & 0xfe) << 13 | (long) (buffer.read8() & 0xfe) << 20; + } + + private void doCommand(int cmd, int... payload) + { + byte[] command = new byte[2 + payload.length]; + command[0] = (byte) cmd; + command[1] = (byte) command.length; + for (int i = 0; i < payload.length; i++) + command[2 + i] = (byte) payload[i]; + doCommand(command); + } + + private void doCommand(Bytes command) + { + doCommand(command.toByteArray()); + } + + private void doCommand(byte[] command) + { + serial.writeBytes(command); + + Bytes buffer = serial.readBytes(2); + + if ((buffer.getByte(0) & 0xff) != (command[0] & 0xff)) + throw new RetryableUsbException(String.format( + "command returned garbage (0x%x != 0x%x with status 0x%x)", + buffer.getByte(0), + command[0], + buffer.getByte(1))); + if (buffer.getByte(1) != 0) + throw new FluxEngineException( + "Greaseweazle error: " + gwError(buffer.getByte(1) & 0xff)); + } + + @Override + public void seek(int track) + { + doCommand(CMD_SEEK, track); + } + + @Override + public double getRotationalPeriod(int hardSectorCount) + { + if (hardSectorCount != 0) + throw new FluxEngineException( + "hard sectors are currently unsupported on the " + "Greaseweazle"); + + /* The Greaseweazle doesn't have a command to fetch the period directly, + * so we have to do a flux read. */ + switch (version) + { + case V22: + doCommand(CMD_READ_FLUX); + break; + + case V24: + case V29: + { + Bytes cmd = new Bytes(0); + ByteWriter bw = new ByteWriter(cmd); + bw.write8(CMD_READ_FLUX); + bw.write8(8); + bw.writeLe32(0); /* ticks default value (guessed) */ + bw.writeLe16(2); /* revolutions */ + doCommand(cmd); + } + } + + long ticksGw = 0; + long firstIndex = -1; + long secondIndex = -1; + for (; ; ) + { + int b = serial.readByte(); + if (b == 0) + break; + + if (b == 255) + { + switch (serial.readByte()) + { + case FLUXOP_INDEX: + { + long index = read28() + ticksGw; + if (firstIndex == -1) + firstIndex = index; + else if (secondIndex == -1) + secondIndex = index; + break; + } + + case FLUXOP_SPACE: + ticksGw += read28(); + break; + + default: + throw new FluxEngineException("bad opcode in Greaseweazle stream"); + } + } else + { + if (b < 250) + ticksGw += b; + else + { + long delta = 250 + (b - 250) * 255 + serial.readByte() - 1; + ticksGw += delta; + } + } + } + + if (secondIndex == -1) + throw new FluxEngineException( + "unable to determine disk rotational period (is a disk in the drive?)"); + doCommand(CMD_GET_FLUX_STATUS); + + revolutions = (secondIndex - firstIndex) * clock; + return revolutions; + } + + @Override + public void testBulkWrite() + { + System.out.print("Writing data: "); + final int LEN = 10 * 1024 * 1024; + Bytes cmd = new Bytes(0); + ByteWriter bw = new ByteWriter(cmd); + switch (version) + { + case V22: + case V24: + bw.write8(CMD_SINK_BYTES); + bw.write8(6); + bw.writeLe32(LEN); + break; + + case V29: + bw.write8(CMD_SINK_BYTES); + bw.write8(10); + bw.writeLe32(LEN); + bw.writeLe32(0); /* seed */ + break; + + default: + throw new IllegalStateException(); + } + doCommand(cmd); + + Bytes junk = new Bytes(0); + ByteWriter jw = new ByteWriter(junk); + long seed = 0; + for (int i = 0; i < LEN; i++) + { + jw.write8((int) seed); + seed = ssRandNext(seed); + } + double startTime = getCurrentTime(); + serial.writeBytes(junk); + serial.readBytes(1); + double elapsedTime = getCurrentTime() - startTime; + + System.out.printf( + "transferred %d bytes from PC -> device in %d ms (%d kb/s)\n", + LEN, + (int) (elapsedTime * 1000.0), + (int) ((LEN / 1024.0) / elapsedTime)); + } + + @Override + public void testBulkRead() + { + System.out.print("Reading data: "); + final int LEN = 10 * 1024 * 1024; + Bytes cmd = new Bytes(0); + ByteWriter bw = new ByteWriter(cmd); + switch (version) + { + case V22: + case V24: + bw.write8(CMD_SOURCE_BYTES); + bw.write8(6); + bw.writeLe32(LEN); + break; + + case V29: + bw.write8(CMD_SOURCE_BYTES); + bw.write8(10); + bw.writeLe32(LEN); + bw.writeLe32(0); /* seed */ + break; + + default: + throw new IllegalStateException(); + } + doCommand(cmd); + + double startTime = getCurrentTime(); + serial.readBytes(LEN); + double elapsedTime = getCurrentTime() - startTime; + + System.out.printf( + "transferred %d bytes from device -> PC in %d ms (%d kb/s)\n", + LEN, + (int) (elapsedTime * 1000.0), + (int) ((LEN / 1024.0) / elapsedTime)); + } + + @Override + public Bytes read(int side, boolean synced, double readTimeNs, double hardSectorThresholdNs) + { + if (hardSectorThresholdNs != 0.0) + throw new FluxEngineException( + "hard sectors are currently unsupported on the " + "Greaseweazle"); + + doCommand(CMD_HEAD, side); + + switch (version) + { + case V22: + { + long revs = (long) ((readTimeNs + revolutions - 1) / revolutions); + Bytes cmd = new Bytes(0); + ByteWriter bw = new ByteWriter(cmd); + bw.write8(CMD_READ_FLUX); + bw.write8(4); + bw.writeLe32((int) (revs + (synced ? 1 : 0))); + doCommand(cmd); + break; + } + + case V24: + case V29: + { + Bytes cmd = new Bytes(0); + ByteWriter bw = new ByteWriter(cmd); + bw.write8(CMD_READ_FLUX); + bw.write8(8); + bw.writeLe32((int) ((readTimeNs + (synced ? revolutions : 0)) / clock)); + bw.writeLe16(0); + doCommand(cmd); + } + } + + Bytes buffer = new Bytes(0); + ByteWriter bw = new ByteWriter(buffer); + for (; ; ) + { + int b = serial.readByte(); + if (b == 0) + break; + bw.write8(b); + } + + doCommand(CMD_GET_FLUX_STATUS); + + Bytes fldata = GreaseweazleUtils.greaseweazleToFluxEngine(buffer, clock); + if (synced) + fldata = GreaseweazleUtils.stripPartialRotation(fldata); + return fldata; + } + + @Override + public void write(int side, Bytes fldata, double hardSectorThresholdNs) + { + if (hardSectorThresholdNs != 0.0) + throw new FluxEngineException( + "hard sectors are currently unsupported on the " + "Greaseweazle"); + + doCommand(CMD_HEAD, side); + switch (version) + { + case V22: + doCommand(CMD_WRITE_FLUX, 1); + break; + + case V24: + case V29: + doCommand(CMD_WRITE_FLUX, 1, 1); + break; + } + Bytes gwdata = GreaseweazleUtils.fluxEngineToGreaseweazle(fldata, clock); + serial.writeBytes(gwdata); + serial.readByte(); /* synchronise */ + + doCommand(CMD_GET_FLUX_STATUS); + } + + @Override + public void erase(int side, double hardSectorThresholdNs) + { + if (hardSectorThresholdNs != 0.0) + throw new FluxEngineException( + "hard sectors are currently unsupported on the " + "Greaseweazle"); + + doCommand(CMD_HEAD, side); + + Bytes cmd = new Bytes(0); + ByteWriter bw = new ByteWriter(cmd); + bw.write8(CMD_ERASE_FLUX); + bw.write8(6); + bw.writeLe32((int) (200e6 / clock)); + doCommand(cmd); + serial.readByte(); /* synchronise */ + + doCommand(CMD_GET_FLUX_STATUS); + } + + @Override + public void setDrive(int drive, boolean highDensity, int indexMode) + { + doCommand(CMD_SELECT, drive); + doCommand(CMD_MOTOR, drive, 1); + doCommand(CMD_SET_PIN, 2, highDensity ? 1 : 0); + } + + @Override + public VoltageMeasurements measureVoltages() + { + throw new FluxEngineException("unsupported operation on the Greaseweazle"); + } + + @Override + public void close() + { + serial.close(); + } + + private enum Version + {V22, V24, V29} +} diff --git a/java/com/cowlark/fluxengine/usb/RetryableUsbException.java b/java/com/cowlark/fluxengine/usb/RetryableUsbException.java new file mode 100644 index 000000000..ac8409638 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/RetryableUsbException.java @@ -0,0 +1,11 @@ +package com.cowlark.fluxengine.usb; + +import com.cowlark.fluxengine.core.FluxEngineException; + +public class RetryableUsbException extends FluxEngineException +{ + public RetryableUsbException(String message) + { + super(message); + } +} diff --git a/java/com/cowlark/fluxengine/usb/Serial.java b/java/com/cowlark/fluxengine/usb/Serial.java new file mode 100644 index 000000000..06e874045 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/Serial.java @@ -0,0 +1,114 @@ +package com.cowlark.fluxengine.usb; + +import static com.fazecast.jSerialComm.SerialPort.FLOW_CONTROL_DISABLED; +import static com.fazecast.jSerialComm.SerialPort.TIMEOUT_READ_BLOCKING; +import static com.fazecast.jSerialComm.SerialPort.TIMEOUT_WRITE_BLOCKING; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.fazecast.jSerialComm.SerialPort; +import com.google.common.util.concurrent.Uninterruptibles; +import java.time.Duration; + +/** + * A wrapper around a USB serial port which more closely matches the behaviour + * of the original lib/usb/serial.c than the raw jSerialComm interface: raw + * 8N1 mode with no flow control, DTR toggling to reset the device, flushing of + * pending input on open, and read/write loops which collect or transmit all of + * the requested bytes. + */ +public final class Serial +{ + private final SerialPort serial; + private final byte[] readBuffer = new byte[4096]; + private int readBufferPtr = 0; + private int readBufferFill = 0; + + public Serial(String path, int baudRate) + { + serial = SerialPort.getCommPort(path); + serial.setComPortParameters(baudRate, 8, 1, 0); /* raw 8N1 */ + serial.setFlowControl(FLOW_CONTROL_DISABLED); + serial.setComPortTimeouts(TIMEOUT_READ_BLOCKING | TIMEOUT_WRITE_BLOCKING, 0, 0); + if (!serial.openPort()) + throw new FluxEngineException("cannot open serial port '" + path + "'"); + + /* Toggle DTR to reset the device. */ + toggleDtr(); + + /* Flush pending input from a generic device. */ + readBufferPtr = 0; + readBufferFill = 0; + } + + /* Toggles the DTR line, which resets the attached device. The C++ clears + * DTR, sleeps, and sets it again. */ + public void toggleDtr() + { + boolean rts = serial.getRTS(); + serial.setDTRandRTS(false, rts); + Uninterruptibles.sleepUninterruptibly(Duration.ofMillis(200)); + serial.setDTRandRTS(true, rts); + } + + public void setBaudRate(int baudRate) + { + if (!serial.setBaudRate(baudRate)) + throw new FluxEngineException("cannot set baud rate on serial port"); + toggleDtr(); + } + + public Bytes readBytes(int count) + { + byte[] array = new byte[count]; + serial.readBytes(array, count); + return new Bytes(array); + } + + public int readByte() + { + return readBytes(1).getByte(0) & 0xff; + } + + public void writeBytes(byte[] data) + { + serial.writeBytes(data, data.length); + } + + public void writeBytes(Bytes data) + { + serial.writeBytes(data.toByteArray(), data.size()); + } + + public void writeByte(int b) + { + Bytes data = new Bytes(1); + data.setByte(0, (byte) b); + writeBytes(data); + } + + public void writeLine(String s) + { + writeBytes(new Bytes(s)); + writeByte('\n'); + } + + public String readLine() + { + StringBuilder sb = new StringBuilder(); + for (; ; ) + { + int b = readByte(); + if (b == '\r') + continue; + if (b == '\n') + return sb.toString(); + sb.append((char) b); + } + } + + public void close() + { + serial.closePort(); + } +} diff --git a/java/com/cowlark/fluxengine/usb/UsbDevice.java b/java/com/cowlark/fluxengine/usb/UsbDevice.java new file mode 100644 index 000000000..ad03029d2 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/UsbDevice.java @@ -0,0 +1,44 @@ +package com.cowlark.fluxengine.usb; + +import com.cowlark.fluxengine.core.Bytes; + +/** + * Base class for USB floppy drive devices, ported from lib/usb/usb.h. + */ +public abstract class UsbDevice implements AutoCloseable +{ + public void recalibrate() + { + seek(0); + } + + public abstract void seek(int track); + + public abstract double getRotationalPeriod(int hardSectorCount); + + public abstract void testBulkWrite(); + + public abstract void testBulkRead(); + + public abstract Bytes read(int side, + boolean synced, + double readTimeNs, + double hardSectorThresholdNs); + + public abstract void write(int side, Bytes bytes, double hardSectorThresholdNs); + + public abstract void erase(int side, double hardSectorThresholdNs); + + public abstract void setDrive(int drive, boolean highDensity, int indexMode); + + public abstract VoltageMeasurements measureVoltages(); + + /* Closes the device, releasing any underlying resources. */ + @Override + public abstract void close(); + + protected String usbError(int error) + { + return String.format("USB error %d", error); + } +} diff --git a/java/com/cowlark/fluxengine/usb/UsbFactory.java b/java/com/cowlark/fluxengine/usb/UsbFactory.java new file mode 100644 index 000000000..b9731aca1 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/UsbFactory.java @@ -0,0 +1,77 @@ +package com.cowlark.fluxengine.usb; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.config.UsbFinder; +import com.cowlark.fluxengine.config.UsbFinder.CandidateDevice; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.Logger; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import java.util.Map; + +/** + * USB device finder, ported from lib/usb/usbfinder.cc. + */ +public final class UsbFactory +{ + + private static final Cache cache = CacheBuilder.newBuilder().build(); + + /* The device factory; replaceable from tests to avoid touching real + * hardware. */ + static java.util.function.Function deviceFactory = UsbFactory::connect; + + private UsbFactory() + { + } + + /* Connects a USB device, reusing a previously connected device for the + * same configuration. This is the Java equivalent of the C++ global + * getUsb(). If a different configuration requires a new device, the + * previously cached device is evicted and closed. */ + public static synchronized UsbDevice reconnect(ConfigProto config) + { + UsbDevice device = cache.getIfPresent(config); + if (device == null) + { + /* Only one device is in use at a time, so any other cached device + * is being replaced. Close it before opening the new one, since + * they may share the same serial port. */ + for (Map.Entry entry : cache.asMap().entrySet()) + entry.getValue().close(); + cache.invalidateAll(); + + device = deviceFactory.apply(config); + cache.put(config, device); + } + return device; + } + + public static UsbDevice connect(ConfigProto config) + { + CandidateDevice candidateDevice = UsbFinder.selectDevice(config); + Logger.logf( + "using %s serial %s", + candidateDevice.type.getDeviceName(), + candidateDevice.serial); + UsbDevice device = switch (candidateDevice.type) + { + case GREASEWEAZLE -> new GreaseweazleUsbDevice( + candidateDevice.serialPort, + config.getUsb().getGreaseweazle()); + case APPLESAUCE -> new ApplesauceUsbDevice( + candidateDevice.serialPort, + config.getUsb().getApplesauce()); + case FLUXENGINE -> new FluxEngineUsbDevice(candidateDevice.device); + default -> throw new FluxEngineException("unsupported hardware device"); + + }; + + device.setDrive( + config.getDrive().getDrive(), + config.getDrive().getHighDensity(), + config.getDrive().getIndexMode().getNumber()); + return device; + } + +} diff --git a/java/com/cowlark/fluxengine/usb/VoltageMeasurements.java b/java/com/cowlark/fluxengine/usb/VoltageMeasurements.java new file mode 100644 index 000000000..2f40ee890 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/VoltageMeasurements.java @@ -0,0 +1,19 @@ +package com.cowlark.fluxengine.usb; + +/** + * A set of FDD bus voltage readings, ported from struct voltages_frame in + * protocol.h. + */ +public class VoltageMeasurements +{ + public Voltages inputBothOff; + public Voltages inputDrive0Selected; + public Voltages inputDrive1Selected; + public Voltages inputDrive0Running; + public Voltages inputDrive1Running; + public Voltages outputBothOff; + public Voltages outputDrive0Selected; + public Voltages outputDrive1Selected; + public Voltages outputDrive0Running; + public Voltages outputDrive1Running; +} diff --git a/java/com/cowlark/fluxengine/usb/Voltages.java b/java/com/cowlark/fluxengine/usb/Voltages.java new file mode 100644 index 000000000..2fffb9d52 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/Voltages.java @@ -0,0 +1,8 @@ +package com.cowlark.fluxengine.usb; + +/** + * Voltage readings, ported from struct voltages in protocol.h. + */ +public record Voltages(int logic0Mv, int logic1Mv) +{ +} diff --git a/java/com/cowlark/fluxengine/usb/usb.proto b/java/com/cowlark/fluxengine/usb/usb.proto new file mode 100644 index 000000000..81c1a3851 --- /dev/null +++ b/java/com/cowlark/fluxengine/usb/usb.proto @@ -0,0 +1,35 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.usb"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message GreaseweazleProto { + enum BusType {/* note that these must match CMD_SET_BUS codes */ + BUSTYPE_INVALID = 0; + IBMPC = 1; + SHUGART = 2; + APPLE2 = 3; + }; + + optional string port = 1 + [(help) = "Greaseweazle serial port to use"]; + optional BusType bus_type = 2 + [(help) = "which FDD bus type is in use", default = IBMPC]; +} + +message ApplesauceProto { + optional string port = 1 + [(help) = "Applesauce serial port to use"]; + optional bool verbose = 2 + [(help) = "Enable verbose protocol logging", default = false]; +} + +message UsbProto { + optional string serial = 1 + [(help) = "serial number of FluxEngine or Greaseweazle device to use"]; + + optional GreaseweazleProto greaseweazle = 2 [(help) = "Greaseweazle-specific options"]; + optional ApplesauceProto applesauce = 3 [(help) = "Applesauce-specific options"]; +} diff --git a/java/com/cowlark/fluxengine/vfs/BUILD.bazel b/java/com/cowlark/fluxengine/vfs/BUILD.bazel new file mode 100644 index 000000000..d8869385a --- /dev/null +++ b/java/com/cowlark/fluxengine/vfs/BUILD.bazel @@ -0,0 +1,16 @@ +load("@com_google_protobuf//bazel:java_proto_library.bzl", "java_proto_library") +load("@rules_proto//proto:defs.bzl", "proto_library") + +package(default_visibility = ["//visibility:public"]) + +proto_library( + name = "vfs_proto", + srcs = ["vfs.proto"], + strip_import_prefix = "/java/", + deps = ["//java/com/cowlark/fluxengine/config:common_proto"], +) + +java_proto_library( + name = "vfs_java_proto", + deps = [":vfs_proto"], +) diff --git a/java/com/cowlark/fluxengine/vfs/vfs.proto b/java/com/cowlark/fluxengine/vfs/vfs.proto new file mode 100644 index 000000000..d98f47594 --- /dev/null +++ b/java/com/cowlark/fluxengine/vfs/vfs.proto @@ -0,0 +1,159 @@ +syntax = "proto2"; + +option java_package = "com.cowlark.fluxengine.vfs"; +option java_multiple_files = true; + +import "com/cowlark/fluxengine/config/common.proto"; + +message AcornDfsProto +{ + enum Flavour + { + UNDEFINED = 0; + ACORN_DFS = 1; + } + + optional Flavour flavour = 1 + [default = ACORN_DFS, (help) = "which flavour of DFS to implement"]; +} + +message Brother120FsProto {} + +message FatFsProto { + optional uint32 cluster_size = 1 + [(help) = "cluster size (for new filesystems); 0 to select automatically", + default = 0]; + optional uint32 root_directory_entries = 2 + [(help) = "number of entries in the root directory (for new filesystems); 0 to select automatically", + default = 0]; +} + +message CpmFsProto +{ + message Location + { + optional uint32 track = 1 [(help) = "track number"]; + optional uint32 side = 2 [(help) = "side number"]; + optional uint32 sector = 3 [(help) = "sector ID"]; + } + + message Padding + { + optional uint32 amount = 1 + [(help) = "number of sectors of padding to insert"]; + optional uint32 every = 2 + [(help) = "insert padding after this many sectors"]; + } + + optional Location filesystem_start = 1 + [(help) = "position of the start of the filesystem"]; + optional int32 block_size = 2 [(help) = "allocation block size"]; + optional int32 dir_entries = 3 + [(help) = "number of entries in the directory"]; + optional Padding padding = 4 + [(help) = "wasted sectors not considered part of the filesystem"]; +} + +message AmigaFfsProto {} + +message MacHfsProto {} + +message CbmfsProto +{ + optional uint32 directory_track = 1 [ + default = 17, + (help) = "which track the directory is on (zero-based numbering)" + ]; +} + +message ProdosProto {} + +message AppledosProto +{ + optional uint32 filesystem_offset_sectors = 1 [ + default = 0, + (help) = "offset the entire offset up the disk this many sectors" + ]; +} + +message Smaky6FsProto {} + +message PhileProto +{ + optional uint32 block_size = 1 + [default = 1024, (help) = "Phile filesystem block size"]; +} + +message LifProto +{ + optional uint32 block_size = 1 + [default = 256, (help) = "LIF filesystem block size"]; +} + +message MicrodosProto {} + +// NEXT_TAG: 16 +message ZDosProto +{ + message Location + { + optional uint32 track = 1 [(help) = "track number"]; + optional uint32 sector = 3 [(help) = "sector ID"]; + } + + optional Location filesystem_start = 1 + [(help) = "position of the filesystem superblock"]; +} + +message RolandFsProto +{ + optional uint32 directory_track = 1 + [(help) = "position of the directory", default = 39]; + optional uint32 block_size = 2 + [(help) = "filesystem block size", default = 3072]; + optional uint32 directory_entries = 3 + [(help) = "number of directory entries", default = 79]; +} + +// NEXT_TAG: 18 +message FilesystemProto +{ + enum FilesystemType + { + NOT_SET = 0; + ACORNDFS = 1; + BROTHER120 = 2; + FATFS = 3; + CPMFS = 4; + AMIGAFFS = 5; + MACHFS = 6; + CBMFS = 7; + PRODOS = 8; + SMAKY6 = 9; + APPLEDOS = 10; + PHILE = 11; + LIF = 12; + MICRODOS = 13; + ZDOS = 14; + ROLAND = 15; + } + + optional FilesystemType type = 10 + [default = NOT_SET, (help) = "filesystem type"]; + + optional AcornDfsProto acorndfs = 1; + optional Brother120FsProto brother120 = 2; + optional FatFsProto fatfs = 3; + optional CpmFsProto cpmfs = 4; + optional AmigaFfsProto amigaffs = 5; + optional MacHfsProto machfs = 6; + optional CbmfsProto cbmfs = 7; + optional ProdosProto prodos = 8; + optional AppledosProto appledos = 12; + optional Smaky6FsProto smaky6 = 11; + optional PhileProto phile = 13; + optional LifProto lif = 14; + optional MicrodosProto microdos = 15; + optional ZDosProto zdos = 16; + optional RolandFsProto roland = 17; +} diff --git a/java/javax.usb.properties b/java/javax.usb.properties new file mode 100644 index 000000000..fd8c2f41f --- /dev/null +++ b/java/javax.usb.properties @@ -0,0 +1 @@ +javax.usb.services=org.usb4java.javax.Services diff --git a/javatests/com/cowlark/fluxengine/algorithms/BUILD.bazel b/javatests/com/cowlark/fluxengine/algorithms/BUILD.bazel new file mode 100644 index 000000000..54cbc5bd2 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/algorithms/BUILD.bazel @@ -0,0 +1,47 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "ReadWriteFluxOperationTest", + srcs = ["ReadWriteFluxOperationTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/algorithms", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "FluxOperationTest", + srcs = ["FluxOperationTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/algorithms", + "//java/com/cowlark/fluxengine/core", + "@maven//:io_reactivex_rxjava3_rxjava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "CommonTest", + srcs = ["CommonTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/algorithms", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/fluxsource", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/algorithms/CommonTest.java b/javatests/com/cowlark/fluxengine/algorithms/CommonTest.java new file mode 100644 index 000000000..afb6a0ead --- /dev/null +++ b/javatests/com/cowlark/fluxengine/algorithms/CommonTest.java @@ -0,0 +1,72 @@ +package com.cowlark.fluxengine.algorithms; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.data.CylinderHead; +import com.cowlark.fluxengine.fluxsource.FluxReadParameters; +import com.cowlark.fluxengine.fluxsource.FluxSource; +import com.cowlark.fluxengine.fluxsource.FluxSourceIterator; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class CommonTest +{ + private static class RecordingFluxSource extends FluxSource + { + @Override + public FluxSourceIterator readFlux(FluxReadParameters parameters) + { + return null; + } + } + + @Test + public void fluxSourceIteratorHolderCaches() + { + final int[] reads = {0}; + FluxSource fluxSource = new FluxSource() + { + @Override + public FluxSourceIterator readFlux(FluxReadParameters parameters) + { + reads[0]++; + return new FluxSourceIterator() + { + @Override + public boolean hasNext() + { + return false; + } + + @Override + public com.cowlark.fluxengine.data.Fluxmap next() + { + return null; + } + }; + } + }; + + Common.FluxSourceIteratorHolder holder = new Common.FluxSourceIteratorHolder(fluxSource); + + FluxSourceIterator it1 = holder.getIterator(FluxReadParameters.builder() + .setCylinder(1).setHead(0).build()); + FluxSourceIterator it2 = holder.getIterator(FluxReadParameters.builder() + .setCylinder(1).setHead(0).build()); + FluxSourceIterator it3 = holder.getIterator(FluxReadParameters.builder() + .setCylinder(2).setHead(1).build()); + + assertThat(reads[0]).isEqualTo(2); + assertThat(it1).isSameInstanceAs(it2); + assertThat(it3).isNotSameInstanceAs(it1); + assertThat(new CylinderHead(1, 0)).isEqualTo(new CylinderHead(1, 0)); + } + + @Test + public void testForEmergencyStopDoesNotThrow() + { + Common.testForEmergencyStop(); + } +} diff --git a/javatests/com/cowlark/fluxengine/algorithms/FluxOperationTest.java b/javatests/com/cowlark/fluxengine/algorithms/FluxOperationTest.java new file mode 100644 index 000000000..8a4b384d2 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/algorithms/FluxOperationTest.java @@ -0,0 +1,272 @@ +package com.cowlark.fluxengine.algorithms; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogMessage.StringMessage; +import com.cowlark.fluxengine.core.Logger; +import com.cowlark.fluxengine.testing.TestHelpers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +@RunWith(JUnit4.class) +public class FluxOperationTest +{ + @Rule public final TestRule loggerRule = TestHelpers.loggerRule(); + + /* A harness whose run() blocks on a semaphore until the test releases it, + * then logs a message. */ + private static class Harness extends FluxOperation + { + final Semaphore gate = new Semaphore(0); + final CountDownLatch started = new CountDownLatch(1); + final CountDownLatch finished = new CountDownLatch(1); + final AtomicInteger disposeCount = new AtomicInteger(); + final CountDownLatch disposed = new CountDownLatch(1); + volatile Thread runThread; + + @Override + public void run() + { + runThread = Thread.currentThread(); + started.countDown(); + try + { + gate.acquire(); + } catch (InterruptedException e) + { + throw new RuntimeException(e); + } + Logger.log(new StringMessage("hello")); + finished.countDown(); + } + + @Override + protected void onDispose() + { + disposeCount.incrementAndGet(); + disposed.countDown(); + } + } + + @Test + public void multipleSubscribersSeeSameOperation() throws Exception + { + Harness harness = new Harness(); + Observable observable = harness.create(); + + List first = new ArrayList<>(); + List second = new ArrayList<>(); + CountDownLatch done = new CountDownLatch(2); + Disposable firstSubscription = observable.subscribe( + m -> { + synchronized (first) + { + first.add(m); + } + }, t -> { + }, done::countDown); + Disposable secondSubscription = observable.subscribe( + m -> { + synchronized (second) + { + second.add(m); + } + }, t -> { + }, done::countDown); + + harness.gate.release(); + + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(first).containsExactly(new StringMessage("hello")); + assertThat(second).containsExactly(new StringMessage("hello")); + } + + @Test + public void consecutiveOperationsRunOnDifferentThreads() throws Exception + { + Harness first = new Harness(); + first.create().subscribe(); + first.gate.release(); + + Harness second = new Harness(); + second.create().subscribe(); + second.gate.release(); + + Harness third = new Harness(); + third.create().subscribe(); + third.gate.release(); + + assertThat(first.finished.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(second.finished.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(third.finished.await(5, TimeUnit.SECONDS)).isTrue(); + + /* Each operation gets its own fresh worker thread, not the test + * thread, and no two operations share a thread. */ + assertThat(first.runThread).isNotEqualTo(Thread.currentThread()); + assertThat(second.runThread).isNotEqualTo(first.runThread); + assertThat(third.runThread).isNotEqualTo(first.runThread); + assertThat(third.runThread).isNotEqualTo(second.runThread); + } + + @Test + public void operationsStartedAtSameTimeAreSerialised() throws Exception + { + Harness first = new Harness(); + Harness second = new Harness(); + first.create().subscribe(); + second.create().subscribe(); + + /* Both operations race for the lock, so either may acquire it first. + * Wait for whichever one does, then verify the other is still + * waiting. */ + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (first.started.getCount() == 1 && second.started.getCount() == 1) + { + if (System.nanoTime() >= deadline) + throw new AssertionError("neither operation started"); + Thread.sleep(1); + } + + Harness running = first.started.getCount() == 0 ? first : second; + Harness waiting = running == first ? second : first; + + /* Only one operation may run at a time: the other must wait. */ + assertThat(waiting.started.getCount()).isEqualTo(1); + + /* Releasing the running operation lets the other run, on its own + * thread. */ + running.gate.release(); + assertThat(waiting.started.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(waiting.runThread).isNotEqualTo(running.runThread); + + waiting.gate.release(); + assertThat(running.finished.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(waiting.finished.await(5, TimeUnit.SECONDS)).isTrue(); + } + + @Test + public void failingOperationDeliversErrorAndCleansUpLogger() throws Exception + { + class TestFluxOperation extends FluxOperation + { + @Override + public void run() + { + throw new RuntimeException("boom"); + } + } + + TestFluxOperation failing = new TestFluxOperation(); + List errors = new ArrayList<>(); + CountDownLatch done = new CountDownLatch(1); + Disposable subscription = failing.create().subscribe( + m -> { + }, t -> { + errors.add(t); + done.countDown(); + }, done::countDown); + + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(errors).hasSize(1); + + /* A fresh worker thread must not inherit the failed operation's + * logger; the default (unset) logger throws. */ + AtomicBoolean loggerThrows = new AtomicBoolean(); + CountDownLatch probed = new CountDownLatch(1); + Schedulers.newThread().scheduleDirect(() -> { + try + { + Logger.log(new StringMessage("probe")); + } catch (IllegalStateException e) + { + loggerThrows.set(true); + } + probed.countDown(); + }); + + assertThat(probed.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(loggerThrows.get()).isTrue(); + } + + @Test + public void operationIsDisposedWhenItCompletes() throws Exception + { + Harness harness = new Harness(); + harness.create().subscribe(); + + harness.gate.release(); + + assertThat(harness.finished.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(harness.disposed.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(harness.disposeCount.get()).isEqualTo(1); + } + + @Test + public void operationIsDisposedWhenItFails() throws Exception + { + Harness harness = new Harness() + { + @Override + public void run() + { + throw new RuntimeException("boom"); + } + }; + + Disposable subscription = harness.create().subscribe( + m -> { + }, t -> { + }, () -> { + }); + + assertThat(harness.disposed.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(harness.disposeCount.get()).isEqualTo(1); + } + + @Test + public void disposingSubscriptionDisposesOperation() throws Exception + { + Harness harness = new Harness(); + Disposable subscription = harness.create().subscribe(); + + try + { + assertThat(harness.started.await(5, TimeUnit.SECONDS)).isTrue(); + + subscription.dispose(); + + assertThat(harness.disposed.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(harness.disposeCount.get()).isEqualTo(1); + } finally + { + /* Always release the gate so a failed assertion doesn't leave a + * worker thread blocked. */ + harness.gate.release(); + } + } + + @Test + public void disposeIsIdempotent() + { + Harness harness = new Harness(); + + harness.dispose(); + harness.dispose(); + + assertThat(harness.disposeCount.get()).isEqualTo(1); + } +} diff --git a/javatests/com/cowlark/fluxengine/algorithms/ReadWriteFluxOperationTest.java b/javatests/com/cowlark/fluxengine/algorithms/ReadWriteFluxOperationTest.java new file mode 100644 index 000000000..c3ee9ac76 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/algorithms/ReadWriteFluxOperationTest.java @@ -0,0 +1,237 @@ +package com.cowlark.fluxengine.algorithms; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.DiskLayout; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.cowlark.fluxengine.data.Track; +import com.cowlark.fluxengine.testing.TestHelpers; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.ArrayList; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ReadWriteFluxOperationTest +{ + @Rule public final TestRule loggerRule = TestHelpers.loggerRule(); + + private static class TestOperation extends ReadWriteFluxOperation + { + @Override + public void run() + { + } + } + + private static LogicalTrackLayout makeLtl() + { + ImmutableList order = ImmutableList.of(0, 1, 2); + return new LogicalTrackLayout( + 0, + 0, + 1, + 0, + 0, + 3, + 256, + order, + order, + order, + ImmutableMap.of(0, 0, 1, 1, 2, 2), + ImmutableMap.of(0, 0, 1, 1, 2, 2)); + } + + private static Sector makeSector(int sectorId, Sector.Status status) + { + Sector sector = new Sector(new LogicalLocation(0, 0, sectorId)); + sector.status = status; + return sector; + } + + private static ConfigProto makeConfig() + { + return new ConfigBuilder().set("usb.serial", "test-serial") + .set("drive.rotational_period_ms", "200") + .set("layout.tracks", "1") + .set("layout.sides", "1") + .set("layout.layoutdata[0].sector_size", "256") + .set("layout.layoutdata[0].physical.start_sector", "0") + .set("layout.layoutdata[0].physical.count", "8") + .build(); + } + + @Test + public void collectSectorsDeduplicatesOkAndBad() + { + List sectors = new ArrayList<>(); + sectors.add(makeSector(0, Sector.Status.OK)); + sectors.add(makeSector(0, Sector.Status.BAD_CHECKSUM)); + sectors.add(makeSector(1, Sector.Status.BAD_CHECKSUM)); + sectors.add(makeSector(1, Sector.Status.OK)); + sectors.add(makeSector(2, Sector.Status.BAD_CHECKSUM)); + + List result = ReadWriteFluxOperation.collectSectors(sectors, true); + + assertThat(result).hasSize(3); + assertThat(result.get(0).status).isEqualTo(Sector.Status.OK); + assertThat(result.get(1).status).isEqualTo(Sector.Status.OK); + assertThat(result.get(2).status).isEqualTo(Sector.Status.BAD_CHECKSUM); + } + + @Test + public void collectSectorsPrefersOkOverMissing() + { + List sectors = new ArrayList<>(); + sectors.add(makeSector(0, Sector.Status.MISSING)); + sectors.add(makeSector(0, Sector.Status.OK)); + + List result = ReadWriteFluxOperation.collectSectors(sectors); + + assertThat(result).hasSize(1); + assertThat(result.get(0).status).isEqualTo(Sector.Status.OK); + } + + @Test + public void collectSectorsConflictWhenBothOkDifferentData() + { + Sector a = makeSector(0, Sector.Status.OK); + a.data = Bytes.of(1); + Sector b = makeSector(0, Sector.Status.OK); + b.data = Bytes.of(2); + + /* collapseConflicts=false keeps both as CONFLICT. */ + List result = ReadWriteFluxOperation.collectSectors(List.of(a, b), false); + assertThat(result).hasSize(2); + assertThat(result.get(0).status).isEqualTo(Sector.Status.CONFLICT); + assertThat(result.get(1).status).isEqualTo(Sector.Status.CONFLICT); + + /* collapseConflicts=true collapses to a single CONFLICT. */ + List collapsed = ReadWriteFluxOperation.collectSectors(List.of(a, b), true); + assertThat(collapsed).hasSize(1); + assertThat(collapsed.get(0).status).isEqualTo(Sector.Status.CONFLICT); + } + + @Test + public void collectSectorsOkDataSameCollapses() + { + Sector a = makeSector(0, Sector.Status.OK); + a.data = Bytes.of(1); + Sector b = makeSector(0, Sector.Status.OK); + b.data = Bytes.of(1); + + List result = ReadWriteFluxOperation.collectSectors(List.of(a, b), false); + + assertThat(result).hasSize(1); + assertThat(result.get(0).status).isEqualTo(Sector.Status.OK); + } + + @Test + public void combineRecordAndSectorsFillsMissing() + { + /* A track with only sector 0 present; the layout wants 0,1,2. */ + Track track = new Track(); + track.allSectors = new ArrayList<>(); + track.allSectors.add(makeSector(0, Sector.Status.OK)); + + ReadWriteFluxOperation.CombinationResult cr = + ReadWriteFluxOperation.combineRecordAndSectors(List.of(track), makeLtl()); + + assertThat(cr.result).isEqualTo(ReadWriteFluxOperation.BadSectorsState.HAS_BAD_SECTORS); + assertThat(cr.sectors).hasSize(3); + + Sector s0 = + cr.sectors.stream().filter(s -> s.location.logicalSector() == 0).findFirst().get(); + Sector s1 = + cr.sectors.stream().filter(s -> s.location.logicalSector() == 1).findFirst().get(); + Sector s2 = + cr.sectors.stream().filter(s -> s.location.logicalSector() == 2).findFirst().get(); + assertThat(s0.status).isEqualTo(Sector.Status.OK); + assertThat(s1.status).isEqualTo(Sector.Status.MISSING); + assertThat(s2.status).isEqualTo(Sector.Status.MISSING); + } + + @Test + public void combineRecordAndSectorsNoBadWhenAllPresent() + { + Track track = new Track(); + track.allSectors = new ArrayList<>(); + track.allSectors.add(makeSector(0, Sector.Status.OK)); + track.allSectors.add(makeSector(1, Sector.Status.OK)); + track.allSectors.add(makeSector(2, Sector.Status.OK)); + + ReadWriteFluxOperation.CombinationResult cr = + ReadWriteFluxOperation.combineRecordAndSectors(List.of(track), makeLtl()); + + assertThat(cr.result).isEqualTo(ReadWriteFluxOperation.BadSectorsState.HAS_NO_BAD_SECTORS); + assertThat(cr.sectors).hasSize(3); + for (Sector sector : cr.sectors) + assertThat(sector.status).isEqualTo(Sector.Status.OK); + } + + @Test + public void combineRecordAndSectorsEmptyTrackIsBad() + { + ReadWriteFluxOperation.CombinationResult cr = + ReadWriteFluxOperation.combineRecordAndSectors(List.of(), makeLtl()); + + assertThat(cr.result).isEqualTo(ReadWriteFluxOperation.BadSectorsState.HAS_BAD_SECTORS); + assertThat(cr.sectors).hasSize(3); + for (Sector sector : cr.sectors) + assertThat(sector.status).isEqualTo(Sector.Status.MISSING); + } + + @Test + public void getConfigReturnsConfiguredConfig() + { + ConfigProto config = makeConfig(); + TestOperation operation = new TestOperation(); + operation.setConfig(config); + + assertThat(operation.getConfig()).isSameInstanceAs(config); + } + + @Test + public void getDiskLayoutBuildsFromConfig() + { + TestOperation operation = new TestOperation(); + operation.setConfig(makeConfig()); + operation.init(); + + DiskLayout diskLayout = operation.getDiskLayout(); + + assertThat(diskLayout).isNotNull(); + assertThat(diskLayout.logicalLocations).isNotEmpty(); + assertThat(diskLayout.layoutByLogicalLocation.size()).isEqualTo(1); + } + + @Test + public void getDiskLayoutIsMemoized() + { + TestOperation operation = new TestOperation(); + operation.setConfig(makeConfig()); + operation.init(); + + assertThat(operation.getDiskLayout()).isSameInstanceAs(operation.getDiskLayout()); + } + + @Test + public void disposeDoesNotThrowWhenNothingCreated() + { + TestOperation operation = new TestOperation(); + operation.setConfig(makeConfig()); + operation.init(); + + operation.dispose(); + } +} diff --git a/javatests/com/cowlark/fluxengine/arch/ArchEncoderTest.java b/javatests/com/cowlark/fluxengine/arch/ArchEncoderTest.java new file mode 100644 index 000000000..95e8bbe6f --- /dev/null +++ b/javatests/com/cowlark/fluxengine/arch/ArchEncoderTest.java @@ -0,0 +1,77 @@ +package com.cowlark.fluxengine.arch; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.encoders.Encoder; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ArchEncoderTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + @Test + public void noEncoderConfiguredThrows() + { + ConfigProto config = new ConfigBuilder() + .set("usb.serial", "test-serial") + .build(); + + assertThrows( + FluxEngineException.class, + () -> Arch.createEncoder(config)); + } + + @Test + public void createAmigaEncoder() + { + ConfigProto config = new ConfigBuilder() + .set("usb.serial", "test-serial") + .set("drive.rotational_period_ms", "200") + .set("encoder.amiga.clock_rate_us", "2.0") + .build(); + + Encoder encoder = Arch.createEncoder(config); + + assertThat(encoder).isInstanceOf( + com.cowlark.fluxengine.arch.amiga.AmigaEncoder.class); + } + + @Test + public void createIbmEncoder() + { + ConfigProto config = new ConfigBuilder() + .set("usb.serial", "test-serial") + .set("drive.rotational_period_ms", "200") + .set("encoder.ibm.trackdata[0].emit_iam", "false") + .build(); + + Encoder encoder = Arch.createEncoder(config); + + assertThat(encoder).isInstanceOf( + com.cowlark.fluxengine.arch.ibm.IbmEncoder.class); + } + + @Test + public void createTartuEncoder() + { + ConfigProto config = new ConfigBuilder() + .set("usb.serial", "test-serial") + .set("drive.rotational_period_ms", "200") + .set("encoder.tartu.clock_period_us", "2.0") + .build(); + + Encoder encoder = Arch.createEncoder(config); + + assertThat(encoder).isInstanceOf( + com.cowlark.fluxengine.arch.tartu.TartuEncoder.class); + } +} diff --git a/javatests/com/cowlark/fluxengine/arch/BUILD.bazel b/javatests/com/cowlark/fluxengine/arch/BUILD.bazel new file mode 100644 index 000000000..44c6493aa --- /dev/null +++ b/javatests/com/cowlark/fluxengine/arch/BUILD.bazel @@ -0,0 +1,18 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "ArchEncoderTest", + srcs = ["ArchEncoderTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/arch", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/encoders", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/arch/amiga/AmigaEncoderTest.java b/javatests/com/cowlark/fluxengine/arch/amiga/AmigaEncoderTest.java new file mode 100644 index 000000000..eed0f1368 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/arch/amiga/AmigaEncoderTest.java @@ -0,0 +1,70 @@ +package com.cowlark.fluxengine.arch.amiga; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +import com.google.common.collect.ImmutableList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class AmigaEncoderTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + private ConfigProto makeConfig() + { + return new ConfigBuilder() + .set("usb.serial", "test-serial") + .set("drive.rotational_period_ms", "200") + .set("encoder.amiga.clock_rate_us", "2.0") + .build(); + } + + @Test + public void encodeProducesPulses() + { + ConfigProto config = makeConfig(); + AmigaEncoder encoder = new AmigaEncoder(config, 200 * 1e6); + + Image image = new Image(); + Sector sector = image.put(0, 0, 0); + sector.data = new Bytes(512); + + List sectors = ImmutableList.of(sector); + + Fluxmap fluxmap = encoder.encode(null, sectors, image); + + assertThat(fluxmap.ticks()).isGreaterThan(0); + assertThat(fluxmap.bytes()).isGreaterThan(0); + } + + @Test + public void encodeRejectsBadSectorSize() + { + ConfigProto config = makeConfig(); + AmigaEncoder encoder = new AmigaEncoder(config, 200 * 1e6); + + Image image = new Image(); + Sector sector = image.put(0, 0, 0); + sector.data = new Bytes(511); + + List sectors = ImmutableList.of(sector); + + FluxEngineException e = assertThrows( + FluxEngineException.class, + () -> encoder.encode(null, sectors, image)); + assertThat(e.getMessage()).contains("unsupported sector size"); + } +} diff --git a/javatests/com/cowlark/fluxengine/arch/amiga/AmigaTest.java b/javatests/com/cowlark/fluxengine/arch/amiga/AmigaTest.java new file mode 100644 index 000000000..bf9d08279 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/arch/amiga/AmigaTest.java @@ -0,0 +1,38 @@ +package com.cowlark.fluxengine.arch.amiga; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.Bytes; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class AmigaTest +{ + private static final Bytes TEST_DATA = Bytes.of( + 0x52, /* 0101 0010 */ + 0xff, /* 1111 1111 */ + 0x4a, /* 0100 1010 */ + 0x22 /* 0010 0010 */ + ); + + private static final Bytes TEST_DATA_INTERLEAVED = Bytes.of( + 0x1f, /* 0001 1111 */ + 0x35, /* 0011 0101 */ + 0xcf, /* 1100 1111 */ + 0x80 /* 1000 0000 */ + ); + + @Test + public void interleave() + { + assertThat(Amiga.amigaInterleave(TEST_DATA)).isEqualTo(TEST_DATA_INTERLEAVED); + } + + @Test + public void deinterleave() + { + assertThat(Amiga.amigaDeinterleave(TEST_DATA_INTERLEAVED)).isEqualTo(TEST_DATA); + } +} \ No newline at end of file diff --git a/javatests/com/cowlark/fluxengine/arch/amiga/BUILD.bazel b/javatests/com/cowlark/fluxengine/arch/amiga/BUILD.bazel new file mode 100644 index 000000000..f15e62825 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/arch/amiga/BUILD.bazel @@ -0,0 +1,30 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "AmigaTest", + srcs = ["AmigaTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/arch", + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "AmigaEncoderTest", + srcs = ["AmigaEncoderTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/arch", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/buildtools/BUILD.bazel b/javatests/com/cowlark/fluxengine/buildtools/BUILD.bazel new file mode 100644 index 000000000..3b66d4c30 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/buildtools/BUILD.bazel @@ -0,0 +1,15 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "ProtoEncodeTest", + srcs = ["ProtoEncodeTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/buildtools", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "@com_google_protobuf//java/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/buildtools/ProtoEncodeTest.java b/javatests/com/cowlark/fluxengine/buildtools/ProtoEncodeTest.java new file mode 100644 index 000000000..4ba880169 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/buildtools/ProtoEncodeTest.java @@ -0,0 +1,87 @@ +package com.cowlark.fluxengine.buildtools; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.google.protobuf.TextFormat; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ProtoEncodeTest +{ + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String PROTO_CLASS = "com.cowlark.fluxengine.config.ConfigProto"; + + private static ConfigProto parse(String textproto) throws TextFormat.ParseException + { + ConfigProto.Builder builder = ConfigProto.newBuilder(); + TextFormat.merge(textproto, builder); + return builder.build(); + } + + @Test + public void encodesPlainTextproto() throws Exception + { + String textpb = "shortname: 'test'\ncomment: 'a comment'\n"; + ConfigProto expected = parse(textpb); + + byte[] data = ProtoEncode.encodeToBytes(textpb, PROTO_CLASS); + assertThat(ConfigProto.parseFrom(data)).isEqualTo(expected); + } + + @Test + public void encodesMultilineStrings() throws Exception + { + String textpb = + "shortname: 'test'\n" + + "documentation:\n" + + "<<<\n" + + "The first line\n" + + "The second line\n" + + ">>>\n"; + ConfigProto expected = parse( + "shortname: 'test'\n" + + "documentation: \"The first line\\nThe second line\\n\"\n"); + + byte[] data = ProtoEncode.encodeToBytes(textpb, PROTO_CLASS); + assertThat(ConfigProto.parseFrom(data)).isEqualTo(expected); + } + + @Test + public void encodesMultilineStringsWithUnicode() throws Exception + { + String textpb = + "shortname: 'test'\n" + + "documentation:\n" + + "<<<\n" + + "Агат is Russian\n" + + ">>>\n"; + ConfigProto expected = parse( + "shortname: 'test'\n" + + "documentation: \"Агат is Russian\\n\"\n"); + + byte[] data = ProtoEncode.encodeToBytes(textpb, PROTO_CLASS); + assertThat(ConfigProto.parseFrom(data)).isEqualTo(expected); + } + + @Test + public void writesBinaryFileThatRoundTrips() throws Exception + { + String textpb = "shortname: 'agat'\ncomment: 'a format'\n"; + ConfigProto expected = parse(textpb); + + Path output = tmp.newFile("agat.bin").toPath(); + ProtoEncode.encodeToFile(textpb, output.toString(), PROTO_CLASS); + + byte[] data = Files.readAllBytes(output); + assertThat(ConfigProto.parseFrom(data)).isEqualTo(expected); + } +} diff --git a/javatests/com/cowlark/fluxengine/cli/BUILD.bazel b/javatests/com/cowlark/fluxengine/cli/BUILD.bazel new file mode 100644 index 000000000..4a50ea402 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/cli/BUILD.bazel @@ -0,0 +1,15 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "InspectCommandTest", + srcs = ["InspectCommandTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/cli/InspectCommandTest.java b/javatests/com/cowlark/fluxengine/cli/InspectCommandTest.java new file mode 100644 index 000000000..936a4befc --- /dev/null +++ b/javatests/com/cowlark/fluxengine/cli/InspectCommandTest.java @@ -0,0 +1,52 @@ +package com.cowlark.fluxengine.cli; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.FluxmapReader; +import com.cowlark.fluxengine.decoders.DecoderProto; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class InspectCommandTest +{ + @Test + public void guessClockDetectsTightClock() + { + /* Pulses every 12 ticks, giving a 12-tick clock. */ + Fluxmap fluxmap = new Fluxmap(); + for (int i = 0; i < 5000; i++) + { + fluxmap.appendInterval(12); + fluxmap.appendPulse(); + } + + FluxmapReader fmr = new FluxmapReader(fluxmap, DecoderProto.getDefaultInstance()); + FluxmapReader.ClockData data = fmr.guessClock(0.01, 0.05); + + assertThat(data.medianTicks).isEqualTo(12); + assertThat(data.buckets[12]).isGreaterThan(0); + } + + @Test + public void guessClockSkipsLongIntervals() + { + /* Intervals longer than 255 ticks are skipped by the histogram. */ + Fluxmap fluxmap = new Fluxmap(); + for (int i = 0; i < 100; i++) + { + fluxmap.appendInterval(300); + fluxmap.appendPulse(); + } + + FluxmapReader fmr = new FluxmapReader(fluxmap, DecoderProto.getDefaultInstance()); + FluxmapReader.ClockData data = fmr.guessClock(0.01, 0.05); + + int total = 0; + for (int b : data.buckets) + total += b; + assertThat(total).isEqualTo(0); + } +} diff --git a/javatests/com/cowlark/fluxengine/config/BUILD.bazel b/javatests/com/cowlark/fluxengine/config/BUILD.bazel new file mode 100644 index 000000000..63d1865fc --- /dev/null +++ b/javatests/com/cowlark/fluxengine/config/BUILD.bazel @@ -0,0 +1,32 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "ConfigBuilderTest", + srcs = ["ConfigBuilderTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//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/core/flags", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "ProtoPathTest", + srcs = ["ProtoPathTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/config/ConfigBuilderTest.java b/javatests/com/cowlark/fluxengine/config/ConfigBuilderTest.java new file mode 100644 index 000000000..28528ab8e --- /dev/null +++ b/javatests/com/cowlark/fluxengine/config/ConfigBuilderTest.java @@ -0,0 +1,421 @@ +package com.cowlark.fluxengine.config; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.core.flags.FlagGroup; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@RunWith(JUnit4.class) +public class ConfigBuilderTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + /* ConfigBuilder defaults to a drive flux source, which makes build() + * select a USB device; stub the serial so no hardware is needed. */ + private static ConfigBuilder builder() + { + return new ConfigBuilder().set("usb.serial", "test-serial"); + } + + @Test + public void loadConfigFileMergesTextproto() throws IOException + { + Path file = Files.createTempFile("config", ".textproto"); + Files.writeString(file, "shortname: \"myconfig\"\ntracks: \"c=0:2\"\n"); + + ConfigProto proto = builder().loadConfigFile(file.toString()).build(); + + assertThat(proto.getShortname()).isEqualTo("myconfig"); + assertThat(proto.getTracks()).isEqualTo("c=0:2"); + } + + @Test + public void loadConfigFileMergesAcrossFiles() throws IOException + { + Path first = Files.createTempFile("config", ".textproto"); + Path second = Files.createTempFile("config", ".textproto"); + Files.writeString(first, "shortname: \"first\"\n"); + Files.writeString(second, "tracks: \"c=0:2\"\n"); + + ConfigProto proto = builder().loadConfigFile(first.toString()) + .loadConfigFile(second.toString()) + .build(); + + assertThat(proto.getShortname()).isEqualTo("first"); + assertThat(proto.getTracks()).isEqualTo("c=0:2"); + } + + @Test + public void loadConfigFileMissingFileThrows() + { + assertThrows( + ConfigException.class, + () -> new ConfigBuilder().loadConfigFile("/nonexistent/config")); + } + + @Test + public void loadConfigFileLoadsBuiltInFormatByName() + { + ConfigProto proto = builder().loadConfigFile("amiga").build(); + + assertThat(proto.getShortname()).isEqualTo("Amiga"); + } + + @Test + public void findOptionLooksUpTopLevelOption() + { + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + + ConfigBuilder.OptionInfo info = builder.findOption("hd"); + + assertThat(info.option().getName()).isEqualTo("hd"); + assertThat(info.group()).isNull(); + assertThat(info.usesValue()).isFalse(); + } + + @Test + public void buildAppliesDefaultOptions() + { + /* _global_options drivetype group has 80 set by default. */ + ConfigProto proto = builder().loadConfigFile("_global_options").build(); + + assertThat(proto.getDrive().getTracks()).isEqualTo("c0-80h0-1"); + assertThat(proto.getDrive().getDriveType()) + .isEqualTo(com.cowlark.fluxengine.external.DriveType.DRIVETYPE_80TRACK); + } + + @Test + public void buildDoesNotApplyDefaultForAppliedGroup() + { + /* If drivetype=40 is applied explicitly, the default (80) must not + * also be applied. */ + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + ConfigBuilder.OptionInfo info = builder.findOption("drivetype"); + builder.applyOption(info, "40"); + + ConfigProto proto = builder.build(); + + assertThat(proto.getDrive().getTracks()).isEqualTo("c0-40h0-1"); + } + + @Test + public void findOptionLooksUpOptionInUnnamedGroup() + { + ConfigBuilder builder = builder().loadConfigFile("amiga"); + + ConfigBuilder.OptionInfo info = builder.findOption("without_metadata"); + + assertThat(info.option().getName()).isEqualTo("without_metadata"); + assertThat(info.group()).isNotNull(); + assertThat(info.usesValue()).isFalse(); + } + + @Test + public void findOptionMissingThrows() + { + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + + assertThrows(ConfigException.class, () -> builder.findOption("no such option")); + } + + @Test + public void fromFlagsLooksUpOption() + { + /* --hd is a top-level option in _global_options; without a dot it is + * looked up as an option rather than a config path. */ + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + + builder.fromFlags(ImmutableList.of("--hd"), new FlagGroup()); + + assertThat(builder.findOption("hd").option().getName()).isEqualTo("hd"); + } + + @Test + public void fromFlagsUnknownOptionThrows() + { + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + + assertThrows( + FluxEngineException.class, + () -> builder.fromFlags(ImmutableList.of("--no-such-option"), new FlagGroup())); + } + + @Test + public void fromFlagsOptionWithoutValue() + { + /* --hd is a top-level option with no value in _global_options. */ + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + + builder.fromFlags(ImmutableList.of("--hd"), new FlagGroup()); + + assertThat(builder.findOption("hd").usesValue()).isFalse(); + } + + @Test + public void fromFlagsOptionWithValue() + { + /* --drivetype is a top-level option with a value in _global_options. */ + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + + builder.fromFlags(ImmutableList.of("--drivetype=80"), new FlagGroup()); + + assertThat(builder.findOption("drivetype").usesValue()).isTrue(); + } + + @Test + public void fromFlagsConfigKeySetsValue() + { + /* A dotted key is a config path, not an option. */ + ConfigBuilder builder = builder(); + + builder.fromFlags(ImmutableList.of("--drive.drive=1"), new FlagGroup()); + + assertThat(builder.build().getDrive().getDrive()).isEqualTo(1); + } + + @Test + public void fromFlagsConfigKeyWithoutDotSetsValue() + { + /* A config key which doesn't have a dot (e.g. --tracks) is also a + * config path, not an option. */ + ConfigBuilder builder = builder(); + + builder.fromFlags(ImmutableList.of("--tracks=c0-80h0-1"), new FlagGroup()); + + assertThat(builder.build().getTracks()).isEqualTo("c0-80h0-1"); + } + + @Test + public void getReturnsConfigValue() + { + ConfigBuilder builder = builder().set("tracks", "c0-80h0-1"); + + assertThat(builder.get("tracks")).isEqualTo("c0-80h0-1"); + } + + @Test + public void getOnUnknownKeyThrows() + { + ConfigBuilder builder = builder(); + + assertThrows(ProtoPathNotFoundException.class, () -> builder.get("nosuchfield")); + } + + @Test + public void applyOptionIsCallable() + { + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + + ConfigBuilder.OptionInfo info = builder.findOption("hd"); + builder.applyOption(info, null); + } + + @Test + public void applyOptionGroupSelectsOptionByValue() + { + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + + ConfigBuilder.OptionInfo info = builder.findOption("drivetype"); + builder.applyOption(info, "80"); + } + + @Test + public void applyOptionGroupWithInvalidValueThrows() + { + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + + ConfigBuilder.OptionInfo info = builder.findOption("drivetype"); + assertThrows( + ConfigException.class, + () -> builder.applyOption(info, "bogus")); + } + + @Test + public void checkOptionValidAppliesWhenPrerequisiteMet() throws Exception + { + Path file = Files.createTempFile("config", ".textproto"); + Files.writeString(file, """ + option { + name: "needs_serial" + prerequisite { + key: "usb.serial" + value: "test-serial" + } + config { + comment: "applied" + } + } + """); + + ConfigBuilder builder = builder().loadConfigFile(file.toString()).set("usb.serial", "test-serial"); + ConfigBuilder.OptionInfo info = builder.findOption("needs_serial"); + builder.applyOption(info, null); + + assertThat(builder.build().getComment()).isEqualTo("applied"); + } + + @Test + public void checkOptionValidThrowsWhenPrerequisiteNotMet() throws Exception + { + Path file = Files.createTempFile("config", ".textproto"); + Files.writeString(file, """ + option { + name: "needs_serial" + prerequisite { + key: "usb.serial" + value: "test-serial" + } + config { + comment: "applied" + } + } + """); + + ConfigBuilder builder = builder().loadConfigFile(file.toString()).set("usb.serial", "other"); + ConfigBuilder.OptionInfo info = builder.findOption("needs_serial"); + assertThrows( + InapplicableOptionException.class, + () -> builder.applyOption(info, null)); + } + + @Test + public void findOptionNamedGroupReturnsUsesValue() + { + /* Named groups (drivetype, drivespeed, bus) are found, but they select + * an option by value, so usesValue is true and no option is set. */ + ConfigBuilder builder = builder().loadConfigFile("_global_options"); + + ConfigBuilder.OptionInfo info = builder.findOption("drivetype"); + + assertThat(info.group()).isNotNull(); + assertThat(info.option()).isNull(); + assertThat(info.usesValue()).isTrue(); + } + + @Test + public void loadConfigFileLoadsBuiltInFormatBeforeFile() + { + /* A file named "amiga" may exist, but the built-in format must take + * precedence. */ + ConfigProto proto = builder().loadConfigFile("amiga").build(); + + assertThat(proto.getShortname()).isEqualTo("Amiga"); + } + + @Test + public void loadConfigFileBadTextprotoThrows() throws IOException + { + Path file = Files.createTempFile("config", ".textproto"); + Files.writeString(file, "this is not a valid textproto\n"); + + assertThrows( + ConfigException.class, + () -> new ConfigBuilder().loadConfigFile(file.toString())); + } + + @Test + public void setMergesWithLoadedConfig() throws IOException + { + Path file = Files.createTempFile("config", ".textproto"); + Files.writeString(file, "shortname: \"myconfig\"\n"); + + ConfigProto proto = + builder().loadConfigFile(file.toString()).set("tracks", "c=0:2").build(); + + assertThat(proto.getShortname()).isEqualTo("myconfig"); + assertThat(proto.getTracks()).isEqualTo("c=0:2"); + } + + @Test + public void fromFlagsSetsDottedConfig() + { + ConfigProto proto = + builder().fromFlags(ImmutableList.of("--drive.drive=1"), new FlagGroup()).build(); + + assertThat(proto.getDrive().getDrive()).isEqualTo(1); + } + + @Test + public void withFluxSource() + { + ConfigProto proto = builder().withFluxSource("foo.flux").build(); + + assertThat(proto.getFluxSource().getType()).isEqualTo(FluxSourceSinkType.FLUXTYPE_FLUX); + assertThat(proto.getFluxSource().getFl2().getFilename()).isEqualTo("foo.flux"); + } + + @Test + public void withFluxSourceDrive() + { + ConfigProto proto = builder().withFluxSource("drive:1").build(); + + assertThat(proto.getFluxSource().getType()).isEqualTo(FluxSourceSinkType.FLUXTYPE_DRIVE); + assertThat(proto.getDrive().getDrive()).isEqualTo(1); + } + + @Test + public void withImageWriter() + { + ConfigProto proto = builder().withImageWriter("out.dsk").build(); + + assertThat(proto.getImageWriter().getType()).isEqualTo(ImageReaderWriterType.IMAGETYPE_IMG); + assertThat(proto.getImageWriter().getFilename()).isEqualTo("out.dsk"); + } + + @Test + public void withCopyFluxTo() + { + ConfigProto proto = builder().withCopyFluxTo("copy.scp").build(); + + assertThat(proto.getDecoder() + .getCopyFluxTo() + .getType()).isEqualTo(FluxSourceSinkType.FLUXTYPE_SCP); + assertThat(proto.getDecoder().getCopyFluxTo().getScp().getFilename()).isEqualTo("copy.scp"); + } + + @Test + public void withFluxSink() + { + ConfigProto proto = builder().withFluxSink("vcd:vcdfiles").build(); + + assertThat(proto.getFluxSink().getType()).isEqualTo(FluxSourceSinkType.FLUXTYPE_VCD); + assertThat(proto.getFluxSink().getVcd().getDirectory()).isEqualTo("vcdfiles"); + } + + @Test + public void withImageReader() + { + ConfigProto proto = builder().withImageReader("in.dim").build(); + + assertThat(proto.getImageReader().getType()).isEqualTo(ImageReaderWriterType.IMAGETYPE_DIM); + assertThat(proto.getImageReader().getFilename()).isEqualTo("in.dim"); + } + + @Test + public void withImageWriterReadOnlyThrows() + { + assertThrows(ConfigException.class, () -> builder().withImageWriter("out.dim")); + } + + @Test + public void withImageReaderUnrecognisedThrows() + { + assertThrows(ConfigException.class, () -> builder().withImageReader("bogus")); + } + + @Test + public void withFluxSourceUnrecognisedThrows() + { + assertThrows(ConfigException.class, () -> builder().withFluxSource("bogus")); + } +} diff --git a/javatests/com/cowlark/fluxengine/config/ProtoPathTest.java b/javatests/com/cowlark/fluxengine/config/ProtoPathTest.java new file mode 100644 index 000000000..42e885422 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/config/ProtoPathTest.java @@ -0,0 +1,186 @@ +package com.cowlark.fluxengine.config; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.assertThrows; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ProtoPathTest +{ + private static ConfigProto set(String path, String value) + { + ConfigProto.Builder builder = ConfigProto.newBuilder(); + ProtoPath.set(builder, path, value); + return builder.build(); + } + + private static String get(String path) + { + return ProtoPath.get(ConfigProto.newBuilder(), path); + } + + @Test + public void getTopLevelString() + { + assertThat(get("tracks")).isEqualTo(""); + } + + @Test + public void getNestedInt() + { + ConfigProto.Builder builder = ConfigProto.newBuilder(); + ProtoPath.set(builder, "drive.drive", "5"); + assertThat(ProtoPath.get(builder, "drive.drive")).isEqualTo("5"); + } + + @Test + public void getNestedBool() + { + ConfigProto.Builder builder = ConfigProto.newBuilder(); + ProtoPath.set(builder, "drive.high_density", "true"); + assertThat(ProtoPath.get(builder, "drive.high_density")).isEqualTo("true"); + } + + @Test + public void getNestedEnum() + { + ConfigProto.Builder builder = ConfigProto.newBuilder(); + ProtoPath.set(builder, "drive.drive_type", "DRIVETYPE_80TRACK"); + assertThat(ProtoPath.get(builder, "drive.drive_type")).isEqualTo("DRIVETYPE_80TRACK"); + } + + @Test + public void getRepeatedStringWithIndex() + { + ConfigProto.Builder builder = ConfigProto.newBuilder(); + ProtoPath.set(builder, "documentation[2]", "hello"); + assertThat(ProtoPath.get(builder, "documentation[2]")).isEqualTo("hello"); + } + + @Test + public void getUnknownFieldThrows() + { + assertThrows(ProtoPathNotFoundException.class, () -> get("bogus")); + } + + @Test + public void getUnknownNestedFieldThrows() + { + assertThrows(ProtoPathNotFoundException.class, () -> get("drive.bogus")); + } + + @Test + public void setTopLevelString() + { + assertThat(set("tracks", "c=0:2").getTracks()).isEqualTo("c=0:2"); + } + + @Test + public void setNestedInt() + { + assertThat(set("drive.drive", "0").getDrive().getDrive()).isEqualTo(0); + } + + @Test + public void setNestedBool() + { + assertThat(set("drive.high_density", "y").getDrive().getHighDensity()).isTrue(); + } + + @Test + public void setNestedEnum() + { + assertThat(set("drive.drive_type", "DRIVETYPE_80TRACK").getDrive() + .getDriveType() + .name()).isEqualTo("DRIVETYPE_80TRACK"); + } + + @Test + public void setRepeatedStringWithIndex() + { + assertThat(set("documentation[2]", "hello").getDocumentationList()).containsExactly( + "", + "", + "hello"); + } + + @Test + public void setRepeatedMessageField() + { + assertThat(set("option[0].comment", "hello").getOption(0).getComment()).isEqualTo("hello"); + } + + @Test + public void setMultipleFieldsMerges() + { + ConfigProto.Builder builder = ConfigProto.newBuilder(); + ProtoPath.set(builder, "tracks", "c=0:2"); + ProtoPath.set(builder, "drive.drive", "0"); + ProtoPath.set(builder, "drive.high_density", "y"); + + ConfigProto proto = builder.build(); + + assertThat(proto.getTracks()).isEqualTo("c=0:2"); + assertThat(proto.getDrive().getDrive()).isEqualTo(0); + assertThat(proto.getDrive().getHighDensity()).isTrue(); + } + + @Test + public void setRepeatedMessageFieldsMerge() + { + ConfigProto.Builder builder = ConfigProto.newBuilder(); + ProtoPath.set(builder, "option[0].comment", "first"); + ProtoPath.set(builder, "option[1].name", "second"); + + ConfigProto proto = builder.build(); + + assertThat(proto.getOption(0).getComment()).isEqualTo("first"); + assertThat(proto.getOption(1).getName()).isEqualTo("second"); + } + + @Test + public void setUnknownFieldThrows() + { + assertThrows(ProtoPathNotFoundException.class, () -> set("bogus", "x")); + } + + @Test + public void setUnknownNestedFieldThrows() + { + assertThrows(ProtoPathNotFoundException.class, () -> set("drive.bogus", "x")); + } + + @Test + public void setMessageDirectlyThrows() + { + assertThrows(ConfigException.class, () -> set("drive", "x")); + } + + @Test + public void setBadNumberThrows() + { + assertThrows(ConfigException.class, () -> set("drive.drive", "notanumber")); + } + + @Test + public void setBadEnumThrows() + { + assertThrows(ConfigException.class, () -> set("drive.drive_type", "BOGUS")); + } + + @Test + public void setRepeatedWithoutIndexThrows() + { + assertThrows(ProtoPathNotFoundException.class, () -> set("documentation", "x")); + } + + @Test + public void setIndexOnScalarThrows() + { + assertThrows(ProtoPathNotFoundException.class, () -> set("tracks[0]", "x")); + } +} diff --git a/javatests/com/cowlark/fluxengine/core/BUILD.bazel b/javatests/com/cowlark/fluxengine/core/BUILD.bazel new file mode 100644 index 000000000..fe3050e40 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/BUILD.bazel @@ -0,0 +1,98 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "BytesTest", + srcs = ["BytesTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "ByteReaderTest", + srcs = ["ByteReaderTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "ByteWriterTest", + srcs = ["ByteWriterTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "BitWriterTest", + srcs = ["BitWriterTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "BitReaderTest", + srcs = ["BitReaderTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "LoggerTest", + srcs = ["LoggerTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/algorithms", + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "LogRendererTest", + srcs = ["LogRendererTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/algorithms", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "BitsTest", + srcs = ["BitsTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "SupplierOfAutocloseableTest", + srcs = ["SupplierOfAutocloseableTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/core/BitReaderTest.java b/javatests/com/cowlark/fluxengine/core/BitReaderTest.java new file mode 100644 index 000000000..9052d2b66 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/BitReaderTest.java @@ -0,0 +1,107 @@ +package com.cowlark.fluxengine.core; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.util.Iterator; + +@RunWith(JUnit4.class) +public class BitReaderTest +{ + @Test + public void readsBits() + { + Bytes bytes = Bytes.of(0xd6, 0xa0); /* 11010110 10100000 */ + BitReader reader = new BitReader(new ByteReader(bytes)); + + boolean[] expected = {true, + true, + false, + true, + false, + true, + true, + false, + true, + false, + true, + false, + false, + false, + false, + false}; + for (boolean bit : expected) + assertThat(reader.get()).isEqualTo(bit); + assertThat(reader.eof()).isTrue(); + } + + @Test + public void roundTrip() + { + Bytes bytes = new Bytes(0); + new BitWriter(new ByteWriter(bytes)).push(0b11010110, 8).push(0b10101100, 8).flush(); + + BitReader reader = new BitReader(new ByteReader(bytes)); + boolean[] expected = {true, + true, + false, + true, + false, + true, + true, + false, + true, + false, + true, + false, + true, + true, + false, + false}; + for (boolean bit : expected) + assertThat(reader.get()).isEqualTo(bit); + assertThat(reader.eof()).isTrue(); + } + + @Test + public void readingPastEndThrows() + { + Bytes bytes = Bytes.of(0x80); + BitReader reader = new BitReader(new ByteReader(bytes)); + for (int i = 0; i < 8; i++) + reader.get(); + + assertThrows(IndexOutOfBoundsException.class, reader::get); + } + + @Test + public void iteration() + { + Iterator iterator = new BitReader(new ByteReader(Bytes.of(0xd6))); + boolean[] expected = {true, true, false, true, false, true, true, false}; + for (boolean bit : expected) + { + assertThat(iterator.hasNext()).isTrue(); + assertThat(iterator.next()).isEqualTo(bit); + } + assertThat(iterator.hasNext()).isFalse(); + assertThrows(java.util.NoSuchElementException.class, iterator::next); + } + + @Test + public void get() + { + BitReader reader = new BitReader(new ByteReader(Bytes.of(0xd6))); + + Bits bits = reader.get(5); + assertThat(bits.size()).isEqualTo(5); + assertThat(bits.get(0)).isTrue(); + assertThat(bits.get(1)).isTrue(); + assertThat(bits.get(2)).isFalse(); + assertThat(bits.get(3)).isTrue(); + assertThat(bits.get(4)).isFalse(); + } +} diff --git a/javatests/com/cowlark/fluxengine/core/BitWriterTest.java b/javatests/com/cowlark/fluxengine/core/BitWriterTest.java new file mode 100644 index 000000000..13720e059 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/BitWriterTest.java @@ -0,0 +1,41 @@ +package com.cowlark.fluxengine.core; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class BitWriterTest +{ + @Test + public void writesWholeByte() + { + Bytes bytes = new Bytes(0); + ByteWriter bw = new ByteWriter(bytes); + new BitWriter(bw).push(0b11010110, 8).flush(); + + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{(byte) 0xd6}); + } + + @Test + public void packsAcrossBytes() + { + Bytes bytes = new Bytes(0); + ByteWriter bw = new ByteWriter(bytes); + new BitWriter(bw).push(0b11010110, 8).push(0b101, 3).flush(); + + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{(byte) 0xd6, 0x05}); + } + + @Test + public void flushesPartialByte() + { + Bytes bytes = new Bytes(0); + ByteWriter bw = new ByteWriter(bytes); + new BitWriter(bw).push(0b101, 3).flush(); + + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{0x05}); + } +} diff --git a/javatests/com/cowlark/fluxengine/core/BitsTest.java b/javatests/com/cowlark/fluxengine/core/BitsTest.java new file mode 100644 index 000000000..d33cda188 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/BitsTest.java @@ -0,0 +1,183 @@ +package com.cowlark.fluxengine.core; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.util.List; + +@RunWith(JUnit4.class) +public class BitsTest +{ + @Test + public void basicGetSet() + { + Bits bits = new Bits(5); + assertThat(bits.size()).isEqualTo(5); + assertThat(bits.get(0)).isFalse(); + assertThat(bits.get(4)).isFalse(); + + assertThat(bits.set(2, true)).isFalse(); + bits.setBit(4, true); + + assertThat(bits.get(2)).isTrue(); + assertThat(bits.getBit(4)).isTrue(); + assertThat(bits.get(0)).isFalse(); + assertThat(bits.set(2, false)).isTrue(); + } + + @Test + public void add() + { + Bits bits = new Bits(0); + bits.add(true); + bits.add(false); + bits.add(true); + + assertThat(bits.size()).isEqualTo(3); + assertThat(bits.get(0)).isTrue(); + assertThat(bits.get(1)).isFalse(); + assertThat(bits.get(2)).isTrue(); + } + + @Test + public void insert() + { + Bits bits = new Bits(3); + bits.add(1, true); + + assertThat(bits.size()).isEqualTo(4); + assertThat(bits.get(0)).isFalse(); + assertThat(bits.get(1)).isTrue(); + assertThat(bits.get(2)).isFalse(); + assertThat(bits.get(3)).isFalse(); + } + + @Test + public void removeThrows() + { + Bits bits = new Bits(2); + + assertThrows(UnsupportedOperationException.class, () -> bits.remove(0)); + assertThrows(UnsupportedOperationException.class, () -> bits.remove(Boolean.TRUE)); + } + + @Test + public void clear() + { + Bits bits = new Bits(4); + bits.set(1, true); + bits.clear(); + assertThat(bits.size()).isEqualTo(0); + } + + @Test + public void iteration() + { + Bits bits = new Bits(0); + bits.add(true); + bits.add(false); + bits.add(true); + + java.util.Iterator it = bits.iterator(); + assertThat(it.next()).isTrue(); + assertThat(it.next()).isFalse(); + assertThat(it.next()).isTrue(); + assertThat(it.hasNext()).isFalse(); + } + + @Test + public void listEquality() + { + Bits bits = new Bits(0); + bits.add(true); + bits.add(false); + + List other = java.util.Arrays.asList(true, false); + assertThat(bits.equals(other)).isTrue(); + assertThat(other.equals(bits)).isTrue(); + } + + @Test + public void boundsChecking() + { + Bits bits = new Bits(2); + + assertThrows(IndexOutOfBoundsException.class, () -> bits.get(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> bits.get(2)); + assertThrows(IndexOutOfBoundsException.class, () -> bits.set(2, true)); + } + + @Test + public void reverseBits() + { + Bits bits = new Bits(0); + bits.add(true); + bits.add(false); + bits.add(true); + bits.add(false); + + Bits reversed = bits.reverseBits(); + assertThat(reversed.size()).isEqualTo(4); + assertThat(reversed.get(0)).isFalse(); + assertThat(reversed.get(1)).isTrue(); + assertThat(reversed.get(2)).isFalse(); + assertThat(reversed.get(3)).isTrue(); + + /* The original is unchanged. */ + assertThat(bits.get(0)).isTrue(); + assertThat(bits.get(2)).isTrue(); + } + + @Test + public void toBytesRoundTrip() + { + Bytes bytes = Bytes.of(0xd6, 0xa5); + assertThat(bytes.toBits().toBytes()).isEqualTo(bytes); + } + + @Test + public void fillBitmapToPattern() + { + Bits bits = new Bits(4); + Bits.Cursor cursor = new Bits.Cursor(0); + + bits.fillBitmapTo(cursor, 4, new boolean[] {true, false}); + + assertThat(cursor.get()).isEqualTo(4); + assertThat(bits.get(0)).isTrue(); + assertThat(bits.get(1)).isFalse(); + assertThat(bits.get(2)).isTrue(); + assertThat(bits.get(3)).isFalse(); + } + + @Test + public void fillBitmapToRespectsTerminateAt() + { + Bits bits = new Bits(10); + Bits.Cursor cursor = new Bits.Cursor(3); + + bits.fillBitmapTo(cursor, 7, new boolean[] {false, true}); + + assertThat(cursor.get()).isEqualTo(7); + assertThat(bits.get(3)).isFalse(); + assertThat(bits.get(4)).isTrue(); + assertThat(bits.get(5)).isFalse(); + assertThat(bits.get(6)).isTrue(); + } + + @Test + public void fillBitmapToStopAtSize() + { + /* The bitmap ends at terminateAt; filling must stop exactly there. */ + Bits bits = new Bits(5); + Bits.Cursor cursor = new Bits.Cursor(0); + + bits.fillBitmapTo(cursor, 5, new boolean[] {true}); + + assertThat(cursor.get()).isEqualTo(5); + assertThat(bits.get(4)).isTrue(); + } +} diff --git a/javatests/com/cowlark/fluxengine/core/ByteReaderTest.java b/javatests/com/cowlark/fluxengine/core/ByteReaderTest.java new file mode 100644 index 000000000..782814c3b --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/ByteReaderTest.java @@ -0,0 +1,130 @@ +package com.cowlark.fluxengine.core; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.assertThrows; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ByteReaderTest +{ + @Test + public void reads8And16() + { + ByteReader reader = new ByteReader(Bytes.of(0x01, 0x02, 0x03, 0x04, 0x05, 0x06)); + + assertThat(reader.read8()).isEqualTo(0x01); + assertThat(reader.readBe16()).isEqualTo(0x0203); + assertThat(reader.readLe16()).isEqualTo(0x0504); + assertThat(reader.read8()).isEqualTo(0x06); + assertThat(reader.eof()).isTrue(); + } + + @Test + public void reads24() + { + ByteReader reader = new ByteReader(Bytes.of(0x01, 0x02, 0x03, 0x04, 0x05, 0x06)); + + assertThat(reader.readBe24()).isEqualTo(0x010203); + assertThat(reader.readLe24()).isEqualTo(0x060504); + } + + @Test + public void reads32() + { + ByteReader reader = + new ByteReader(Bytes.of(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08)); + + assertThat(reader.readBe32()).isEqualTo(0x01020304); + assertThat(reader.readLe32()).isEqualTo(0x08070605); + } + + @Test + public void reads48() + { + ByteReader reader = new ByteReader(Bytes.of( + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, + 0x06, + 0x0a, + 0x0b, + 0x0c, + 0x0d, + 0x0e, + 0x0f)); + + assertThat(reader.readBe48()).isEqualTo(0x010203040506L); + assertThat(reader.readLe48()).isEqualTo(0x0f0e0d0c0b0aL); + } + + @Test + public void reads64() + { + ByteReader reader = new ByteReader(Bytes.of( + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, + 0x06, + 0x07, + 0x08, + 0x09, + 0x0a, + 0x0b, + 0x0c, + 0x0d, + 0x0e, + 0x0f, + 0x10)); + + assertThat(reader.readBe64()).isEqualTo(0x0102030405060708L); + assertThat(reader.readLe64()).isEqualTo(0x100f0e0d0c0b0a09L); + } + + @Test + public void readSlice() + { + ByteReader reader = new ByteReader(Bytes.of(1, 2, 3, 4, 5)); + + Bytes slice = reader.read(2); + assertThat(slice.get(0) & 0xff).isEqualTo(1); + assertThat(slice.get(1) & 0xff).isEqualTo(2); + assertThat(reader.pos()).isEqualTo(2); + assertThat(reader.read8()).isEqualTo(3); + } + + @Test + public void seekSkipAndEof() + { + ByteReader reader = new ByteReader(Bytes.of(1, 2, 3)); + + assertThat(reader.pos()).isEqualTo(0); + assertThat(reader.remaining()).isEqualTo(3); + + assertThat(reader.skip(2).pos()).isEqualTo(2); + assertThat(reader.eof()).isFalse(); + assertThat(reader.remaining()).isEqualTo(1); + + assertThat(reader.skip(1).eof()).isTrue(); + assertThat(reader.seek(0).pos()).isEqualTo(0); + } + + @Test + public void boundsChecking() + { + ByteReader reader = new ByteReader(Bytes.of(1, 2, 3)); + reader.seek(3); + + assertThrows(IndexOutOfBoundsException.class, reader::read8); + assertThrows(IndexOutOfBoundsException.class, () -> reader.seek(2).readBe16()); + assertThrows(IndexOutOfBoundsException.class, () -> reader.seek(0).readBe32()); + assertThrows(IndexOutOfBoundsException.class, () -> reader.seek(0).read(4)); + } +} diff --git a/javatests/com/cowlark/fluxengine/core/ByteWriterTest.java b/javatests/com/cowlark/fluxengine/core/ByteWriterTest.java new file mode 100644 index 000000000..e5fc4fb16 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/ByteWriterTest.java @@ -0,0 +1,127 @@ +package com.cowlark.fluxengine.core; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ByteWriterTest +{ + @Test + public void writes8And16() + { + Bytes bytes = new Bytes(0); + new ByteWriter(bytes).write8(0x01).writeBe16(0x0203).writeLe16(0x0504).write8(0x06); + + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{1, 2, 3, 4, 5, 6}); + } + + @Test + public void writes24And32() + { + Bytes bytes = new Bytes(0); + new ByteWriter(bytes).writeBe24(0x010203) + .writeLe24(0x060504) + .writeBe32(0x0708090a) + .writeLe32(0x0e0d0c0b); + + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14}); + } + + @Test + public void writes48And64() + { + Bytes bytes = new Bytes(0); + new ByteWriter(bytes).writeBe48(0x010203040506L) + .writeLe48(0x0c0b0a090807L) + .writeBe64(0x0102030405060708L) + .writeLe64(0x100f0e0d0c0b0a09L); + + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16}); + } + + @Test + public void writesBytesAndPads() + { + Bytes bytes = new Bytes(0); + new ByteWriter(bytes).write(Bytes.of(1, 2)).write(new byte[]{3, 4}).pad(2, 0xff).pad(1); + + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{1, + 2, + 3, + 4, + (byte) 0xff, + (byte) 0xff, + 0}); + } + + @Test + public void growsAndSeeks() + { + Bytes bytes = new Bytes(1); + bytes.set(0, (byte) 0xaa); + ByteWriter writer = new ByteWriter(bytes); + + writer.seekToEnd().write8(0x01); + assertThat(bytes.size()).isEqualTo(2); + assertThat(bytes.get(0) & 0xff).isEqualTo(0xaa); + assertThat(bytes.get(1) & 0xff).isEqualTo(0x01); + + writer.seek(0).write8(0x02); + assertThat(bytes.get(0) & 0xff).isEqualTo(0x02); + } + + @Test + public void writingToASliceDetachesIt() + { + Bytes parent = Bytes.of(1, 2, 3); + Bytes slice = parent.slice(0, 3); + + new ByteWriter(slice).write8(0xaa); + + assertThat(slice.get(0) & 0xff).isEqualTo(0xaa); + assertThat(parent.get(0) & 0xff).isEqualTo(1); + } +} diff --git a/javatests/com/cowlark/fluxengine/core/BytesTest.java b/javatests/com/cowlark/fluxengine/core/BytesTest.java new file mode 100644 index 000000000..99e2c39ce --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/BytesTest.java @@ -0,0 +1,235 @@ +package com.cowlark.fluxengine.core; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.util.ListIterator; + +@RunWith(JUnit4.class) +public class BytesTest +{ + @Test + public void boundsChecking() + { + Bytes bytes = Bytes.of(1, 2, 3); + + assertThrows(IndexOutOfBoundsException.class, () -> bytes.get(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> bytes.get(3)); + assertThrows(IndexOutOfBoundsException.class, () -> bytes.set(-1, (byte) 0)); + assertThrows(IndexOutOfBoundsException.class, () -> bytes.set(3, (byte) 0)); + assertThrows(IndexOutOfBoundsException.class, () -> bytes.slice(-1, 1)); + assertThrows(IndexOutOfBoundsException.class, () -> bytes.slice(0, -1)); + } + + @Test + public void sliceZeroPads() + { + Bytes bytes = Bytes.of(1, 2, 3); + + assertThat(bytes.slice(1, 3).toByteArray()).isEqualTo(new byte[]{2, 3, 0}); + assertThat(bytes.slice(5, 2).toByteArray()).isEqualTo(new byte[]{0, 0}); + assertThat(bytes.slice(3, 2).toByteArray()).isEqualTo(new byte[]{0, 0}); + assertThat(bytes.slice(2).toByteArray()).isEqualTo(new byte[]{3}); + assertThat(bytes.slice(5).isEmpty()).isTrue(); + } + + @Test + public void clear() + { + Bytes bytes = Bytes.of(1, 2, 3); + bytes.clear(); + assertThat(bytes.size()).isEqualTo(0); + assertThat(bytes.isEmpty()).isTrue(); + } + + @Test + public void split() + { + Bytes bytes = Bytes.of(1, 2, 0, 3, 4, 0, 5); + ImmutableList pieces = bytes.split(0); + + assertThat(pieces).hasSize(3); + assertThat(pieces.get(0).toByteArray()).isEqualTo(new byte[]{1, 2}); + assertThat(pieces.get(1).toByteArray()).isEqualTo(new byte[]{3, 4}); + assertThat(pieces.get(2).toByteArray()).isEqualTo(new byte[]{5}); + + /* Consecutive separators and a trailing separator yield empty pieces. */ + ImmutableList empties = Bytes.of(0, 1, 0, 0).split(0); + assertThat(empties).hasSize(4); + assertThat(empties.get(0).isEmpty()).isTrue(); + assertThat(empties.get(1).toByteArray()).isEqualTo(new byte[]{1}); + assertThat(empties.get(2).isEmpty()).isTrue(); + assertThat(empties.get(3).isEmpty()).isTrue(); + } + + @Test + public void swab() + { + assertThat(Bytes.of(1, 2, 3, 4).swab().toByteArray()).isEqualTo(new byte[]{2, 1, 4, 3}); + + /* Odd length pads the trailing byte with a zero. */ + assertThat(Bytes.of(1, 2, 3).swab().toByteArray()).isEqualTo(new byte[]{2, 1, 0, 3}); + } + + @Test + public void toBits() + { + Bits bits = Bytes.of(0xd6).toBits(); + + assertThat(bits.size()).isEqualTo(8); + assertThat(bits.get(0)).isTrue(); + assertThat(bits.get(1)).isTrue(); + assertThat(bits.get(2)).isFalse(); + assertThat(bits.get(3)).isTrue(); + assertThat(bits.get(4)).isFalse(); + assertThat(bits.get(5)).isTrue(); + assertThat(bits.get(6)).isTrue(); + assertThat(bits.get(7)).isFalse(); + } + + @Test + public void compressAndDecompress() + { + Bytes data = new Bytes(0); + ByteWriter bw = new ByteWriter(data); + for (int i = 0; i < 10000; i++) + bw.write8(i & 0xff); + + Bytes compressed = data.compress(); + + /* zlib format: first byte is the CMF header (0x78 for deflate). */ + assertThat(compressed.get(0) & 0xff).isEqualTo(0x78); + assertThat(compressed.size()).isLessThan(data.size()); + + assertThat(compressed.decompress()).isEqualTo(data); + } + + @Test + public void compressAndUncompressEmpty() + { + Bytes data = new Bytes(0); + assertThat(data.compress().decompress()).isEqualTo(data); + } + + @Test + public void listOperations() + { + Bytes bytes = Bytes.of(1, 2, 3); + + assertThat(bytes.contains(Byte.valueOf((byte) 2))).isTrue(); + assertThat(bytes.indexOf(Byte.valueOf((byte) 2))).isEqualTo(1); + assertThat(bytes.lastIndexOf(Byte.valueOf((byte) 2))).isEqualTo(1); + assertThat(bytes.indexOf(Byte.valueOf((byte) 9))).isEqualTo(-1); + + bytes.add(Byte.valueOf((byte) 4)); + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{1, 2, 3, 4}); + + bytes.add(1, Byte.valueOf((byte) 9)); + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{1, 9, 2, 3, 4}); + + assertThat(bytes.remove(0)).isEqualTo((byte) 1); + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{9, 2, 3, 4}); + + assertThat(bytes.remove(Byte.valueOf((byte) 3))).isTrue(); + assertThat(bytes.toByteArray()).isEqualTo(new byte[]{9, 2, 4}); + + ListIterator it = bytes.listIterator(); + assertThat(it.next()).isEqualTo((byte) 9); + assertThat(it.next()).isEqualTo((byte) 2); + assertThat(it.previous()).isEqualTo((byte) 2); + } + + @Test + public void listEquality() + { + Bytes bytes = Bytes.of(1, 2, 3); + java.util.List other = java.util.Arrays.asList((byte) 1, (byte) 2, (byte) 3); + + assertThat(bytes.equals(other)).isTrue(); + assertThat(other.equals(bytes)).isTrue(); + } + + @Test + public void resizing() + { + Bytes bytes = Bytes.of(1, 2, 3); + + bytes.resize(5); + assertThat(bytes.size()).isEqualTo(5); + assertThat(bytes.get(0) & 0xff).isEqualTo(1); + assertThat(bytes.get(2) & 0xff).isEqualTo(3); + assertThat(bytes.get(3) & 0xff).isEqualTo(0); + assertThat(bytes.get(4) & 0xff).isEqualTo(0); + + bytes.resize(1); + assertThat(bytes.size()).isEqualTo(1); + assertThat(bytes.get(0) & 0xff).isEqualTo(1); + + bytes.resize(0); + assertThat(bytes.size()).isEqualTo(0); + assertThat(bytes.isEmpty()).isTrue(); + } + + @Test + public void slicesShareStorage() + { + Bytes parent = Bytes.of(10, 20, 30); + Bytes view = parent.slice(1, 2); + + assertThat(view.size()).isEqualTo(2); + assertThat(view.get(0) & 0xff).isEqualTo(20); + assertThat(view.get(1) & 0xff).isEqualTo(30); + assertThat(parent.refcount()).isEqualTo(2); + } + + @Test + public void copyOnWriteOnlyWhenShared() + { + Bytes lone = Bytes.of(1, 2, 3); + lone.set(0, (byte) 9); + lone.resize(4); + assertThat(lone.refcount()).isEqualTo(1); + assertThat(lone.get(0) & 0xff).isEqualTo(9); + + /* Shared bytes: a write on the parent detaches it, leaving the view + * unchanged. */ + Bytes parent = Bytes.of(1, 2, 3); + Bytes view = parent.slice(0, 3); + parent.set(0, (byte) 9); + assertThat(parent.get(0) & 0xff).isEqualTo(9); + assertThat(view.get(0) & 0xff).isEqualTo(1); + assertThat(parent.refcount()).isEqualTo(1); + + /* And a write on the view detaches it, leaving the parent unchanged. */ + Bytes parent2 = Bytes.of(1, 2, 3); + Bytes view2 = parent2.slice(0, 3); + view2.set(2, (byte) 7); + assertThat(view2.get(2) & 0xff).isEqualTo(7); + assertThat(parent2.get(2) & 0xff).isEqualTo(3); + assertThat(view2.refcount()).isEqualTo(1); + + /* Resizing a shared window detaches it too. */ + Bytes parent3 = Bytes.of(1, 2, 3); + Bytes view3 = parent3.slice(0, 3); + parent3.resize(5); + assertThat(parent3.size()).isEqualTo(5); + assertThat(view3.size()).isEqualTo(3); + assertThat(view3.get(0) & 0xff).isEqualTo(1); + } + + @Test + public void iteration() + { + Bytes bytes = Bytes.of(1, 2, 3); + int expected = 1; + for (Byte b : bytes) + { + assertThat(b.intValue()).isEqualTo(expected++); + } + assertThat(expected).isEqualTo(4); + } +} diff --git a/javatests/com/cowlark/fluxengine/core/LogRendererTest.java b/javatests/com/cowlark/fluxengine/core/LogRendererTest.java new file mode 100644 index 000000000..231436def --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/LogRendererTest.java @@ -0,0 +1,115 @@ +package com.cowlark.fluxengine.core; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.algorithms.BeginReadOperationLogMessage; +import com.cowlark.fluxengine.algorithms.BeginWriteOperationLogMessage; +import com.cowlark.fluxengine.algorithms.EndSpeedOperationLogMessage; +import com.cowlark.fluxengine.config.OptionLogMessage; +import com.cowlark.fluxengine.config.OptionProto; +import com.cowlark.fluxengine.core.LogMessage.EmergencyStopMessage; +import com.cowlark.fluxengine.core.LogMessage.ErrorLogMessage; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.function.Consumer; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class LogRendererTest +{ + private static String render(Consumer action) + { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + PrintStream stream = new PrintStream(buffer); + LogRenderer renderer = LogRenderer.create(stream); + action.accept(renderer); + stream.flush(); + return buffer.toString(); + } + + @Test + public void errorMessage() + { + String output = render( + r -> r.add(new ErrorLogMessage("disk failed"))); + + assertThat(output).isEqualTo("\n Error: disk failed\n"); + } + + @Test + public void emergencyStop() + { + String output = render( + r -> r.add(new EmergencyStopMessage())); + + assertThat(output).isEqualTo("\n Stop!\n"); + } + + @Test + public void endSpeedOperation() + { + String output = render( + r -> r.add(new EndSpeedOperationLogMessage(200e6))); + + assertThat(output).isEqualTo( + "\n Rotational period is 200.0ms (300.0rpm)\n"); + } + + @Test + public void readOperationHeader() + { + String output = render( + r -> r.add(new BeginReadOperationLogMessage(3, 1))); + + assertThat(output).isEqualTo("\nR 3.1: "); + } + + @Test + public void writeOperationHeader() + { + String output = render( + r -> r.add(new BeginWriteOperationLogMessage(3, 1))); + + assertThat(output).isEqualTo("\nW 3.1: "); + } + + @Test + public void optionMessage() + { + OptionProto option = OptionProto.newBuilder() + .setComment("high density") + .build(); + String output = render( + r -> r.add(new OptionLogMessage("user option", option))); + + assertThat(output).isEqualTo("\n OPTION: user option: high density\n"); + } + + @Test + public void commaSeparates() + { + String output = render(r -> + { + r.add("one"); + r.comma(); + r.add("two"); + }); + + assertThat(output).isEqualTo(" one; two"); + } + + @Test + public void addAfterNewlineIndents() + { + String output = render(r -> + { + r.add("one"); + r.newline(); + r.add("two"); + }); + + assertThat(output).isEqualTo(" one\n two"); + } +} diff --git a/javatests/com/cowlark/fluxengine/core/LoggerTest.java b/javatests/com/cowlark/fluxengine/core/LoggerTest.java new file mode 100644 index 000000000..74e562a39 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/LoggerTest.java @@ -0,0 +1,66 @@ +package com.cowlark.fluxengine.core; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.algorithms.BeginReadOperationLogMessage; +import com.cowlark.fluxengine.core.LogMessage.ErrorLogMessage; +import com.cowlark.fluxengine.core.LogMessage.StringMessage; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class LoggerTest +{ + @Test + public void logfStringWrapsInStringMessage() + { + List messages = new ArrayList<>(); + Logger.setLogger(messages::add); + + Logger.logf("hello"); + + assertThat(messages).containsExactly(new StringMessage("hello")); + } + + @Test + public void logMessagePassesThrough() + { + List messages = new ArrayList<>(); + Logger.setLogger(messages::add); + + Logger.log(new ErrorLogMessage("oops")); + + assertThat(messages).containsExactly(new ErrorLogMessage("oops")); + } + + @Test + public void defaultLoggerRendersToStdout() + { + Logger.setLogger(message -> LogRenderer.create(System.out).add(message)); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + PrintStream stream = new PrintStream(buffer); + LogRenderer renderer = LogRenderer.create(stream); + + renderer.add(new BeginReadOperationLogMessage(3, 1)); + + assertThat(buffer.toString()).isEqualTo("\nR 3.1: "); + } + + @Test + public void logUsesSetLogger() + { + List messages = new ArrayList<>(); + Logger.setLogger(messages::add); + + Logger.logf("one"); + Logger.log(new StringMessage("two")); + + assertThat(messages).hasSize(2); + } +} diff --git a/javatests/com/cowlark/fluxengine/core/SupplierOfAutocloseableTest.java b/javatests/com/cowlark/fluxengine/core/SupplierOfAutocloseableTest.java new file mode 100644 index 000000000..c1c16ef15 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/SupplierOfAutocloseableTest.java @@ -0,0 +1,131 @@ +package com.cowlark.fluxengine.core; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class SupplierOfAutocloseableTest +{ + private static final class TestCloseable implements AutoCloseable + { + final AtomicInteger closes = new AtomicInteger(); + + @Override + public void close() + { + closes.incrementAndGet(); + } + } + + @Test + public void nullDelegateThrows() + { + assertThrows(IllegalArgumentException.class, + () -> new SupplierOfAutocloseable(null)); + } + + @Test + public void getReturnsInstance() + { + TestCloseable delegate = new TestCloseable(); + SupplierOfAutocloseable supplier = + new SupplierOfAutocloseable<>(() -> delegate); + + assertThat(supplier.get()).isSameInstanceAs(delegate); + assertThat(supplier.instance).isSameInstanceAs(delegate); + } + + @Test + public void getMemoizesInstance() + { + AtomicInteger calls = new AtomicInteger(); + SupplierOfAutocloseable supplier = new SupplierOfAutocloseable<>(() -> + { + calls.incrementAndGet(); + return new TestCloseable(); + }); + + TestCloseable first = supplier.get(); + TestCloseable second = supplier.get(); + + assertThat(calls.get()).isEqualTo(1); + assertThat(second).isSameInstanceAs(first); + } + + @Test + public void getAfterCloseThrows() + { + SupplierOfAutocloseable supplier = + new SupplierOfAutocloseable<>(TestCloseable::new); + + assertThrows(Exception.class, () -> + { + supplier.close(); + supplier.get(); + }); + } + + @Test + public void closeClosesCreatedInstance() + { + TestCloseable delegate = new TestCloseable(); + SupplierOfAutocloseable supplier = + new SupplierOfAutocloseable<>(() -> delegate); + + supplier.get(); + + assertThat(delegate.closes.get()).isEqualTo(0); + try + { + supplier.close(); + } catch (Exception e) + { + throw new AssertionError("close should not throw", e); + } + assertThat(delegate.closes.get()).isEqualTo(1); + } + + @Test + public void closeDoesNotCloseNeverCreatedInstance() + { + TestCloseable delegate = new TestCloseable(); + SupplierOfAutocloseable supplier = + new SupplierOfAutocloseable<>(() -> delegate); + + try + { + supplier.close(); + } catch (Exception e) + { + throw new AssertionError("close should not throw", e); + } + + assertThat(delegate.closes.get()).isEqualTo(0); + } + + @Test + public void closeIsIdempotent() + { + TestCloseable delegate = new TestCloseable(); + SupplierOfAutocloseable supplier = + new SupplierOfAutocloseable<>(() -> delegate); + supplier.get(); + + try + { + supplier.close(); + supplier.close(); + } catch (Exception e) + { + throw new AssertionError("close should not throw", e); + } + + /* The instance is only closed once. */ + assertThat(delegate.closes.get()).isEqualTo(1); + } +} diff --git a/javatests/com/cowlark/fluxengine/core/flags/BUILD.bazel b/javatests/com/cowlark/fluxengine/core/flags/BUILD.bazel new file mode 100644 index 000000000..1db693142 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/flags/BUILD.bazel @@ -0,0 +1,15 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "FlagsTest", + srcs = ["FlagsTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/core/flags", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/core/flags/FlagsTest.java b/javatests/com/cowlark/fluxengine/core/flags/FlagsTest.java new file mode 100644 index 000000000..66c8fef06 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/core/flags/FlagsTest.java @@ -0,0 +1,201 @@ +package com.cowlark.fluxengine.core.flags; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.cowlark.fluxengine.core.FluxEngineException; +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.util.List; + +@RunWith(JUnit4.class) +public class FlagsTest +{ + @Test + public void parsesFlags() + { + FlagGroup group = new FlagGroup(); + StringFlag config = StringFlag.builder() + .setGroup(group) + .setName("--config") + .setName("-c") + .setHelpText("config file") + .build(); + IntFlag count = + IntFlag.builder().setGroup(group).setName("--count").setHelpText("count").build(); + BoolFlag verbose = BoolFlag.builder() + .setGroup(group) + .setName("--verbose") + .setHelpText("verbose") + .build(); + + Flags.parse( + ImmutableList.of("--config=foo", "-c", "bar", "--count", "7", "--verbose=true"), + group); + + assertThat(config.get()).isEqualTo("bar"); + assertThat(count.get()).isEqualTo(7); + assertThat(verbose.get()).isTrue(); + } + + @Test + public void parsesParentGroups() + { + FlagGroup common = new FlagGroup(); + StringFlag serial = StringFlag.builder() + .setGroup(common) + .setName("--serial") + .setHelpText("serial") + .build(); + FlagGroup group = new FlagGroup(common); + StringFlag thing = StringFlag.builder() + .setGroup(group) + .setName("--thing") + .setHelpText("thing") + .build(); + + Flags.parse(ImmutableList.of("--serial=abc", "--thing=xyz"), group); + + assertThat(serial.get()).isEqualTo("abc"); + assertThat(thing.get()).isEqualTo("xyz"); + } + + @Test + public void searchesAcrossMultipleRootGroups() + { + FlagGroup first = new FlagGroup(); + FlagGroup second = new FlagGroup(); + StringFlag thing = StringFlag.builder() + .setGroup(second) + .setName("--thing") + .setHelpText("thing") + .build(); + + Flags.parse(ImmutableList.of("--thing=xyz"), first, second); + + assertThat(thing.get()).isEqualTo("xyz"); + } + + @Test + public void duplicateNamesThrow() + { + FlagGroup group = new FlagGroup(); + StringFlag.builder().setGroup(group).setName("--foo").setHelpText("one").build(); + StringFlag.builder().setGroup(group).setName("--foo").setHelpText("two").build(); + + assertThrows( + IllegalStateException.class, + () -> Flags.parse(ImmutableList.of("--foo=x"), group)); + } + + @Test + public void unknownFlagThrows() + { + FlagGroup group = new FlagGroup(); + assertThrows( + FluxEngineException.class, + () -> Flags.parse(ImmutableList.of("--nope=x"), group)); + } + + @Test + public void filenames() + { + FlagGroup group = new FlagGroup(); + List filenames = Flags.parseWithFilenames( + ImmutableList.of("one.dsk", "two.dsk"), + name -> name.equals("one.dsk"), + group); + + assertThat(filenames).containsExactly("two.dsk"); + } + + @Test + public void uninitialisedFlagThrows() + { + FlagGroup group = new FlagGroup(); + StringFlag flag = + StringFlag.builder().setGroup(group).setName("--foo").setHelpText("foo").build(); + + assertThrows(IllegalStateException.class, flag::get); + } + + @Test + public void findFlagReturnsTheFlag() + { + FlagGroup group = new FlagGroup(); + StringFlag foo = StringFlag.builder() + .setGroup(group) + .setName("--foo") + .setName("-f") + .setHelpText("foo") + .build(); + + assertThat(group.findFlag("--foo")).isSameInstanceAs(foo); + assertThat(group.findFlag("-f")).isSameInstanceAs(foo); + assertThat(group.findFlag("--nope")).isNull(); + } + + @Test + public void setNameAddsEachName() + { + FlagGroup group = new FlagGroup(); + StringFlag flag = StringFlag.builder() + .setGroup(group) + .setName("--long") + .setName("-l") + .setName("-long") + .setHelpText("flag") + .build(); + + assertThat(flag.names()).containsExactly("--long", "-l", "-long"); + } + + @Test + public void setNamesTakesACollection() + { + FlagGroup group = new FlagGroup(); + StringFlag flag = StringFlag.builder() + .setGroup(group) + .setNames(List.of("--long", "-l")) + .setHelpText("flag") + .build(); + + assertThat(flag.names()).containsExactly("--long", "-l"); + } + + @Test + public void findFlagRecursesToParents() + { + FlagGroup common = new FlagGroup(); + StringFlag serial = StringFlag.builder() + .setGroup(common) + .setName("--serial") + .setHelpText("serial") + .build(); + FlagGroup group = new FlagGroup(common); + + assertThat(group.findFlag("--serial")).isSameInstanceAs(serial); + } + + @Test + public void noArgFlagDoesNotConsumeFollowingToken() + { + FlagGroup group = new FlagGroup(); + SettableFlag flag = SettableFlag.builder() + .setGroup(group) + .setName("--read-only") + .setHelpText("read only") + .build(); + + List filenames = + Flags.parseWithFilenames( + ImmutableList.of("--read-only", "image.dsk"), + unused -> false, + group); + + assertThat(flag.get()).isTrue(); + assertThat(filenames).containsExactly("image.dsk"); + } +} diff --git a/javatests/com/cowlark/fluxengine/data/BUILD.bazel b/javatests/com/cowlark/fluxengine/data/BUILD.bazel new file mode 100644 index 000000000..e02c01eec --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/BUILD.bazel @@ -0,0 +1,121 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "FormatsTest", + srcs = ["FormatsTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/data", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "ImageTest", + srcs = ["ImageTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "FluxmapTest", + srcs = ["FluxmapTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/external", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "FluxmapReaderTest", + srcs = ["FluxmapReaderTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "//java/com/cowlark/fluxengine/external", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "LocationsTest", + srcs = ["LocationsTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "DiskLayoutTest", + srcs = ["DiskLayoutTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/config:drive_java_proto", + "//java/com/cowlark/fluxengine/config:layout_java_proto", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "KryofluxTest", + srcs = ["KryofluxTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "SectorTest", + srcs = ["SectorTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "RecordTest", + srcs = ["RecordTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "FluxPatternTest", + srcs = ["FluxPatternTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/data", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/data/DiskLayoutTest.java b/javatests/com/cowlark/fluxengine/data/DiskLayoutTest.java new file mode 100644 index 000000000..c70b4bea7 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/DiskLayoutTest.java @@ -0,0 +1,200 @@ +package com.cowlark.fluxengine.data; + +import static com.cowlark.fluxengine.external.DriveType.DRIVETYPE_80TRACK; +import static com.cowlark.fluxengine.external.FormatType.FORMATTYPE_40TRACK; +import static com.cowlark.fluxengine.external.FormatType.FORMATTYPE_80TRACK; +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.config.DriveProto; +import com.cowlark.fluxengine.config.LayoutProto; +import com.google.common.collect.ImmutableMap; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.util.function.Consumer; + +@RunWith(JUnit4.class) +public class DiskLayoutTest +{ + private static DiskLayout diskLayout(com.cowlark.fluxengine.external.FormatType formatType, + Consumer layoutData) + { + ConfigProto.Builder config = baseConfig(formatType); + config.getLayoutBuilder().setTracks(78).setSides(2); + layoutData.accept(addLayoutData(config)); + return new DiskLayout(config.build()); + } + + private static LogicalTrackLayout logicalLayoutAt(DiskLayout diskLayout, int cylinder, int head) + { + return diskLayout.layoutByPhysicalLocation.get(new CylinderHead( + cylinder, + head)).logicalTrackLayout; + } + + private static ConfigProto.Builder baseConfig(com.cowlark.fluxengine.external.FormatType formatType) + { + return ConfigProto.newBuilder() + .setDrive(DriveProto.newBuilder().setDriveType(DRIVETYPE_80TRACK).build()) + .setLayout(LayoutProto.newBuilder().setFormatType(formatType).build()); + } + + private static LayoutProto.LayoutdataProto.Builder addLayoutData(ConfigProto.Builder config) + { + return config.getLayoutBuilder().addLayoutdataBuilder(); + } + + @Test + public void testPhysicalSectors() + { + DiskLayout diskLayout = diskLayout( + FORMATTYPE_80TRACK, (track) -> { + track.setSectorSize(256); + track.getPhysicalBuilder().addSector(0).addSector(2).addSector(1).addSector(3); + }); + + LogicalTrackLayout layout = logicalLayoutAt(diskLayout, 0, 0); + assertThat(diskLayout.layoutByLogicalLocation.get(new CylinderHead(0, 0))).isSameInstanceAs( + layout); + assertThat(layout.naturalSectorOrder).containsExactly(0, 1, 2, 3).inOrder(); + assertThat(layout.diskSectorOrder).containsExactly(0, 2, 1, 3).inOrder(); + assertThat(layout.filesystemSectorOrder).containsExactly(0, 1, 2, 3).inOrder(); + } + + @Test + public void testLogicalSectors() + { + DiskLayout diskLayout = diskLayout( + FORMATTYPE_80TRACK, (track) -> { + track.setSectorSize(256); + track.getPhysicalBuilder().addSector(0).addSector(1).addSector(2).addSector(3); + track.getFilesystemBuilder() + .addSector(0) + .addSector(2) + .addSector(1) + .addSector(3); + }); + + LogicalTrackLayout layout = logicalLayoutAt(diskLayout, 0, 0); + assertThat(diskLayout.layoutByLogicalLocation.get(new CylinderHead(0, 0))).isSameInstanceAs( + layout); + assertThat(layout.naturalSectorOrder).containsExactly(0, 1, 2, 3).inOrder(); + assertThat(layout.diskSectorOrder).containsExactly(0, 1, 2, 3).inOrder(); + assertThat(layout.filesystemSectorOrder).containsExactly(0, 2, 1, 3).inOrder(); + } + + @Test + public void test_bothSectors() + { + DiskLayout diskLayout = diskLayout( + FORMATTYPE_80TRACK, (track) -> { + track.setSectorSize(256); + track.getPhysicalBuilder().addSector(3).addSector(2).addSector(1).addSector(0); + track.getFilesystemBuilder() + .addSector(0) + .addSector(2) + .addSector(1) + .addSector(3); + }); + + LogicalTrackLayout layout = logicalLayoutAt(diskLayout, 0, 0); + assertThat(diskLayout.layoutByLogicalLocation.get(new CylinderHead(0, 0))).isSameInstanceAs( + layout); + assertThat(layout.naturalSectorOrder).containsExactly(0, 1, 2, 3).inOrder(); + assertThat(layout.diskSectorOrder).containsExactly(3, 2, 1, 0).inOrder(); + assertThat(layout.filesystemSectorOrder).containsExactly(0, 2, 1, 3).inOrder(); + } + + @Test + public void test_skew() + { + DiskLayout diskLayout = diskLayout( + FORMATTYPE_80TRACK, (track) -> { + track.setSectorSize(256); + track.getPhysicalBuilder().setStartSector(0).setCount(12).setSkew(6); + }); + + LogicalTrackLayout layout = logicalLayoutAt(diskLayout, 0, 0); + assertThat(layout.naturalSectorOrder).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) + .inOrder(); + assertThat(layout.diskSectorOrder).containsExactly(0, 6, 1, 7, 2, 8, 3, 9, 4, 10, 5, 11) + .inOrder(); + } + + @Test + public void test_bounds() + { + ConfigProto.Builder config = baseConfig(FORMATTYPE_40TRACK); + config.getLayoutBuilder().setTracks(2).setSides(2); + addLayoutData(config).setSectorSize(256) + .getPhysicalBuilder() + .setStartSector(0) + .setCount(12) + .setSkew(6); + + DiskLayout diskLayout = new DiskLayout(config.build()); + assertThat(diskLayout.groupSize).isEqualTo(2); + assertThat(diskLayout.getLogicalBounds()).isEqualTo(new DiskLayout.LayoutBounds( + 0, + 1, + 0, + 1)); + assertThat(diskLayout.getPhysicalBounds()).isEqualTo(new DiskLayout.LayoutBounds( + 0, + 3, + 0, + 1)); + } + + @Test + public void test_sectoroffsets() + { + ConfigProto.Builder config = baseConfig(FORMATTYPE_80TRACK); + config.getLayoutBuilder().setTracks(2).setSides(2); + LayoutProto.LayoutdataProto.Builder layoutData = addLayoutData(config); + layoutData.setSectorSize(256); + layoutData.getPhysicalBuilder().setStartSector(0).setCount(4); + layoutData.getFilesystemBuilder().setStartSector(0).setCount(4).setSkew(2); + + DiskLayout diskLayout = new DiskLayout(config.build()); + assertThat(diskLayout.groupSize).isEqualTo(1); + assertThat(diskLayout.logicalSectorLocationBySectorOffset).isEqualTo(ImmutableMap.builder() + .put(0L, new LogicalLocation(0, 0, 0)) + .put(256L, new LogicalLocation(0, 0, 2)) + .put(512L, new LogicalLocation(0, 0, 1)) + .put(768L, new LogicalLocation(0, 0, 3)) + .put(1024L, new LogicalLocation(0, 1, 0)) + .put(1280L, new LogicalLocation(0, 1, 2)) + .put(1536L, new LogicalLocation(0, 1, 1)) + .put(1792L, new LogicalLocation(0, 1, 3)) + .put(2048L, new LogicalLocation(1, 0, 0)) + .put(2304L, new LogicalLocation(1, 0, 2)) + .put(2560L, new LogicalLocation(1, 0, 1)) + .put(2816L, new LogicalLocation(1, 0, 3)) + .put(3072L, new LogicalLocation(1, 1, 0)) + .put(3328L, new LogicalLocation(1, 1, 2)) + .put(3584L, new LogicalLocation(1, 1, 1)) + .put(3840L, new LogicalLocation(1, 1, 3)) + .build()); + assertThat(diskLayout.sectorOffsetByLogicalSectorLocation).isEqualTo(ImmutableMap.builder() + .put(new LogicalLocation(0, 0, 0), 0L) + .put(new LogicalLocation(0, 0, 1), 512L) + .put(new LogicalLocation(0, 0, 2), 256L) + .put(new LogicalLocation(0, 0, 3), 768L) + .put(new LogicalLocation(0, 1, 0), 1024L) + .put(new LogicalLocation(0, 1, 1), 1536L) + .put(new LogicalLocation(0, 1, 2), 1280L) + .put(new LogicalLocation(0, 1, 3), 1792L) + .put(new LogicalLocation(1, 0, 0), 2048L) + .put(new LogicalLocation(1, 0, 1), 2560L) + .put(new LogicalLocation(1, 0, 2), 2304L) + .put(new LogicalLocation(1, 0, 3), 2816L) + .put(new LogicalLocation(1, 1, 0), 3072L) + .put(new LogicalLocation(1, 1, 1), 3584L) + .put(new LogicalLocation(1, 1, 2), 3328L) + .put(new LogicalLocation(1, 1, 3), 3840L) + .build()); + } +} \ No newline at end of file diff --git a/javatests/com/cowlark/fluxengine/data/FluxPatternTest.java b/javatests/com/cowlark/fluxengine/data/FluxPatternTest.java new file mode 100644 index 000000000..4667a6efc --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/FluxPatternTest.java @@ -0,0 +1,86 @@ +package com.cowlark.fluxengine.data; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class FluxPatternTest +{ + /* Ported from tests/fluxpattern.cc. */ + + @Test + public void testPatternConstruction() + { + FluxPattern fp1 = new FluxPattern(16, 0x0003); + assertThat(fp1.getBitCount()).isEqualTo(16); + assertThat(fp1.getIntervals()).containsExactlyElementsIn(ImmutableList.of(1)); + + FluxPattern fp2 = new FluxPattern(16, 0xc000); + assertThat(fp2.getBitCount()).isEqualTo(16); + assertThat(fp2.getIntervals()).containsExactlyElementsIn(ImmutableList.of(1, 15)); + + FluxPattern fp3 = new FluxPattern(16, 0x0050); + assertThat(fp3.getBitCount()).isEqualTo(16); + assertThat(fp3.getIntervals()).containsExactlyElementsIn(ImmutableList.of(2, 5)); + + FluxPattern fp4 = new FluxPattern(16, 0x0070); + assertThat(fp4.getBitCount()).isEqualTo(16); + assertThat(fp4.getIntervals()).containsExactlyElementsIn(ImmutableList.of(1, 1, 5)); + + FluxPattern fp5 = new FluxPattern(16, 0x0070); + assertThat(fp5.getBitCount()).isEqualTo(16); + assertThat(fp5.getIntervals()).containsExactlyElementsIn(ImmutableList.of(1, 1, 5)); + + FluxPattern fp6 = new FluxPattern(16, 0x0110); + assertThat(fp6.getBitCount()).isEqualTo(16); + assertThat(fp6.getIntervals()).containsExactlyElementsIn(ImmutableList.of(4, 5)); + } + + @Test + public void testPatternMatchingWithoutTrailingZeroes() + { + FluxPattern fp = new FluxPattern(16, 0x000b); + final long[] matching = {100, 100, 200, 100}; + final long[] notMatching = {100, 200, 100, 100}; + final long[] closeMatch1 = {90, 90, 180, 90}; + final long[] closeMatch2 = {110, 110, 220, 110}; + + FluxMatch match = new FluxMatch(); + assertThat(fp.matches(matching, 4, 0.40, match)).isTrue(); + assertThat(match.intervals).isEqualTo(2); + + assertThat(fp.matches(notMatching, 4, 0.40, match)).isFalse(); + + assertThat(fp.matches(closeMatch1, 4, 0.40, match)).isTrue(); + assertThat(match.intervals).isEqualTo(2); + + assertThat(fp.matches(closeMatch2, 4, 0.40, match)).isTrue(); + assertThat(match.intervals).isEqualTo(2); + } + + @Test + public void testPatternMatchingWithTrailingZeroes() + { + FluxPattern fp = new FluxPattern(16, 0x0016); + final long[] matching = {100, 100, 200, 100, 200}; + final long[] notMatching = {100, 200, 100, 100, 100}; + final long[] closeMatch1 = {90, 90, 180, 90, 300}; + final long[] closeMatch2 = {110, 110, 220, 110, 220}; + + FluxMatch match = new FluxMatch(); + assertThat(fp.matches(matching, 5, 0.40, match)).isTrue(); + assertThat(match.intervals).isEqualTo(3); + + assertThat(fp.matches(notMatching, 5, 0.40, match)).isFalse(); + + assertThat(fp.matches(closeMatch1, 5, 0.40, match)).isTrue(); + assertThat(match.intervals).isEqualTo(3); + + assertThat(fp.matches(closeMatch2, 5, 0.40, match)).isTrue(); + assertThat(match.intervals).isEqualTo(3); + } +} \ No newline at end of file diff --git a/javatests/com/cowlark/fluxengine/data/FluxmapReaderTest.java b/javatests/com/cowlark/fluxengine/data/FluxmapReaderTest.java new file mode 100644 index 000000000..a8741aaa2 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/FluxmapReaderTest.java @@ -0,0 +1,115 @@ +package com.cowlark.fluxengine.data; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.decoders.DecoderProto; +import com.cowlark.fluxengine.external.FluxEngine; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class FluxmapReaderTest +{ + private static final DecoderProto DECODER = DecoderProto.getDefaultInstance(); + + @Test + public void readsEvents() + { + Fluxmap map = new Fluxmap(Bytes.of( + FluxEngine.F_DESYNC, + FluxEngine.F_BIT_PULSE | 0x30, + FluxEngine.F_BIT_INDEX | 0x30, + FluxEngine.F_BIT_PULSE | FluxEngine.F_BIT_INDEX | 0x30, + FluxEngine.F_DESYNC, + FluxEngine.F_BIT_PULSE | 0x30, + FluxEngine.F_DESYNC, + FluxEngine.F_BIT_PULSE | 0x30)); + + FluxmapReader r = new FluxmapReader(map, DECODER); + + assertThat(r.getNextEvent().event()).isEqualTo(FluxEngine.F_DESYNC); + assertThat(r.getNextEvent().event()).isEqualTo(FluxEngine.F_BIT_PULSE); + assertThat(r.getNextEvent().event()).isEqualTo(FluxEngine.F_BIT_INDEX); + assertThat(r.getNextEvent().event()).isEqualTo( + FluxEngine.F_BIT_PULSE | FluxEngine.F_BIT_INDEX); + assertThat(r.getNextEvent().event()).isEqualTo(FluxEngine.F_DESYNC); + assertThat(r.getNextEvent().event()).isEqualTo(FluxEngine.F_BIT_PULSE); + assertThat(r.getNextEvent().event()).isEqualTo(FluxEngine.F_DESYNC); + assertThat(r.getNextEvent().event()).isEqualTo(FluxEngine.F_BIT_PULSE); + assertThat(r.getNextEvent().event()).isEqualTo(FluxEngine.F_EOF); + assertThat(r.eof()).isTrue(); + } + + @Test + public void ticksAccumulate() + { + Fluxmap map = + new Fluxmap(Bytes.of(FluxEngine.F_BIT_PULSE | 0x30, FluxEngine.F_BIT_PULSE | 0x30)); + + FluxmapReader r = new FluxmapReader(map, DECODER); + + assertThat(r.getNextEvent().ticks()).isEqualTo(0x30L); + assertThat(r.getNextEvent().ticks()).isEqualTo(0x30L); + assertThat(r.tell().ticks()).isEqualTo(0x30 + 0x30); + } + + @Test + public void findEvent() + { + Fluxmap map = + new Fluxmap(Bytes.of(FluxEngine.F_BIT_PULSE | 0x30, FluxEngine.F_BIT_INDEX | 0x30)); + + FluxmapReader r = new FluxmapReader(map, DECODER); + + FluxmapReader.EventResult result = r.findEvent(FluxEngine.F_BIT_INDEX); + + assertThat(result.found()).isTrue(); + assertThat(result.ticks()).isEqualTo(0x60L); + } + + @Test + public void findEventNotFound() + { + Fluxmap map = + new Fluxmap(Bytes.of(FluxEngine.F_BIT_PULSE | 0x30, FluxEngine.F_BIT_PULSE | 0x30)); + + FluxmapReader r = new FluxmapReader(map, DECODER); + + FluxmapReader.EventResult result = r.findEvent(FluxEngine.F_BIT_INDEX); + + assertThat(result.found()).isFalse(); + } + + @Test + public void rewindResets() + { + Fluxmap map = new Fluxmap(Bytes.of(FluxEngine.F_BIT_PULSE | 0x30)); + + FluxmapReader r = new FluxmapReader(map, DECODER); + r.getNextEvent(); + assertThat(r.eof()).isTrue(); + + r.rewind(); + + assertThat(r.eof()).isFalse(); + assertThat(r.getNextEvent().event()).isEqualTo(FluxEngine.F_BIT_PULSE); + } + + @Test + public void guessClock() + { + Fluxmap map = new Fluxmap(); + for (int i = 0; i < 100; i++) + { + map.appendInterval(0x30); + map.appendPulse(); + } + + FluxmapReader r = new FluxmapReader(map, DECODER); + FluxmapReader.ClockData data = r.guessClock(); + + assertThat(data.medianTicks).isEqualTo(0x30L); + } +} diff --git a/javatests/com/cowlark/fluxengine/data/FluxmapTest.java b/javatests/com/cowlark/fluxengine/data/FluxmapTest.java new file mode 100644 index 000000000..5afd15127 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/FluxmapTest.java @@ -0,0 +1,101 @@ +package com.cowlark.fluxengine.data; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.Bytes; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.util.List; + +@RunWith(JUnit4.class) +public class FluxmapTest +{ + @Test + public void appendIntervalAndPulse() + { + Fluxmap map = new Fluxmap(); + map.appendInterval(0x30); + map.appendPulse(); + + assertThat(map.rawBytes()).isEqualTo(Bytes.of(0x30 | 0x80)); + assertThat(map.ticks()).isEqualTo(0x30); + } + + @Test + public void appendIntervalSplitsLargeValues() + { + Fluxmap map = new Fluxmap(); + map.appendInterval(100); + + assertThat(map.rawBytes()).isEqualTo(Bytes.of(0x3f, 100 - 0x3f)); + assertThat(map.ticks()).isEqualTo(100); + } + + @Test + public void appendIndex() + { + Fluxmap map = new Fluxmap(); + map.appendInterval(0x30); + map.appendIndex(); + + assertThat(map.rawBytes()).isEqualTo(Bytes.of(0x30 | 0x40)); + } + + @Test + public void appendDesync() + { + Fluxmap map = new Fluxmap(); + map.appendInterval(0x30); + map.appendDesync(); + + assertThat(map.rawBytes()).isEqualTo(Bytes.of(0x30, 0x00)); + } + + @Test + public void split() + { + Fluxmap map = new Fluxmap(); + map.appendInterval(0x30); + map.appendDesync(); + map.appendInterval(0x30); + map.appendPulse(); + + List parts = map.split(); + + assertThat(parts).hasSize(2); + assertThat(parts.get(0).bytes()).isEqualTo(1); + assertThat(parts.get(1).bytes()).isEqualTo(1); + } + + @Test + public void getIndexMarks() + { + Fluxmap map = new Fluxmap(); + map.appendInterval(100); + map.appendIndex(); + map.appendInterval(50); + map.appendIndex(); + + List marks = map.getIndexMarks(); + + assertThat(marks).containsExactly(100L, 150L); + } + + @Test + public void indexMarksFlushOnAppend() + { + Fluxmap map = new Fluxmap(); + map.appendInterval(100); + map.appendIndex(); + map.appendInterval(50); + map.appendIndex(); + + assertThat(map.getIndexMarks()).hasSize(2); + + map.appendInterval(50); + map.appendIndex(); + + assertThat(map.getIndexMarks()).hasSize(3); + } +} diff --git a/javatests/com/cowlark/fluxengine/data/FormatsTest.java b/javatests/com/cowlark/fluxengine/data/FormatsTest.java new file mode 100644 index 000000000..3cd340d34 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/FormatsTest.java @@ -0,0 +1,42 @@ +package com.cowlark.fluxengine.data; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigProto; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class FormatsTest +{ + @Test + public void looksUpConfigByName() + { + ConfigProto config = Formats.get("amiga"); + assertThat(config).isNotNull(); + assertThat(config.getShortname()).isEqualTo("Amiga"); + } + + @Test + public void looksUpGlobalOptions() + { + ConfigProto config = Formats.get("_global_options"); + assertThat(config).isNotNull(); + assertThat(config.getIsExtension()).isTrue(); + } + + @Test + public void returnsNullForUnknownName() + { + assertThat(Formats.get("not a real format")).isNull(); + } + + @Test + public void returnsAllConfigNames() + { + assertThat(Formats.all()).hasSize(36); + assertThat(Formats.all()).contains("ibm"); + assertThat(Formats.all()).contains("_global_options"); + } +} diff --git a/javatests/com/cowlark/fluxengine/data/ImageTest.java b/javatests/com/cowlark/fluxengine/data/ImageTest.java new file mode 100644 index 000000000..46d6da90d --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/ImageTest.java @@ -0,0 +1,113 @@ +package com.cowlark.fluxengine.data; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.Bytes; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ImageTest +{ + @Test + public void emptyImageHasNoSectors() + { + Image image = new Image(); + + assertThat(image.empty()).isTrue(); + assertThat(image.iterator().hasNext()).isFalse(); + } + + @Test + public void putAndGetSectors() + { + Image image = new Image(); + + Sector sector = image.put(0, 0, 3); + assertThat(image.contains(0, 0, 3)).isTrue(); + assertThat(image.contains(new LogicalLocation(0, 0, 3))).isTrue(); + assertThat(image.get(0, 0, 3)).isSameInstanceAs(sector); + assertThat(image.get(new LogicalLocation(0, 0, 3))).isSameInstanceAs(sector); + + image.erase(0, 0, 3); + assertThat(image.contains(0, 0, 3)).isFalse(); + assertThat(image.get(0, 0, 3)).isNull(); + } + + @Test + public void calculatesGeometry() + { + Image image = new Image(); + image.put(0, 0, 1).data = new Bytes(128); + image.put(2, 1, 5).data = new Bytes(256); + image.put(2, 1, 8).data = new Bytes(512); + + image.calculateSize(); + + Geometry geometry = image.getGeometry(); + assertThat(geometry.numCylinders).isEqualTo(3); + assertThat(geometry.numHeads).isEqualTo(2); + assertThat(geometry.firstSector).isEqualTo(1); + assertThat(geometry.numSectors).isEqualTo(8); + assertThat(geometry.sectorSize).isEqualTo(512); + assertThat(geometry.totalBytes).isEqualTo(896); + } + + @Test + public void constructorCalculatesGeometry() + { + java.util.List sectors = java.util.List.of( + makeSector(0, 0, 0, 256), + makeSector(1, 1, 3, 256)); + + Image image = new Image(sectors); + + assertThat(image.getGeometry().numCylinders).isEqualTo(2); + assertThat(image.getGeometry().numHeads).isEqualTo(2); + assertThat(image.getGeometry().firstSector).isEqualTo(0); + assertThat(image.getGeometry().numSectors).isEqualTo(4); + } + + @Test + public void addMissingSectorsPopulatesMissing() + { + Image image = new Image(); + image.put(0, 0, 0); + + /* A disk with sectors 0 and 1; sector 1 is missing. */ + DiskLayout layout = new DiskLayout(1, 1, 2, 256); + image.addMissingSectors(layout, false); + + assertThat(image.contains(0, 0, 0)).isTrue(); + assertThat(image.contains(0, 0, 1)).isTrue(); + assertThat(image.get(0, 0, 1).status).isEqualTo(Sector.Status.MISSING); + } + + @Test + public void populateSectorPhysicalLocations() + { + Image image = new Image(); + image.put(0, 0, 0); + image.put(0, 0, 1); + + DiskLayout layout = new DiskLayout(1, 1, 2, 256); + image.populateSectorPhysicalLocationsFromLogicalLocations(layout); + + for (Sector sector : image) + { + assertThat(sector.physicalLocation).isNotNull(); + assertThat(sector.physicalLocation.cylinder()).isEqualTo( + sector.location.logicalCylinder()); + assertThat(sector.physicalLocation.head()).isEqualTo( + sector.location.logicalHead()); + } + } + + private static Sector makeSector(int cylinder, int head, int sector, int size) + { + Sector s = new Sector(new LogicalLocation(cylinder, head, sector)); + s.data = new Bytes(size); + return s; + } +} diff --git a/javatests/com/cowlark/fluxengine/data/KryofluxTest.java b/javatests/com/cowlark/fluxengine/data/KryofluxTest.java new file mode 100644 index 000000000..5c9e8edc5 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/KryofluxTest.java @@ -0,0 +1,69 @@ +package com.cowlark.fluxengine.data; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.Bytes; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.util.Arrays; + +@RunWith(JUnit4.class) +public class KryofluxTest +{ + private static void testConvert(Bytes kyrofluxBytes, Bytes expectedFluxmapBytes) + { + Fluxmap fluxmap = Kryoflux.readStream(kyrofluxBytes); + assertThat(fluxmap.rawBytes().toByteArray()).isEqualTo(expectedFluxmapBytes.toByteArray()); + } + + private static Bytes unsignedBytes(int count) + { + byte[] data = new byte[count]; + Arrays.fill(data, (byte) 0x3f); + return new Bytes(data); + } + + @Test + public void test_stream_reader() + { + testConvert(Bytes.of(), Bytes.of()); + + /* Simple one-byte intervals */ + testConvert(Bytes.of(0x20, 0x20, 0x20, 0x20), Bytes.of(0x8f, 0x8f, 0x8f, 0x8f)); + + /* One-and-a-half-byte intervals */ + testConvert( + Bytes.of(0x20, 0x00, 0x10, 0x20, 0x01, 0x10, 0x20), + Bytes.of(0x8f, 0x87, 0x8f, 0x3f, 0x3f, 0x89, 0x8f)); + + /* Two-byte intervals */ + testConvert( + Bytes.of(0x20, 0x0c, 0x00, 0x10, 0x20, 0x0c, 0x01, 0x10, 0x20), + Bytes.of(0x8f, 0x87, 0x8f, 0x3f, 0x3f, 0x89, 0x8f)); + + /* Overflow */ + testConvert( + Bytes.of(0x20, 0x0b, 0x10, 0x20), + Bytes.of(0x8f).concat(unsignedBytes(0x207)).concat(Bytes.of(0xa9, 0x8f))); + + /* Single-byte nop */ + testConvert(Bytes.of(0x20, 0x08, 0x20), Bytes.of(0x8f, 0x8f)); + + /* Double-byte nop */ + testConvert(Bytes.of(0x20, 0x09, 0xde, 0x20), Bytes.of(0x8f, 0x8f)); + + /* Triple-byte nop */ + testConvert(Bytes.of(0x20, 0x0a, 0xde, 0xad, 0x20), Bytes.of(0x8f, 0x8f)); + + /* OOB block */ + testConvert( + Bytes.of( + 0x20, /* data before */ + 0x0d, /* OOB */ + 0xaa, /* type byte */ + 0x01, 0x00, /* size of payload, little-endian */ + 0x55, /* payload */ + 0x20 /* data continues */), Bytes.of(0x8f, 0x8f)); + } +} \ No newline at end of file diff --git a/javatests/com/cowlark/fluxengine/data/LocationsTest.java b/javatests/com/cowlark/fluxengine/data/LocationsTest.java new file mode 100644 index 000000000..09db4af65 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/LocationsTest.java @@ -0,0 +1,60 @@ +package com.cowlark.fluxengine.data; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.cowlark.fluxengine.core.FluxEngineException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.util.List; + +@RunWith(JUnit4.class) +public class LocationsTest +{ + @Test + public void parseSingle() + { + assertThat(Locations.parseCylinderHeadsString("c0h0")).containsExactly(new CylinderHead( + 0, + 0)); + } + + @Test + public void parseRangeAndStep() + { + assertThat(Locations.parseCylinderHeadsString("c0-2h0-2x2")).containsExactly( + new CylinderHead(0, 0), + new CylinderHead(0, 2), + new CylinderHead(1, 0), + new CylinderHead(1, 2), + new CylinderHead(2, 0), + new CylinderHead(2, 2)); + } + + @Test + public void parseMultipleGroups() + { + assertThat(Locations.parseCylinderHeadsString("c1h1 c0h0")).containsExactly( + new CylinderHead(0, + 0), new CylinderHead(1, 1)); + } + + @Test + public void convertRoundTrip() + { + List chs = List.of(new CylinderHead(0, 0), new CylinderHead(1, 2)); + + assertThat(Locations.convertCylinderHeadsToString(chs)).isEqualTo("c0h0 c1h2"); + } + + @Test + public void parseMalformedThrows() + { + assertThrows(FluxEngineException.class, () -> Locations.parseCylinderHeadsString("c0")); + assertThrows( + FluxEngineException.class, + () -> Locations.parseCylinderHeadsString("garbage")); + assertThrows(FluxEngineException.class, () -> Locations.parseCylinderHeadsString("c0h2x0")); + } +} diff --git a/javatests/com/cowlark/fluxengine/data/RecordTest.java b/javatests/com/cowlark/fluxengine/data/RecordTest.java new file mode 100644 index 000000000..4f9fb2f00 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/RecordTest.java @@ -0,0 +1,41 @@ +package com.cowlark.fluxengine.data; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.Bytes; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class RecordTest +{ + @Test + public void defaultsAreEmptyRecord() + { + Record record = new Record(); + + assertThat(record.clockNs).isEqualTo(0.0); + assertThat(record.startTimeNs).isEqualTo(0.0); + assertThat(record.endTimeNs).isEqualTo(0.0); + assertThat(record.position).isEqualTo(0); + assertThat(record.rawData.isEmpty()).isTrue(); + } + + @Test + public void holdsFields() + { + Record record = new Record(); + record.clockNs = 123.0; + record.startTimeNs = 456.0; + record.endTimeNs = 789.0; + record.position = 42; + record.rawData = Bytes.of(0x11, 0x22); + + assertThat(record.clockNs).isEqualTo(123.0); + assertThat(record.startTimeNs).isEqualTo(456.0); + assertThat(record.endTimeNs).isEqualTo(789.0); + assertThat(record.position).isEqualTo(42); + assertThat(record.rawData).isEqualTo(Bytes.of(0x11, 0x22)); + } +} \ No newline at end of file diff --git a/javatests/com/cowlark/fluxengine/data/SectorTest.java b/javatests/com/cowlark/fluxengine/data/SectorTest.java new file mode 100644 index 000000000..e01247931 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/data/SectorTest.java @@ -0,0 +1,67 @@ +package com.cowlark.fluxengine.data; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class SectorTest +{ + @Test + public void defaultsAreEmptySector() + { + Sector sector = new Sector(new LogicalLocation(0, 0, 0)); + + assertThat(sector.location).isEqualTo(new LogicalLocation(0, 0, 0)); + assertThat(sector.status).isEqualTo(Sector.Status.INTERNAL_ERROR); + assertThat(sector.position).isEqualTo(0); + assertThat(sector.clockNs).isEqualTo(0.0); + assertThat(sector.headerStartTimeNs).isEqualTo(0.0); + assertThat(sector.headerEndTimeNs).isEqualTo(0.0); + assertThat(sector.dataStartTimeNs).isEqualTo(0.0); + assertThat(sector.dataEndTimeNs).isEqualTo(0.0); + assertThat(sector.physicalLocation).isNull(); + assertThat(sector.data.isEmpty()).isTrue(); + assertThat(sector.records).isEmpty(); + } + + @Test + public void holdsLogicalLocation() + { + LogicalLocation location = new LogicalLocation(1, 2, 3); + Sector sector = new Sector(location); + + assertThat(sector.location).isSameInstanceAs(location); + assertThat(sector.location.trackLocation()).isEqualTo(new CylinderHead(1, 2)); + } + + @Test + public void statusStringRoundTrips() + { + for (Sector.Status status : Sector.Status.values()) + { + assertThat(Sector.stringToStatus(Sector.statusToString(status))) + .isEqualTo(status); + } + } + + @Test + public void statusToStringIsReadable() + { + assertThat(Sector.statusToString(Sector.Status.OK)).isEqualTo("OK"); + assertThat(Sector.statusToString(Sector.Status.MISSING)).isEqualTo("sector not found"); + assertThat(Sector.statusToString(Sector.Status.DATA_MISSING)) + .isEqualTo("present but no data found"); + } + + @Test + public void stringToStatusAcceptsChars() + { + assertThat(Sector.stringToStatus("OK")).isEqualTo(Sector.Status.OK); + assertThat(Sector.stringToStatus("MISSING")).isEqualTo(Sector.Status.MISSING); + assertThat(Sector.stringToStatus("bad checksum")).isEqualTo(Sector.Status.BAD_CHECKSUM); + assertThat(Sector.stringToStatus("garbage")).isEqualTo(Sector.Status.INTERNAL_ERROR); + } +} diff --git a/javatests/com/cowlark/fluxengine/decoders/BUILD.bazel b/javatests/com/cowlark/fluxengine/decoders/BUILD.bazel new file mode 100644 index 000000000..c590f75fc --- /dev/null +++ b/javatests/com/cowlark/fluxengine/decoders/BUILD.bazel @@ -0,0 +1,18 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "FluxDecoderTest", + srcs = ["FluxDecoderTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/decoders", + "//java/com/cowlark/fluxengine/decoders:decoders_java_proto", + "//java/com/cowlark/fluxengine/external", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/decoders/FluxDecoderTest.java b/javatests/com/cowlark/fluxengine/decoders/FluxDecoderTest.java new file mode 100644 index 000000000..09e684e90 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/decoders/FluxDecoderTest.java @@ -0,0 +1,83 @@ +package com.cowlark.fluxengine.decoders; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.FluxmapReader; +import com.cowlark.fluxengine.external.FmMfm; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class FluxDecoderTest +{ + private static final int CLOCK_TICKS = 1000; + private static final double CLOCK_NS = CLOCK_TICKS * 1000000000.0 / 12000000.0; + + private static Bytes roundTrip(Bytes data) + { + /* Encode the data as an MFM bitstream... */ + Bits encoded = FmMfm.encodeMfm(data, new boolean[1]).toBits(); + + /* ...write it out as flux... */ + Fluxmap map = new Fluxmap(); + map.appendBits(encoded, CLOCK_NS); + FluxmapReader reader = new FluxmapReader(map, DecoderProto.getDefaultInstance()); + FluxDecoder decoder = new FluxDecoder(reader, CLOCK_NS, DecoderProto.getDefaultInstance()); + + /* ...and read the raw bits back, skipping the PLL init pulse. */ + Bits decoded = new Bits(); + decoder.readBit(); + while (!reader.eof()) + decoded.add(decoder.readBit()); + + return FmMfm.decodeFmMfm(decoded); + } + + @Test + public void roundTripsMfmData() + { + Bytes data = Bytes.of(0x81, 0x00, 0xa1, 0x4e, 0x4e); + assertThat(roundTrip(data)).isEqualTo(data); + } + + @Test + public void emitsAClockForEveryFluxTransition() + { + /* A pulse at every cell boundary reads back as an unbroken run of + * trues. */ + Bits inputBits = new Bits(); + for (int i = 0; i < 8; i++) + inputBits.add(true); + Fluxmap map = new Fluxmap(); + map.appendBits(inputBits, CLOCK_NS); + FluxmapReader reader = new FluxmapReader(map, DecoderProto.getDefaultInstance()); + FluxDecoder decoder = new FluxDecoder(reader, CLOCK_NS, DecoderProto.getDefaultInstance()); + + Bits bits = new Bits(); + while (!reader.eof()) + bits.add(decoder.readBit()); + + assertThat(bits.size()).isEqualTo(9); + for (int i = 0; i < bits.size(); i++) + assertThat(bits.getBit(i)).isTrue(); + } + + @Test + public void firstBitIsAlwaysTrue() + { + /* The initial leading-zeroes state (tell().zeroes() == 0) makes the + * first readBit return true. */ + Bits inputBits = new Bits(); + inputBits.add(true); + Fluxmap map = new Fluxmap(); + map.appendBits(inputBits, CLOCK_NS); + FluxmapReader reader = new FluxmapReader(map, DecoderProto.getDefaultInstance()); + FluxDecoder decoder = new FluxDecoder(reader, CLOCK_NS, DecoderProto.getDefaultInstance()); + + assertThat(decoder.readBit()).isTrue(); + } +} \ No newline at end of file diff --git a/javatests/com/cowlark/fluxengine/encoders/BUILD.bazel b/javatests/com/cowlark/fluxengine/encoders/BUILD.bazel new file mode 100644 index 000000000..8936984e0 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/encoders/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "EncoderTest", + srcs = ["EncoderTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/encoders", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/encoders/EncoderTest.java b/javatests/com/cowlark/fluxengine/encoders/EncoderTest.java new file mode 100644 index 000000000..d63788004 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/encoders/EncoderTest.java @@ -0,0 +1,108 @@ +package com.cowlark.fluxengine.encoders; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.assertThrows; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.DiskLayout; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalTrackLayout; +import com.cowlark.fluxengine.data.Sector; +import com.google.common.collect.ImmutableList; +import java.util.List; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class EncoderTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + private static final class TestEncoder extends Encoder + { + TestEncoder(double diskRotationalPeriodNs) + { + super(diskRotationalPeriodNs); + } + + @Override + public Fluxmap encode(LogicalTrackLayout ltl, List sectors, Image image) + { + return new Fluxmap(); + } + } + + @Test + public void createThrowsNotImplemented() + { + ConfigProto config = new ConfigBuilder().set("usb.serial", "test-serial").build(); + + assertThrows(FluxEngineException.class, () -> Encoder.create(config)); + } + + @Test + public void collectSectorsCollectsInDiskOrder() + { + /* A single-track, single-side disk with sectors 0 and 1. */ + DiskLayout layout = new DiskLayout(1, 1, 2, 256); + LogicalTrackLayout ltl = + layout.layoutByLogicalLocation.get(new com.cowlark.fluxengine.data.CylinderHead( + 0, + 0)); + assertThat(ltl).isNotNull(); + + Image image = new Image(); + image.put(0, 0, 0); + image.put(0, 0, 1); + + TestEncoder encoder = new TestEncoder(200 * 1e6); + + ImmutableList sectors = encoder.collectSectors(ltl, image); + + assertThat(sectors).hasSize(2); + assertThat(sectors.get(0).location.logicalSector()).isEqualTo(0); + assertThat(sectors.get(1).location.logicalSector()).isEqualTo(1); + } + + @Test + public void collectSectorsMissingSectorThrows() + { + DiskLayout layout = new DiskLayout(1, 1, 2, 256); + LogicalTrackLayout ltl = + layout.layoutByLogicalLocation.get(new com.cowlark.fluxengine.data.CylinderHead( + 0, + 0)); + + Image image = new Image(); + image.put(0, 0, 0); /* sector 1 missing */ + + TestEncoder encoder = new TestEncoder(200 * 1e6); + + assertThrows(FluxEngineException.class, () -> encoder.collectSectors(ltl, image)); + } + + @Test + public void calculatePhysicalClockPeriodNs() + { + TestEncoder encoder = new TestEncoder(200 * 1e6); + + assertThat(encoder.calculatePhysicalClockPeriodNs(4000, 200e6)).isEqualTo(4000.0); + } + + @Test + public void calculatePhysicalClockPeriodNsUnsetThrows() + { + TestEncoder encoder = new TestEncoder(0); + + assertThrows( + FluxEngineException.class, + () -> encoder.calculatePhysicalClockPeriodNs(4000, 200e6)); + } +} diff --git a/javatests/com/cowlark/fluxengine/external/BUILD.bazel b/javatests/com/cowlark/fluxengine/external/BUILD.bazel new file mode 100644 index 000000000..3de1d6bef --- /dev/null +++ b/javatests/com/cowlark/fluxengine/external/BUILD.bazel @@ -0,0 +1,37 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "FmMfmTest", + srcs = ["FmMfmTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/external", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "CrcTest", + srcs = ["CrcTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/external", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "GreaseweazleUtilsTest", + srcs = ["GreaseweazleUtilsTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/external", + "//javatests/com/cowlark/fluxengine/testing", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/external/CrcTest.java b/javatests/com/cowlark/fluxengine/external/CrcTest.java new file mode 100644 index 000000000..925ac6047 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/external/CrcTest.java @@ -0,0 +1,74 @@ +package com.cowlark.fluxengine.external; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.Bytes; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class CrcTest +{ + /* The standard CRC check value: the result over the ASCII string + * "123456789". */ + private static final Bytes CHECK = Bytes.of(0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39); + + @Test + public void crc16() + { + /* CRC-16/CCITT-FALSE. */ + assertThat(Crc.crc16(Crc.CCITT_POLY, CHECK)).isEqualTo(0x29b1); + + /* CRC-16/XMODEM. */ + assertThat(Crc.crc16(Crc.CCITT_POLY, 0x0000, CHECK)).isEqualTo(0x31c3); + + /* TD0 imagereader polynomial. */ + assertThat(Crc.crc16(0xa097, 0x0000, CHECK)).isEqualTo(0x0fb3); + + /* The F85 decoder uses CCITT with non-standard init values. */ + assertThat(Crc.crc16(Crc.CCITT_POLY, 0xef21, CHECK)).isEqualTo(0xd2bb); + assertThat(Crc.crc16(Crc.CCITT_POLY, 0xbf84, CHECK)).isEqualTo(0x10cb); + } + + @Test + public void crc16ref() + { + /* CRC-16/MODBUS. */ + assertThat(Crc.crc16ref(Crc.MODBUS_POLY_REF, CHECK)).isEqualTo(0x4b37); + assertThat(Crc.crc16ref(Crc.MODBUS_POLY_REF, 0x0000, CHECK)).isEqualTo(0xbb3d); + } + + @Test + public void crc16Empty() + { + /* An empty input leaves the CRC at its init value. */ + assertThat(Crc.crc16(Crc.CCITT_POLY, Bytes.of())).isEqualTo(0xffff); + assertThat(Crc.crc16ref(Crc.MODBUS_POLY_REF, Bytes.of())).isEqualTo(0xffff); + } + + @Test + public void crc16SingleByte() + { + assertThat(Crc.crc16(Crc.CCITT_POLY, Bytes.of(0x00))).isEqualTo(0xe1f0); + assertThat(Crc.crc16(Crc.CCITT_POLY, Bytes.of(0x01))).isEqualTo(0xf1d1); + assertThat(Crc.crc16ref(Crc.MODBUS_POLY_REF, Bytes.of(0x00))).isEqualTo(0x40bf); + assertThat(Crc.crc16ref(Crc.MODBUS_POLY_REF, Bytes.of(0xff))).isEqualTo(0xff); + } + + @Test + public void sumBytes() + { + assertThat(Crc.sumBytes(Bytes.of(1, 2, 3, 4))).isEqualTo(10); + assertThat(Crc.sumBytes(Bytes.of())).isEqualTo(0); + assertThat(Crc.sumBytes(Bytes.of(0xff, 0x01))).isEqualTo(0x100); + } + + @Test + public void xorBytes() + { + assertThat(Crc.xorBytes(Bytes.of(1, 2, 3, 4))).isEqualTo(4); + assertThat(Crc.xorBytes(Bytes.of())).isEqualTo(0); + assertThat(Crc.xorBytes(Bytes.of(0xff, 0xff))).isEqualTo(0); + } +} diff --git a/javatests/com/cowlark/fluxengine/external/FmMfmTest.java b/javatests/com/cowlark/fluxengine/external/FmMfmTest.java new file mode 100644 index 000000000..baacee05c --- /dev/null +++ b/javatests/com/cowlark/fluxengine/external/FmMfmTest.java @@ -0,0 +1,178 @@ +package com.cowlark.fluxengine.external; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.Bits; +import com.cowlark.fluxengine.core.Bytes; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class FmMfmTest +{ + private static Bits wrapEncodeMfm(Bytes bytes) + { + Bits bits = new Bits(16); + Bits.Cursor cursor = new Bits.Cursor(0); + boolean[] lastBit = {false}; + FmMfm.encodeMfm(bits, cursor, bytes, lastBit); + return bits; + } + + private static Bits wrapEncodeFm(Bytes bytes) + { + Bits bits = new Bits(16); + Bits.Cursor cursor = new Bits.Cursor(0); + FmMfm.encodeFm(bits, cursor, bytes); + return bits; + } + + private static Bits bits(boolean... values) + { + Bits bits = new Bits(values.length); + for (int i = 0; i < values.length; i++) + bits.setBit(i, values[i]); + return bits; + } + + @Test + public void decode() + { + assertThat(FmMfm.decodeFmMfm(bits( + true, + false, + true, + false, + true, + false, + true, + false, + true, + false, + true, + false, + true, + false, + true, + false))).isEqualTo(Bytes.of(0x00)); + + assertThat(FmMfm.decodeFmMfm(bits( + true, + true, + true, + false, + true, + false, + true, + false, + true, + false, + true, + false, + true, + false, + true, + true))).isEqualTo(Bytes.of(0x81)); + + assertThat(FmMfm.decodeFmMfm(bits(true, true, true, false))).isEqualTo(Bytes.of(0x80)); + } + + @Test + public void encodeMfm() + { + assertThat(wrapEncodeMfm(Bytes.of(0xa1))).isEqualTo(bits( + false, + true, + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + true, + false, + false, + true)); + + assertThat(wrapEncodeMfm(Bytes.of(0xc2))).isEqualTo(bits( + false, + true, + false, + true, + false, + false, + true, + false, + true, + false, + true, + false, + false, + true, + false, + false)); + + assertThat(wrapEncodeMfm(Bytes.of(0xb0))).isEqualTo(bits( + false, + true, + false, + false, + false, + true, + false, + true, + false, + false, + true, + false, + true, + false, + true, + false)); + } + + @Test + public void encodeFm() + { + assertThat(wrapEncodeFm(Bytes.of(0x00))).isEqualTo(bits( + true, + false, + true, + false, + true, + false, + true, + false, + true, + false, + true, + false, + true, + false, + true, + false)); + + assertThat(wrapEncodeFm(Bytes.of(0x81))).isEqualTo(bits( + true, + true, + true, + false, + true, + false, + true, + false, + true, + false, + true, + false, + true, + false, + true, + true)); + } +} \ No newline at end of file diff --git a/javatests/com/cowlark/fluxengine/external/GreaseweazleUtilsTest.java b/javatests/com/cowlark/fluxengine/external/GreaseweazleUtilsTest.java new file mode 100644 index 000000000..39c554de6 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/external/GreaseweazleUtilsTest.java @@ -0,0 +1,60 @@ +package com.cowlark.fluxengine.external; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class GreaseweazleUtilsTest +{ + private static final double CLOCK = 2 * FluxEngine.NS_PER_TICK; + + private static void testConvert(Bytes gwBytes, Bytes flBytes) + { + assertThat(GreaseweazleUtils.greaseweazleToFluxEngine(gwBytes, CLOCK)).isEqualTo(flBytes); + assertThat(GreaseweazleUtils.fluxEngineToGreaseweazle(flBytes, CLOCK)).isEqualTo(gwBytes); + } + + private static Bytes encode28(int val) + { + return Bytes.of( + 1 | (val << 1) & 0xff, + 1 | (val >> 6) & 0xff, + 1 | (val >> 13) & 0xff, + 1 | (val >> 20) & 0xff); + } + + @Test + public void conversions() + { + /* Simple one-byte intervals. */ + testConvert(Bytes.of(1, 1, 1, 1, 0), Bytes.of(0x82, 0x82, 0x82, 0x82)); + + /* Larger one-byte intervals. */ + testConvert(Bytes.of(32, 0), Bytes.of(0x3f, 0x81)); + testConvert(Bytes.of(64, 0), Bytes.of(0x3f, 0x3f, 0x82)); + testConvert(Bytes.of(128, 0), Bytes.of(0x3f, 0x3f, 0x3f, 0x3f, 0x84)); + + /* Two-byte intervals. */ + testConvert(Bytes.of(250, 1, 0), Bytes.of(0x3f, 0x3f, 0x3f, 0x3f, 0x3f, 0x3f, 0x3f, 0xbb)); + + /* Very long intervals. */ + Bytes gw = new Bytes(0); + new ByteWriter(gw).write8(255) + .write8(2) /* FLUXOP_SPACE */.write(encode28(2048 - 249)) + .write8(249) + .write8(0); + + Bytes fl = new Bytes(0); + ByteWriter bw = new ByteWriter(fl); + for (int i = 0; i < 65; i++) + bw.write8(0x3f); + bw.write8(0x81); + + testConvert(gw, fl); + } +} diff --git a/javatests/com/cowlark/fluxengine/fluxsink/BUILD.bazel b/javatests/com/cowlark/fluxengine/fluxsink/BUILD.bazel new file mode 100644 index 000000000..d41343f73 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsink/BUILD.bazel @@ -0,0 +1,38 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "Fl2FluxSinkTest", + srcs = ["Fl2FluxSinkTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "//java/com/cowlark/fluxengine/fluxsink", + "//java/com/cowlark/fluxengine/fluxsink:fluxsink_java_proto", + "@com_google_protobuf//java/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "FluxSinkTest", + srcs = ["FluxSinkTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/external", + "//java/com/cowlark/fluxengine/fluxsink", + "//java/com/cowlark/fluxengine/fluxsink:fluxsink_java_proto", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/fluxsink/Fl2FluxSinkTest.java b/javatests/com/cowlark/fluxengine/fluxsink/Fl2FluxSinkTest.java new file mode 100644 index 000000000..aaff13776 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsink/Fl2FluxSinkTest.java @@ -0,0 +1,84 @@ +package com.cowlark.fluxengine.fluxsink; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.external.FluxFileProto; +import com.cowlark.fluxengine.external.FluxFileVersion; +import com.cowlark.fluxengine.external.FluxMagic; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@RunWith(JUnit4.class) +public class Fl2FluxSinkTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + private static ConfigProto makeConfig() + { + return new ConfigBuilder() + .set("usb.serial", "test-serial") + .set("drive.rotational_period_ms", "200") + .build(); + } + + private static Fluxmap makeFluxmap() + { + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendInterval(100); + fluxmap.appendPulse(); + fluxmap.appendInterval(50); + fluxmap.appendPulse(); + return fluxmap; + } + + @Test + public void writesFile() throws IOException + { + Path path = Files.createTempFile("flux", ".fl2"); + Files.delete(path); + + Fl2FluxSink sink = new Fl2FluxSink(path.toString(), makeConfig()); + sink.addFlux(0, 0, makeFluxmap()); + sink.addFlux(0, 1, makeFluxmap()); + sink.close(); + + byte[] data = Files.readAllBytes(path); + assertThat(data.length).isGreaterThan(0); + + FluxFileProto proto = FluxFileProto.parseFrom(data); + assertThat(proto.getMagic()).isEqualTo(FluxMagic.MAGIC.getNumber()); + assertThat(proto.getVersion()).isEqualTo(FluxFileVersion.VERSION_2); + assertThat(proto.getRotationalPeriodMs()).isEqualTo(200.0); + assertThat(proto.getTrackCount()).isEqualTo(2); + assertThat(proto.getTrack(0).getTrack()).isEqualTo(0); + assertThat(proto.getTrack(0).getHead()).isEqualTo(0); + assertThat(proto.getTrack(0).getFluxCount()).isEqualTo(1); + assertThat(proto.getTrack(1).getTrack()).isEqualTo(0); + assertThat(proto.getTrack(1).getHead()).isEqualTo(1); + } + + @Test + public void factoryWiring() + { + ConfigProto config = new ConfigBuilder() + .set("usb.serial", "test-serial") + .set("flux_sink.type", "FLUXTYPE_FLUX") + .set("flux_sink.fl2.filename", "test.fl2") + .build(); + + FluxSinkFactory factory = FluxSinkFactory.create(config); + + assertThat(factory).isInstanceOf(Fl2FluxSinkFactory.class); + assertThat(factory.getPath()).isEqualTo("test.fl2"); + assertThat(factory.isHardware()).isFalse(); + } +} diff --git a/javatests/com/cowlark/fluxengine/fluxsink/FluxSinkTest.java b/javatests/com/cowlark/fluxengine/fluxsink/FluxSinkTest.java new file mode 100644 index 000000000..651f2b394 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsink/FluxSinkTest.java @@ -0,0 +1,146 @@ +package com.cowlark.fluxengine.fluxsink; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.cowlark.fluxengine.config.ConfigBuilder; +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.Fluxmap; +import com.cowlark.fluxengine.external.Scp; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@RunWith(JUnit4.class) +public class FluxSinkTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + private static ConfigProto makeConfig() + { + return new ConfigBuilder() + .set("usb.serial", "test-serial") + .set("drive.rotational_period_ms", "200") + .set("layout.tracks", "1") + .set("layout.sides", "1") + .set("layout.layoutdata[0].sector_size", "256") + .set("layout.layoutdata[0].physical.start_sector", "0") + .set("layout.layoutdata[0].physical.count", "8") + .build(); + } + + private static Fluxmap makeFluxmap() + { + Fluxmap fluxmap = new Fluxmap(); + fluxmap.appendInterval(100); + fluxmap.appendPulse(); + fluxmap.appendInterval(50); + fluxmap.appendPulse(); + fluxmap.appendIndex(); + return fluxmap; + } + + @Test + public void vcdWritesFile() throws IOException + { + Path dir = Files.createTempDirectory("vcd"); + VcdFluxSink sink = new VcdFluxSink(dir.toString()); + sink.addFlux(0, 0, makeFluxmap()); + + String contents = Files.readString(dir.resolve("c00.h0.vcd")); + assertThat(contents).contains("$timescale 1ns $end"); + assertThat(contents).contains("$var wire 1 p pulse $end"); + assertThat(contents).contains("$enddefinitions $end"); + } + + @Test + public void auWritesFile() throws IOException + { + Path dir = Files.createTempDirectory("au"); + AuFluxSink sink = new AuFluxSink(dir.toString(), true); + sink.addFlux(0, 0, makeFluxmap()); + + Bytes data = new Bytes(Files.readAllBytes(dir.resolve("c00.h0.au"))); + ByteReader br = new ByteReader(data); + assertThat(br.readBe32()).isEqualTo(0x2e736e64); + assertThat(br.readBe32()).isEqualTo(24); + assertThat(br.readBe32()).isEqualTo((makeFluxmap().ticks() + 2) * 2); + assertThat(br.readBe32()).isEqualTo(2); /* 8-bit PCM */ + assertThat(br.readBe32()).isEqualTo(12000000); /* TICK_FREQUENCY */ + assertThat(br.readBe32()).isEqualTo(2); /* channels */ + } + + @Test + public void a2rWritesFile() throws IOException + { + Path path = Files.createTempFile("flux", ".a2r"); + Files.delete(path); + + A2RFluxSink sink = new A2RFluxSink(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"); + assertThat(data.getByte(4) & 0xff).isEqualTo(0xff); + assertThat(data.getByte(5) & 0xff).isEqualTo(0x0a); + } + + @Test + public void scpWritesFile() throws IOException + { + Path path = Files.createTempFile("flux", ".scp"); + Files.delete(path); + + ScpFluxSink sink = new ScpFluxSink(path.toString(), 0xff, false, makeConfig()); + sink.addFlux(0, 0, makeFluxmap()); + sink.close(); + + Bytes data = new Bytes(Files.readAllBytes(path)); + assertThat(data.size()).isGreaterThan(Scp.SCP_HEADER_SIZE); + assertThat(new String(data.slice(0, 3).toByteArray())).isEqualTo("SCP"); + assertThat(data.getByte(3) & 0xff).isEqualTo(0x18); /* version */ + assertThat(data.getByte(4) & 0xff).isEqualTo(0xff); /* type byte */ + } + + @Test + public void scpRejectsApple2() + { + ConfigProto config = new ConfigBuilder() + .set("usb.serial", "test-serial") + .set("drive.rotational_period_ms", "200") + .set("drive.drive_type", "DRIVETYPE_APPLE2") + .set("layout.tracks", "1") + .set("layout.sides", "1") + .build(); + + assertThrows( + FluxEngineException.class, + () -> new ScpFluxSink("test.scp", 0xff, false, config)); + } + + @Test + public void factoryWiring() + { + ConfigProto config = new ConfigBuilder() + .set("usb.serial", "test-serial") + .set("flux_sink.type", "FLUXTYPE_A2R") + .set("flux_sink.a2r.filename", "test.a2r") + .build(); + + FluxSinkFactory factory = FluxSinkFactory.create(config); + assertThat(factory).isInstanceOf(A2RFluxSinkFactory.class); + assertThat(factory.getPath()).isEqualTo("test.a2r"); + assertThat(factory.isHardware()).isFalse(); + } +} diff --git a/javatests/com/cowlark/fluxengine/fluxsource/A2RFluxSourceTest.java b/javatests/com/cowlark/fluxengine/fluxsource/A2RFluxSourceTest.java new file mode 100644 index 000000000..8172fc7a2 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsource/A2RFluxSourceTest.java @@ -0,0 +1,104 @@ +package com.cowlark.fluxengine.fluxsource; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.external.DriveType; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@RunWith(JUnit4.class) +public class A2RFluxSourceTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + /* Builds an A2R file containing a single track 0/0, encoded as a 3.5" + * disk with two short intervals. */ + private static Path writeTempFile() throws IOException + { + Bytes result = new Bytes(256); + ByteWriter bw = new ByteWriter(result); + + 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"), ... + writeChunk(bw, "INFO"); + int sizePos = bw.pos(); + bw.writeLe32(0); + bw.write8(1); + for (int i = 0; i < 32; i++) + bw.write8('x'); + bw.write8(2); + bw.write8(1); + bw.write8(1); + int infoEnd = bw.pos(); + bw.seek(sizePos); + 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. + 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(30); + bw.write8(30); + bw.write8(30); + bw.write8(0xff); // stream terminator + int strmSize = bw.pos(); + bw.seek(sizePos2); + bw.writeLe32(strmSize - sizePos2 - 4); + bw.seek(strmSize); + + Bytes bytes = result.slice(0, strmSize); + Path path = Files.createTempFile("flux", ".a2r"); + Files.write(path, bytes.toByteArray()); + return path; + } + + private static void writeChunk(ByteWriter bw, String id) + { + for (int i = 0; i < 4; i++) + bw.write8(id.charAt(i)); + } + + @Test + public void readsTracks() throws IOException + { + Path path = writeTempFile(); + + A2RFluxSource source = new A2RFluxSource(A2rFluxSourceProto.newBuilder() + .setFilename(path.toString()) + .build()); + + FluxSourceIterator iterator = + source.readFlux(FluxReadParameters.builder().setCylinder(0).setHead(0).build()); + assertThat(iterator.hasNext()).isTrue(); + Bytes expected = Bytes.of(0x40, 0xad, 0xad, 0xad); + assertThat(iterator.next().rawBytes().toByteArray()).isEqualTo(expected.toByteArray()); + assertThat(iterator.hasNext()).isFalse(); + assertThat(source.readFlux(FluxReadParameters.builder().setCylinder(1).setHead(0).build())).isInstanceOf( + EmptyFluxSourceIterator.class); + + ConfigBuilder configBuilder = new ConfigBuilder().set("usb.serial", "test-serial"); + source.adjustConfig(configBuilder); + ConfigProto config = configBuilder.build(); + assertThat(config.getDrive().getTracks()).isEqualTo("c0h0"); + assertThat(config.getDrive().getDriveType()).isEqualTo(DriveType.DRIVETYPE_80TRACK); + } +} \ No newline at end of file diff --git a/javatests/com/cowlark/fluxengine/fluxsource/BUILD.bazel b/javatests/com/cowlark/fluxengine/fluxsource/BUILD.bazel new file mode 100644 index 000000000..8debf4fe7 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsource/BUILD.bazel @@ -0,0 +1,93 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "A2RFluxSourceTest", + srcs = ["A2RFluxSourceTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "//java/com/cowlark/fluxengine/fluxsource", + "//java/com/cowlark/fluxengine/fluxsource:fluxsource_java_proto", + "@com_google_protobuf//java/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "Fl2FluxSourceTest", + srcs = ["Fl2FluxSourceTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "//java/com/cowlark/fluxengine/fluxsource", + "//java/com/cowlark/fluxengine/fluxsource:fluxsource_java_proto", + "@com_google_protobuf//java/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "ScpFluxSourceTest", + srcs = ["ScpFluxSourceTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/external", + "//java/com/cowlark/fluxengine/external:fl2_java_proto", + "//java/com/cowlark/fluxengine/fluxsource", + "//java/com/cowlark/fluxengine/fluxsource:fluxsource_java_proto", + "@com_google_protobuf//java/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "HardwareFluxSourceTest", + srcs = ["HardwareFluxSourceTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_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/usb", + "@com_google_protobuf//java/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) + +java_test( + name = "KryofluxFluxSourceTest", + srcs = ["KryofluxFluxSourceTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_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", + "@com_google_protobuf//java/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/fluxsource/Fl2FluxSourceTest.java b/javatests/com/cowlark/fluxengine/fluxsource/Fl2FluxSourceTest.java new file mode 100644 index 000000000..126162147 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsource/Fl2FluxSourceTest.java @@ -0,0 +1,92 @@ +package com.cowlark.fluxengine.fluxsource; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.external.FluxFileProto; +import com.cowlark.fluxengine.external.FluxFileVersion; +import com.cowlark.fluxengine.external.TrackFluxProto; +import com.google.protobuf.ByteString; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@RunWith(JUnit4.class) +public class Fl2FluxSourceTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + private static Path writeTemp(FluxFileProto file) throws IOException + { + Path path = Files.createTempFile("flux", ".fl2"); + Files.write(path, file.toByteArray()); + return path; + } + + @Test + public void readsTracks() throws IOException + { + TrackFluxProto track = TrackFluxProto.newBuilder() + .setTrack(0) + .setHead(0) + .addFlux(ByteString.copyFrom(new byte[]{(byte) 0xb0})) + .build(); + Path path = writeTemp(FluxFileProto.newBuilder() + .setVersion(FluxFileVersion.VERSION_2) + .addTrack(track) + .setRotationalPeriodMs(200.0) + .build()); + + Fl2FluxSource source = new Fl2FluxSource(Fl2FluxSourceProto.newBuilder() + .setFilename(path.toString()) + .build()); + + FluxSourceIterator iterator = + source.readFlux(FluxReadParameters.builder().setCylinder(0).setHead(0).build()); + assertThat(iterator.hasNext()).isTrue(); + assertThat(iterator.next()).isNotNull(); + assertThat(iterator.hasNext()).isFalse(); + assertThat(source.readFlux(FluxReadParameters.builder().setCylinder(1).setHead(0).build())).isInstanceOf( + EmptyFluxSourceIterator.class); + + ConfigBuilder configBuilder = new ConfigBuilder().set("usb.serial", "test-serial"); + source.adjustConfig(configBuilder); + ConfigProto config = configBuilder.build(); + assertThat(config.getDrive().getTracks()).isEqualTo("c0h0"); + assertThat(config.getDrive().getRotationalPeriodMs()).isEqualTo(200.0); + } + + @Test + public void upgradesVersion1() throws IOException + { + /* A single flux segment containing a desync byte should be split into + * two segments. */ + TrackFluxProto track = TrackFluxProto.newBuilder() + .setTrack(0) + .setHead(0) + .addFlux(ByteString.copyFrom(new byte[]{(byte) 0xb0, 0x00, (byte) 0xb0})) + .build(); + Path path = writeTemp(FluxFileProto.newBuilder() + .setVersion(FluxFileVersion.VERSION_1) + .addTrack(track) + .build()); + + Fl2FluxSource source = new Fl2FluxSource(Fl2FluxSourceProto.newBuilder() + .setFilename(path.toString()) + .build()); + + FluxSourceIterator iterator = + source.readFlux(FluxReadParameters.builder().setCylinder(0).setHead(0).build()); + assertThat(iterator.hasNext()).isTrue(); + iterator.next(); + assertThat(iterator.hasNext()).isTrue(); + iterator.next(); + assertThat(iterator.hasNext()).isFalse(); + } +} diff --git a/javatests/com/cowlark/fluxengine/fluxsource/FluxSourceTest.java b/javatests/com/cowlark/fluxengine/fluxsource/FluxSourceTest.java new file mode 100644 index 000000000..da4f299c4 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsource/FluxSourceTest.java @@ -0,0 +1,79 @@ +package com.cowlark.fluxengine.fluxsource; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.assertThrows; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.FluxSourceSinkType; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Fluxmap; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class FluxSourceTest +{ + @Test + public void createUnknownTypeReturnsNull() + { + FluxSourceProto config = + FluxSourceProto.newBuilder().setType(FluxSourceSinkType.FLUXTYPE_NOT_SET).build(); + + assertThat(FluxSource.create(config)).isNull(); + } + + @Test + public void createUnportedTypeThrows() + { + FluxSourceProto config = + FluxSourceProto.newBuilder().setType(FluxSourceSinkType.FLUXTYPE_DRIVE).build(); + + assertThrows(FluxEngineException.class, () -> FluxSource.create(config)); + } + + @Test + public void createEraseFluxSource() + { + FluxSourceProto config = + FluxSourceProto.newBuilder().setType(FluxSourceSinkType.FLUXTYPE_ERASE).build(); + + FluxSource source = FluxSource.create(config); + + assertThat(source).isInstanceOf(EraseFluxSource.class); + assertThat(source.readFlux(new FluxReadParameters(0, 0)).next()).isNull(); + + ConfigBuilder configBuilder = new ConfigBuilder().set("usb.serial", "test-serial"); + source.adjustConfig(configBuilder); + assertThat(configBuilder.build().getDrive().getTracks()).isEqualTo("c0-255h0-1"); + } + + @Test + public void trivialFluxSourceIteratorYieldsOneMap() + { + TrivialFluxSource source = new TrivialFluxSource() + { + @Override + public Fluxmap readSingleFlux(FluxReadParameters parameters) + { + return new Fluxmap(); + } + }; + + FluxSourceIterator iterator = source.readFlux(new FluxReadParameters(0, 0)); + + assertThat(iterator.hasNext()).isTrue(); + iterator.next(); + assertThat(iterator.hasNext()).isFalse(); + } + + @Test + public void emptyIterator() + { + FluxSourceIterator iterator = new EmptyFluxSourceIterator(); + + assertThat(iterator.hasNext()).isFalse(); + assertThrows(FluxEngineException.class, iterator::next); + } +} diff --git a/javatests/com/cowlark/fluxengine/fluxsource/HardwareFluxSourceTest.java b/javatests/com/cowlark/fluxengine/fluxsource/HardwareFluxSourceTest.java new file mode 100644 index 000000000..f0b826d57 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsource/HardwareFluxSourceTest.java @@ -0,0 +1,164 @@ +package com.cowlark.fluxengine.fluxsource; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.data.Fluxmap; +import com.cowlark.fluxengine.usb.UsbDevice; +import com.cowlark.fluxengine.usb.VoltageMeasurements; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class HardwareFluxSourceTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + private static class FakeUsbDevice extends UsbDevice + { + int seekedTo = -1; + int recalibrated = 0; + Integer readSide; + Boolean readSynced; + Double readTimeNs; + Double readThresholdNs; + Bytes readResult = new Bytes(); + + @Override + public void seek(int track) + { + seekedTo = track; + } + + @Override + public void recalibrate() + { + recalibrated++; + seek(0); + } + + @Override + public double getRotationalPeriod(int hardSectorCount) + { + return 0; + } + + @Override + public void testBulkWrite() + { + } + + @Override + public void testBulkRead() + { + } + + @Override + public Bytes read(int side, boolean synced, double readTimeNs, double hardSectorThresholdNs) + { + readSide = side; + readSynced = synced; + this.readTimeNs = readTimeNs; + readThresholdNs = hardSectorThresholdNs; + return readResult; + } + + @Override + public void write(int side, Bytes bytes, double hardSectorThresholdNs) + { + } + + @Override + public void erase(int side, double hardSectorThresholdNs) + { + } + + @Override + public void setDrive(int drive, boolean highDensity, int indexMode) + { + } + + @Override + public VoltageMeasurements measureVoltages() + { + return null; + } + + @Override + public void close() + { + } + } + + private static ConfigProto config() + { + return new ConfigBuilder().set("usb.serial", "test-serial") + .set("drive.sync_with_index", "true") + .set("drive.revolutions", "3") + .set("drive.rotational_period_ms", "200") + .set("drive.hard_sector_threshold_ns", "1000") + .build(); + } + + @Test + public void isHardware() + { + HardwareFluxSource source = new HardwareFluxSource(config(), new FakeUsbDevice()); + + assertThat(source.isHardware()).isTrue(); + } + + @Test + public void seekDelegatesToDevice() + { + FakeUsbDevice device = new FakeUsbDevice(); + HardwareFluxSource source = new HardwareFluxSource(config(), device); + + source.seek(42); + + assertThat(device.seekedTo).isEqualTo(42); + } + + @Test + public void recalibrateDelegatesToDevice() + { + FakeUsbDevice device = new FakeUsbDevice(); + HardwareFluxSource source = new HardwareFluxSource(config(), device); + + source.recalibrate(); + + assertThat(device.recalibrated).isEqualTo(1); + } + + @Test + public void readFluxReadsAndWrapsFluxmap() + { + FakeUsbDevice device = new FakeUsbDevice(); + device.readResult = Bytes.of(0x01, 0x02, 0x03, 0x04); + HardwareFluxSource source = new HardwareFluxSource(config(), device); + + FluxSourceIterator iterator = source.readFlux(FluxReadParameters.builder() + .setCylinder(17) + .setHead(1) + .setSyncWithIndex(true) + .setReadTimeNs(3 * 200 * 1e6) + .setHardSectorThresholdNs(1000) + .build()); + + assertThat(iterator.hasNext()).isTrue(); + Fluxmap fluxmap = iterator.next(); + + assertThat(device.seekedTo).isEqualTo(17); + assertThat(device.readSide).isEqualTo(1); + assertThat(device.readSynced).isTrue(); + assertThat(device.readTimeNs).isEqualTo(3 * 200 * 1e6); + assertThat(device.readThresholdNs).isEqualTo(1000); + assertThat(fluxmap.rawBytes()).isEqualTo(device.readResult); + assertThat(iterator.hasNext()).isTrue(); + } +} diff --git a/javatests/com/cowlark/fluxengine/fluxsource/KryofluxFluxSourceTest.java b/javatests/com/cowlark/fluxengine/fluxsource/KryofluxFluxSourceTest.java new file mode 100644 index 000000000..2f0462b2a --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsource/KryofluxFluxSourceTest.java @@ -0,0 +1,47 @@ +package com.cowlark.fluxengine.fluxsource; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.stream.Collectors; + +@RunWith(JUnit4.class) +public class KryofluxFluxSourceTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + @Rule public TemporaryFolder folder = new TemporaryFolder(); + + @Test + public void readsSingleFluxFromDirectory() throws Exception + { + Path dir = folder.getRoot().toPath(); + Files.write(dir.resolve("track80.0.raw"), new byte[]{0x20}); + Files.write(dir.resolve("track81.1.raw"), new byte[]{0x20}); + + KryofluxFluxSourceProto config = + KryofluxFluxSourceProto.newBuilder().setDirectory(dir.toString()).build(); + KryofluxFluxSource source = new KryofluxFluxSource(config); + + assertThat(source.readSingleFlux(FluxReadParameters.builder() + .setCylinder(80) + .setHead(0) + .build()).rawBytes().toByteArray()).isEqualTo(new byte[]{(byte) 0x8f}); + + ConfigBuilder configBuilder = new ConfigBuilder().set("usb.serial", "test-serial"); + source.adjustConfig(configBuilder); + String tracks = configBuilder.build().getDrive().getTracks(); + String sorted = Arrays.stream(tracks.split(" ")).sorted().collect(Collectors.joining(" ")); + assertThat(sorted).isEqualTo("c80h0 c81h1"); + } +} \ No newline at end of file diff --git a/javatests/com/cowlark/fluxengine/fluxsource/ScpFluxSourceTest.java b/javatests/com/cowlark/fluxengine/fluxsource/ScpFluxSourceTest.java new file mode 100644 index 000000000..a3e5a1e7d --- /dev/null +++ b/javatests/com/cowlark/fluxengine/fluxsource/ScpFluxSourceTest.java @@ -0,0 +1,106 @@ +package com.cowlark.fluxengine.fluxsource; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.ByteWriter; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.external.DriveType; +import com.cowlark.fluxengine.external.Scp; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@RunWith(JUnit4.class) +public class ScpFluxSourceTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + /* Builds an SCP file containing a single track 0/0 (strack 0), encoded + * with two intervals of 100 and 200 at a 25ns resolution. */ + private static Path writeTempFile() throws IOException + { + Bytes result = new Bytes(Scp.SCP_HEADER_SIZE + 4 + 12 + 4); + ByteWriter bw = new ByteWriter(result); + + bw.write8('S'); + bw.write8('C'); + bw.write8('P'); + bw.write8(0x18); /* version 1.8 */ + bw.write8(0xff); /* type */ + bw.write8(1); /* revolutions */ + bw.write8(Scp.strackno(0, 0)); /* start track */ + bw.write8(Scp.strackno(0, 0)); /* end track */ + bw.write8(0); /* flags: not 96tpi */ + bw.write8(0); /* cell width: 16-bit cells */ + bw.write8(1); /* heads: side 0 only */ + bw.write8(0); /* resolution: 25ns */ + bw.writeLe32(0); /* checksum */ + + /* Track offset table; only strack 0 is present. */ + int trackOffset = Scp.SCP_HEADER_SIZE; + for (int i = 0; i < 168; i++) + bw.writeLe32(i == 0 ? trackOffset : 0); + + /* Track header: 'TRK' + strack, then one revolution record. */ + bw.write8('T'); + bw.write8('R'); + bw.write8('K'); + bw.write8(0); /* strack */ + bw.writeLe32(0); /* index */ + bw.writeLe32(2); /* length: two cells */ + bw.writeLe32(16); /* offset to cell data, relative to track header */ + + /* Cell data: two big-endian intervals. */ + bw.writeBe16(100); + bw.writeBe16(200); + + Path path = Files.createTempFile("flux", ".scp"); + Files.write(path, result.toByteArray()); + return path; + } + + @Test + public void readsTracks() throws IOException + { + Path path = writeTempFile(); + + ScpFluxSource source = new ScpFluxSource(ScpFluxSourceProto.newBuilder() + .setFilename(path.toString()) + .build()); + + FluxSourceIterator iterator = + source.readFlux(FluxReadParameters.builder().setCylinder(0).setHead(0).build()); + assertThat(iterator.hasNext()).isTrue(); + Bytes expected = Bytes.of(0x9e, 0xbc); + assertThat(iterator.next().rawBytes().toByteArray()).isEqualTo(expected.toByteArray()); + assertThat(iterator.hasNext()).isFalse(); + + ConfigBuilder configBuilder = new ConfigBuilder().set("usb.serial", "test-serial"); + source.adjustConfig(configBuilder); + ConfigProto config = configBuilder.build(); + assertThat(config.getDrive().getTracks()).isEqualTo("c0h0"); + assertThat(config.getDrive().getDriveType()).isEqualTo(DriveType.DRIVETYPE_40TRACK); + } + + @Test + public void missingTrackReturnsEmptyFluxmap() throws IOException + { + Path path = writeTempFile(); + + ScpFluxSource source = new ScpFluxSource(ScpFluxSourceProto.newBuilder() + .setFilename(path.toString()) + .build()); + + FluxSourceIterator iterator = + source.readFlux(FluxReadParameters.builder().setCylinder(1).setHead(0).build()); + assertThat(iterator.hasNext()).isTrue(); + assertThat(iterator.next().ticks()).isEqualTo(0); + } +} diff --git a/javatests/com/cowlark/fluxengine/gui/BUILD.bazel b/javatests/com/cowlark/fluxengine/gui/BUILD.bazel new file mode 100644 index 000000000..a323de3a6 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/gui/BUILD.bazel @@ -0,0 +1,18 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "PreferencesReaderWriterTest", + srcs = ["PreferencesReaderWriterTest.java"], + deps = [ + "//java/com/cowlark/fluxengine/gui", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + "@maven//:net_bytebuddy_byte_buddy", + "@maven//:net_bytebuddy_byte_buddy_agent", + "@maven//:org_mockito_mockito_core", + "@maven//:org_objenesis_objenesis", + ], +) diff --git a/javatests/com/cowlark/fluxengine/gui/PreferencesReaderWriterTest.java b/javatests/com/cowlark/fluxengine/gui/PreferencesReaderWriterTest.java new file mode 100644 index 000000000..ab35b0be9 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/gui/PreferencesReaderWriterTest.java @@ -0,0 +1,105 @@ +package com.cowlark.fluxengine.gui; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import java.util.prefs.Preferences; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class PreferencesReaderWriterTest +{ + @Mock private Preferences preferences; + private PreferencesReaderWriter writer = null; + + @Test + public void setOptionsForFormatEncodesIntoPreferences() + { + writer = new PreferencesReaderWriter(preferences); + + writer.setOptionsForFormat("ibm", ImmutableMap.of("tracks", "c0-80", "side", "0")); + + verify(preferences).put("format_ibm", "tracks=c0-80&side=0"); + } + + @Test + public void getOptionsForFormatDecodesFromPreferences() + { + when(preferences.get("format_ibm", "")).thenReturn("tracks=c0-80&side=0"); + writer = new PreferencesReaderWriter(preferences); + + assertThat(writer.getOptionsForFormat("ibm")) + .isEqualTo(ImmutableMap.of("tracks", "c0-80", "side", "0")); + } + + @Test + public void roundTripPreservesOptions() + { + writer = new PreferencesReaderWriter(preferences); + ImmutableMap options = ImmutableMap.of( + "density", "hd", + "cylinders", "0-79", + "rotational-period-ms", "200"); + + writer.setOptionsForFormat("ibm", options); + + when(preferences.get("format_ibm", "")).thenReturn( + "density=hd&cylinders=0-79&rotational-period-ms=200"); + assertThat(writer.getOptionsForFormat("ibm")).isEqualTo(options); + } + + @Test + public void roundTripEncodesSpecialCharacters() + { + writer = new PreferencesReaderWriter(preferences); + ImmutableMap options = ImmutableMap.of( + "comment", "hello world & goodbye", + "path", "a=b%c+d"); + + writer.setOptionsForFormat("amiga", options); + + String stored = options.entrySet() + .stream() + .map(entry -> java.net.URLEncoder.encode(entry.getKey(), + java.nio.charset.StandardCharsets.UTF_8) + "=" + + java.net.URLEncoder.encode(entry.getValue(), + java.nio.charset.StandardCharsets.UTF_8)) + .collect(java.util.stream.Collectors.joining("&")); + when(preferences.get("format_amiga", "")).thenReturn(stored); + + assertThat(writer.getOptionsForFormat("amiga")).isEqualTo(options); + } + + @Test + public void getOptionsForFormatReturnsEmptyMapWhenNotSet() + { + when(preferences.get("format_unknown", "")).thenReturn(""); + writer = new PreferencesReaderWriter(preferences); + + assertThat(writer.getOptionsForFormat("unknown")).isEmpty(); + } + + @Test + public void setPreferenceStoresValue() + { + writer = new PreferencesReaderWriter(preferences); + + writer.setPreference("last-format", "ibm"); + + verify(preferences).put("last-format", "ibm"); + } + + @Test + public void getPreferenceReturnsDefaultWhenNotSet() + { + when(preferences.get("missing", "default")).thenReturn("default"); + writer = new PreferencesReaderWriter(preferences); + + assertThat(writer.getPreference("missing", "default")).isEqualTo("default"); + } +} diff --git a/javatests/com/cowlark/fluxengine/imagereader/BUILD.bazel b/javatests/com/cowlark/fluxengine/imagereader/BUILD.bazel new file mode 100644 index 000000000..dca1091dc --- /dev/null +++ b/javatests/com/cowlark/fluxengine/imagereader/BUILD.bazel @@ -0,0 +1,21 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "ImageReaderTest", + srcs = ["ImageReaderTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:common_java_proto", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/imagereader", + "//java/com/cowlark/fluxengine/imagereader:imagereader_java_proto", + "@com_google_protobuf//java/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/imagereader/ImageReaderTest.java b/javatests/com/cowlark/fluxengine/imagereader/ImageReaderTest.java new file mode 100644 index 000000000..3de31c9b6 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/imagereader/ImageReaderTest.java @@ -0,0 +1,165 @@ +package com.cowlark.fluxengine.imagereader; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.config.ImageReaderWriterType; +import com.cowlark.fluxengine.core.Bytes; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.Sector; +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 ImageReaderTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + @Test + public void createD64ImageReader() + { + ImageReaderProto config = ImageReaderProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_D64) + .build(); + + assertThat(ImageReader.create(config)).isInstanceOf(D64ImageReader.class); + } + + @Test + public void createImgImageReader() + { + ImageReaderProto config = ImageReaderProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_IMG) + .build(); + + assertThat(ImageReader.create(ConfigProto.getDefaultInstance(), config)) + .isInstanceOf(ImgImageReader.class); + } + + @Test + public void createNsiImageReader() + { + ImageReaderProto config = ImageReaderProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_NSI) + .build(); + + assertThat(ImageReader.create(config)).isInstanceOf(NsiImageReader.class); + } + + @Test + public void createTd0ImageReader() + { + ImageReaderProto config = ImageReaderProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_TD0) + .build(); + + assertThat(ImageReader.create(config)).isInstanceOf(Td0ImageReader.class); + } + + @Test + public void createBadTypeThrows() + { + ImageReaderProto config = ImageReaderProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_NOT_SET) + .build(); + + assertThrows(FluxEngineException.class, () -> ImageReader.create(config)); + } + + @Test + public void createNoReaderConfiguredThrows() + { + ConfigProto config = new ConfigBuilder() + .set("usb.serial", "test-serial") + .build(); + + assertThrows(FluxEngineException.class, () -> ImageReader.create(config)); + } + + @Test + public void d64ReadsSectorData() throws Exception + { + /* 40 tracks; the first track has 21 sectors of 256 bytes. Write a + * single byte of payload at the start of sector 0. */ + Path file = Files.createTempFile("image", ".d64"); + byte[] data = new byte[256 * 21]; + data[0] = 0x42; + Files.write(file, data); + + ImageReaderProto config = ImageReaderProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_D64) + .setFilename(file.toString()) + .build(); + + Image image = new D64ImageReader(config).readImage(); + + Sector sector = image.get(0, 0, 0); + assertThat(sector).isNotNull(); + assertThat(sector.status).isEqualTo(Sector.Status.OK); + assertThat(sector.data.getByte(0) & 0xff).isEqualTo(0x42); + assertThat(sector.data.size()).isEqualTo(256); + } + + @Test + public void d64ShortFileMarksMissing() throws Exception + { + Path file = Files.createTempFile("image", ".d64"); + Files.write(file, new byte[10]); + + ImageReaderProto config = ImageReaderProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_D64) + .setFilename(file.toString()) + .build(); + + Image image = new D64ImageReader(config).readImage(); + + /* Track 0, sector 0 is present (10 bytes available); track 39, sector + * 0 has no data. */ + assertThat(image.get(0, 0, 0).status).isEqualTo(Sector.Status.OK); + assertThat(image.get(39, 0, 0).status).isEqualTo(Sector.Status.DATA_MISSING); + } + + @Test + public void nsiReadsSectorData() throws Exception + { + /* 35 tracks x 2 heads x 10 sectors x 512 bytes. */ + Path file = Files.createTempFile("image", ".nsi"); + byte[] data = new byte[358400]; + data[0] = 0x43; + Files.write(file, data); + + ImageReaderProto config = ImageReaderProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_NSI) + .setFilename(file.toString()) + .build(); + + Image image = new NsiImageReader(config).readImage(); + + Sector sector = image.get(0, 0, 0); + assertThat(sector).isNotNull(); + assertThat(sector.data.getByte(0) & 0xff).isEqualTo(0x43); + assertThat(image.get(34, 1, 0)).isNotNull(); + } + + @Test + public void nsiUnknownSizeThrows() throws Exception + { + Path file = Files.createTempFile("image", ".nsi"); + Files.write(file, new byte[12345]); + + ImageReaderProto config = ImageReaderProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_NSI) + .setFilename(file.toString()) + .build(); + + assertThrows(FluxEngineException.class, () -> new NsiImageReader(config).readImage()); + } +} diff --git a/javatests/com/cowlark/fluxengine/imagewriter/BUILD.bazel b/javatests/com/cowlark/fluxengine/imagewriter/BUILD.bazel new file mode 100644 index 000000000..49548f64c --- /dev/null +++ b/javatests/com/cowlark/fluxengine/imagewriter/BUILD.bazel @@ -0,0 +1,21 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "ImageWriterTest", + srcs = ["ImageWriterTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:common_java_proto", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/data", + "//java/com/cowlark/fluxengine/imagewriter", + "//java/com/cowlark/fluxengine/imagewriter:imagewriter_java_proto", + "@com_google_protobuf//java/core", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/imagewriter/ImageWriterTest.java b/javatests/com/cowlark/fluxengine/imagewriter/ImageWriterTest.java new file mode 100644 index 000000000..7a30a3d1d --- /dev/null +++ b/javatests/com/cowlark/fluxengine/imagewriter/ImageWriterTest.java @@ -0,0 +1,198 @@ +package com.cowlark.fluxengine.imagewriter; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.assertThrows; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.config.ImageReaderWriterType; +import com.cowlark.fluxengine.core.FluxEngineException; +import com.cowlark.fluxengine.data.Image; +import com.cowlark.fluxengine.data.LogicalLocation; +import com.cowlark.fluxengine.data.Sector; +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 ImageWriterTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + @Test + public void createUnportedTypeThrows() + { + ImageWriterProto config = ImageWriterProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_LDBS) + .build(); + + assertThrows(FluxEngineException.class, () -> ImageWriter.create(config)); + } + + @Test + public void createD64ImageWriter() + { + ImageWriterProto config = ImageWriterProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_D64) + .build(); + + assertThat(ImageWriter.create(config)).isInstanceOf(D64ImageWriter.class); + } + + @Test + public void createD88ImageWriter() + { + ImageWriterProto config = ImageWriterProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_D88) + .build(); + + assertThat(ImageWriter.create(config)).isInstanceOf(D88ImageWriter.class); + } + + @Test + public void createDiskCopyImageWriter() + { + ImageWriterProto config = ImageWriterProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_DISKCOPY) + .build(); + + assertThat(ImageWriter.create(config)).isInstanceOf(DiskCopyImageWriter.class); + } + + @Test + public void createImdImageWriter() + { + ImageWriterProto config = ImageWriterProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_IMD) + .build(); + + assertThat(ImageWriter.create(config)).isInstanceOf(ImdImageWriter.class); + } + + @Test + public void createNsiImageWriter() + { + ImageWriterProto config = ImageWriterProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_NSI) + .build(); + + assertThat(ImageWriter.create(config)).isInstanceOf(NsiImageWriter.class); + } + + @Test + public void createRawImageWriter() + { + ImageWriterProto config = ImageWriterProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_RAW) + .build(); + + assertThat(ImageWriter.create(config)).isInstanceOf(RawImageWriter.class); + } + + @Test + public void createImgImageWriterFromConfig() + { + ConfigProto config = new ConfigBuilder() + .set("usb.serial", "test-serial") + .withImageWriter("out.dsk") + .build(); + + assertThat(ImageWriter.create(config)).isInstanceOf(ImgImageWriter.class); + } + + @Test + public void createBadTypeThrows() + { + ImageWriterProto config = ImageWriterProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_NOT_SET) + .build(); + + assertThrows(FluxEngineException.class, () -> ImageWriter.create(config)); + } + + @Test + public void createNoWriterConfiguredThrows() + { + ConfigProto config = new ConfigBuilder() + .set("usb.serial", "test-serial") + .build(); + + assertThrows(FluxEngineException.class, () -> ImageWriter.create(config)); + } + + @Test + public void d64WritesSectorData() throws Exception + { + Image image = new Image(); + Sector sector = image.put(0, 0, 0); + sector.data = com.cowlark.fluxengine.core.Bytes.of(1, 2, 3, 4); + + Path file = Files.createTempFile("image", ".d64"); + ImageWriterProto config = ImageWriterProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_D64) + .setFilename(file.toString()) + .build(); + + new D64ImageWriter(config).writeImage(image); + + byte[] data = Files.readAllBytes(file); + assertThat(data.length).isEqualTo(4); + assertThat(data[0]).isEqualTo((byte) 1); + assertThat(data[1]).isEqualTo((byte) 2); + assertThat(data[2]).isEqualTo((byte) 3); + assertThat(data[3]).isEqualTo((byte) 4); + } + + @Test + public void d64EmptyImageWritesNothing() throws Exception + { + Image image = new Image(); + + Path file = Files.createTempFile("image", ".d64"); + ImageWriterProto config = ImageWriterProto.newBuilder() + .setType(ImageReaderWriterType.IMAGETYPE_D64) + .setFilename(file.toString()) + .build(); + + new D64ImageWriter(config).writeImage(image); + + byte[] data = Files.readAllBytes(file); + assertThat(data).isEmpty(); + } + + @Test + public void writeCsv() throws Exception + { + Image image = new Image(); + Sector sector = image.put(2, 1, 5); + sector.status = Sector.Status.OK; + sector.position = 1234; + sector.clockNs = 2000.0; + sector.headerStartTimeNs = 1.0; + sector.headerEndTimeNs = 2.0; + sector.dataStartTimeNs = 3.0; + sector.dataEndTimeNs = 4.0; + + Path file = Files.createTempFile("image", ".csv"); + ImageWriter writer = new ImageWriter(ImageWriterProto.getDefaultInstance()) + { + @Override + public void writeImage(Image image) + { + } + }; + + writer.writeCsv(image, file.toString()); + + String contents = Files.readString(file); + assertThat(contents).contains("\"Physical track\",\"Physical side\""); + assertThat(contents).contains("\"Status\""); + assertThat(contents).contains( + "-1,-1,5,2,1,2000.0,1.0,2.0,3.0,4.0,1234,0,OK\n"); + } +} diff --git a/javatests/com/cowlark/fluxengine/testing/BUILD.bazel b/javatests/com/cowlark/fluxengine/testing/BUILD.bazel new file mode 100644 index 000000000..e9762b504 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/testing/BUILD.bazel @@ -0,0 +1,15 @@ +load("@rules_java//java:defs.bzl", "java_library") + +package(default_visibility = ["//visibility:public"]) + +java_library( + name = "testing", + srcs = [ + "LoggerRule.java", + "TestHelpers.java", + ], + deps = [ + "//java/com/cowlark/fluxengine/core", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/testing/LoggerRule.java b/javatests/com/cowlark/fluxengine/testing/LoggerRule.java new file mode 100644 index 000000000..e61293128 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/testing/LoggerRule.java @@ -0,0 +1,41 @@ +package com.cowlark.fluxengine.testing; + +import com.cowlark.fluxengine.core.LogMessage; +import com.cowlark.fluxengine.core.LogRenderer; +import com.cowlark.fluxengine.core.Logger; +import java.util.function.Consumer; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +/** + * A JUnit rule which installs a stdout-rendering logger for the thread running + * the test, and restores the previous logger afterwards. Attach it with: + * + *

+ * @Rule public final TestRule loggerRule = new LoggerRule();
+ * 
+ */ +public final class LoggerRule implements TestRule +{ + @Override + public Statement apply(Statement base, Description description) + { + return new Statement() + { + @Override + public void evaluate() throws Throwable + { + Consumer oldLogger = Logger.getLogger(); + Logger.setLogger(LogRenderer.create(System.out)::add); + try + { + base.evaluate(); + } finally + { + Logger.setLogger(oldLogger); + } + } + }; + } +} diff --git a/javatests/com/cowlark/fluxengine/testing/TestHelpers.java b/javatests/com/cowlark/fluxengine/testing/TestHelpers.java new file mode 100644 index 000000000..309d7db08 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/testing/TestHelpers.java @@ -0,0 +1,18 @@ +package com.cowlark.fluxengine.testing; + +import org.junit.rules.TestRule; + +/** + * A convenience wrapper for creating a {@link LoggerRule}. + */ +public final class TestHelpers +{ + private TestHelpers() + { + } + + public static TestRule loggerRule() + { + return new LoggerRule(); + } +} diff --git a/javatests/com/cowlark/fluxengine/usb/BUILD.bazel b/javatests/com/cowlark/fluxengine/usb/BUILD.bazel new file mode 100644 index 000000000..ffccf9855 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/usb/BUILD.bazel @@ -0,0 +1,17 @@ +load("@rules_java//java:defs.bzl", "java_test") + +package(default_visibility = ["//visibility:public"]) + +java_test( + name = "UsbFactoryTest", + srcs = ["UsbFactoryTest.java"], + deps = [ + "//javatests/com/cowlark/fluxengine/testing", + "//java/com/cowlark/fluxengine/config", + "//java/com/cowlark/fluxengine/config:config_java_proto", + "//java/com/cowlark/fluxengine/core", + "//java/com/cowlark/fluxengine/usb", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + ], +) diff --git a/javatests/com/cowlark/fluxengine/usb/UsbFactoryTest.java b/javatests/com/cowlark/fluxengine/usb/UsbFactoryTest.java new file mode 100644 index 000000000..7e13cd999 --- /dev/null +++ b/javatests/com/cowlark/fluxengine/usb/UsbFactoryTest.java @@ -0,0 +1,152 @@ +package com.cowlark.fluxengine.usb; + +import static com.google.common.truth.Truth.assertThat; + +import com.cowlark.fluxengine.config.ConfigBuilder; +import com.cowlark.fluxengine.config.ConfigProto; +import com.cowlark.fluxengine.core.Bytes; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class UsbFactoryTest +{ + @org.junit.Rule + public final org.junit.rules.TestRule loggerRule = + com.cowlark.fluxengine.testing.TestHelpers.loggerRule(); + + private static class FakeUsbDevice extends UsbDevice + { + int closed = 0; + + @Override + public void seek(int track) + { + } + + @Override + public double getRotationalPeriod(int hardSectorCount) + { + return 0; + } + + @Override + public void testBulkWrite() + { + } + + @Override + public void testBulkRead() + { + } + + @Override + public Bytes read(int side, boolean synced, double readTimeNs, double hardSectorThresholdNs) + { + return new Bytes(); + } + + @Override + public void write(int side, Bytes bytes, double hardSectorThresholdNs) + { + } + + @Override + public void erase(int side, double hardSectorThresholdNs) + { + } + + @Override + public void setDrive(int drive, boolean highDensity, int indexMode) + { + } + + @Override + public VoltageMeasurements measureVoltages() + { + return null; + } + + @Override + public void close() + { + closed++; + } + } + + private static ConfigProto config() + { + return new ConfigBuilder() + .set("usb.serial", "test-serial") + .build(); + } + + private static void withFakeFactory(java.util.function.Function factory, + Runnable test) + { + java.util.function.Function saved = UsbFactory.deviceFactory; + UsbFactory.deviceFactory = factory; + try + { + UsbFactory.reconnect(config()); /* flush any cached device */ + test.run(); + } finally + { + UsbFactory.deviceFactory = saved; + } + } + + @Test + public void reconnectReturnsSameInstanceForSameConfig() + { + withFakeFactory(c -> new FakeUsbDevice(), () -> + { + ConfigProto config = config(); + + UsbDevice first = UsbFactory.reconnect(config); + UsbDevice second = UsbFactory.reconnect(config); + + assertThat(second).isSameInstanceAs(first); + }); + } + + @Test + public void reconnectCachesByConfigValue() + { + withFakeFactory(c -> new FakeUsbDevice(), () -> + { + /* The cache is keyed by ConfigProto value equality, so a distinct + * but equal config object must hit the same cache entry. */ + ConfigProto first = config(); + ConfigProto second = config(); + + UsbDevice a = UsbFactory.reconnect(first); + UsbDevice b = UsbFactory.reconnect(second); + + assertThat(a).isNotNull(); + assertThat(b).isSameInstanceAs(a); + }); + } + + @Test + public void reconnectWithDifferentConfigEvictsAndClosesOldDevice() + { + withFakeFactory(c -> new FakeUsbDevice(), () -> + { + ConfigProto first = config(); + ConfigProto second = new ConfigBuilder() + .set("usb.serial", "test-serial") + .set("drive.drive", "1") + .build(); + + UsbDevice a = UsbFactory.reconnect(first); + FakeUsbDevice fakeA = (FakeUsbDevice) a; + UsbDevice b = UsbFactory.reconnect(second); + + assertThat(a).isNotNull(); + assertThat(b).isNotSameInstanceAs(a); + assertThat(fakeA.closed).isEqualTo(1); + }); + } +} diff --git a/javatests/javatests.iml b/javatests/javatests.iml new file mode 100644 index 000000000..a6c28e92b --- /dev/null +++ b/javatests/javatests.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/jpackage.bzl b/jpackage.bzl new file mode 100644 index 000000000..b8411d51b --- /dev/null +++ b/jpackage.bzl @@ -0,0 +1,207 @@ +_JPACKAGE_TYPES = ["deb", "rpm", "msi", "dmg", "unsupported"] + +def _add_launcher_args(files): + args = [] + for f in files: + name = f.basename + if name.endswith(".properties"): + name = name[: -len(".properties")] + args.append("--add-launcher %s=%s" % (name, f.path)) + return " ".join(args) + + +def _jpackage_impl(ctx): + # Locate jpackage via the configured Java toolchain's runtime, so the rule + # works with whatever JDK Bazel is using (e.g. remotejdk_21). + java_runtime = ctx.toolchains["@bazel_tools//tools/jdk:toolchain_type"].java.java_runtime + jpackage_path = java_runtime.java_home + "/bin/jpackage" + + package_type = ctx.attr.package_type + if package_type == "unsupported": + # Not the platform this installer targets (jpackage can't cross + # compile); produce an empty target so `bazel build //java/...` still + # works everywhere. Trying to actually use the output on the wrong + # platform will just find nothing. + return [DefaultInfo()] + + extension = { + "deb": "deb", + "rpm": "rpm", + "msi": "msi", + "dmg": "dmg", + }[package_type] + + jar = ctx.file.jar + out = ctx.actions.declare_file(ctx.attr.package_name + "_" + ctx.attr.app_version + "." + extension) + extra_launchers = ctx.files.extra_launchers + + # jpackage writes a lot of scratch state (a jlink runtime image and an app + # image) and chmods files in it. Do all the scratch work in a plain + # directory under the execroot (which is writable in the sandbox) and only + # declare the final package as an output. The sandbox input jar is a + # symlink to a read-only file, so dereference it (cp -L) and make the copy + # writable. + # + # rpmbuild (invoked by jpackage for --type rpm) creates its temp scripts in + # /var/tmp by default, which is read-only in the sandbox, so point it at the + # scratch dir via a ~/.rpmmacros file. + ctx.actions.run_shell( + outputs = [out], + inputs = [jar] + extra_launchers, + tools = [java_runtime.files], + use_default_shell_env = True, + command = """ + rm -rf workdir + mkdir -p workdir/input workdir/tmp workdir/dest workdir/home workdir/rpmbuild + cp -L "{jar}" workdir/input/ + chmod u+w workdir/input/* + if [ "{package_type}" = "rpm" ]; then + WORKTMP="$(pwd)/workdir/tmp" + RPMPREFIX="$(pwd)/workdir/rpmbuild" + cat > workdir/home/.rpmmacros <& lhs, - const std::shared_ptr& rhs) -{ - return *lhs < *rhs; -} - -bool sectorPointerEqualsPredicate(const std::shared_ptr& lhs, - const std::shared_ptr& rhs) -{ - if (!lhs && !rhs) - return true; - if (!lhs || !rhs) - return false; - return *lhs == *rhs; -} diff --git a/lib/data/sector.h b/lib/data/sector.h deleted file mode 100644 index d198ff9ae..000000000 --- a/lib/data/sector.h +++ /dev/null @@ -1,70 +0,0 @@ -#ifndef SECTOR_H -#define SECTOR_H - -#include "lib/core/bytes.h" -#include "lib/data/fluxmap.h" -#include "lib/data/locations.h" - -class Record; -class LogicalTrackLayout; - -struct Sector : public LogicalLocation -{ - enum Status - { - OK, - BAD_CHECKSUM, - MISSING, - DATA_MISSING, - CONFLICT, - INTERNAL_ERROR, - }; - - static std::string statusToString(Status status); - static std::string statusToChar(Status status); - static Status stringToStatus(const std::string& value); - - Status status = Status::INTERNAL_ERROR; - uint32_t position = 0; - nanoseconds_t clock = 0; - nanoseconds_t headerStartTime = 0; - nanoseconds_t headerEndTime = 0; - nanoseconds_t dataStartTime = 0; - nanoseconds_t dataEndTime = 0; - std::optional physicalLocation = {}; - Bytes data; - std::vector> records; - - Sector(const Sector& other) = default; - Sector& operator=(const Sector& other) = default; - - Sector(const LogicalLocation& location); - - std::tuple key() const - { - return std::make_tuple( - logicalCylinder, logicalHead, logicalSector, status); - } - - std::strong_ordering operator<=>(const Sector& rhs) const - { - return key() <=> rhs.key(); - } -}; - -template <> -struct fmt::formatter : formatter -{ - auto format(Sector::Status status, format_context& ctx) const - { - return fmt::format_to(ctx.out(), "{}", Sector::statusToString(status)); - } -}; - -extern bool sectorPointerSortPredicate(const std::shared_ptr& lhs, - const std::shared_ptr& rhs); -extern bool sectorPointerEqualsPredicate( - const std::shared_ptr& lhs, - const std::shared_ptr& rhs); - -#endif diff --git a/lib/decoders/build.py b/lib/decoders/build.py deleted file mode 100644 index fb4cf836e..000000000 --- a/lib/decoders/build.py +++ /dev/null @@ -1,29 +0,0 @@ -from build.protobuf import proto, protocc -from build.c import cxxlibrary - -proto( - name="proto", - srcs=["./decoders.proto"], - deps=["lib/config+common_proto", "arch+proto", "lib/fluxsink+proto"], -) - -protocc( - name="proto_lib", - srcs=[".+proto"], - deps=[ - "lib/config+common_proto_lib", - "arch+proto_lib", - "lib/fluxsink+proto_lib", - ], -) - -cxxlibrary( - name="decoders", - srcs=["./decoders.cc", "./fluxdecoder.cc", "./fmmfm.cc"], - hdrs={ - "lib/decoders/decoders.h": "./decoders.h", - "lib/decoders/fluxdecoder.h": "./fluxdecoder.h", - "lib/decoders/rawbits.h": "./rawbits.h", - }, - deps=["lib/core", "lib/config", "lib/data", ".+proto_lib"], -) diff --git a/lib/decoders/decoders.cc b/lib/decoders/decoders.cc deleted file mode 100644 index 443b96583..000000000 --- a/lib/decoders/decoders.cc +++ /dev/null @@ -1,179 +0,0 @@ -#include "lib/core/globals.h" -#include "lib/config/flags.h" -#include "lib/data/fluxmap.h" -#include "lib/config/config.h" -#include "lib/decoders/decoders.h" -#include "lib/data/fluxmapreader.h" -#include "lib/data/disk.h" -#include "protocol.h" -#include "lib/decoders/rawbits.h" -#include "lib/data/sector.h" -#include "lib/data/image.h" -#include "lib/decoders/decoders.pb.h" -#include "lib/data/layout.h" -#include - -std::shared_ptr Decoder::decodeToSectors( - std::shared_ptr fluxmap, - const std::shared_ptr& ptl) -{ - _ltl = ptl->logicalTrackLayout; - - _trackdata = std::make_shared(); - _trackdata->fluxmap = fluxmap; - _trackdata->ptl = ptl; - _trackdata->ltl = ptl->logicalTrackLayout; - - FluxmapReader fmr(*fluxmap); - _fmr = &fmr; - - auto newSector = [&] - { - _sector = std::make_shared(LogicalLocation{0, 0, 0}); - _sector->physicalLocation = std::make_optional( - ptl->physicalCylinder, ptl->physicalHead); - _sector->status = Sector::MISSING; - }; - - newSector(); - beginTrack(); - for (;;) - { - newSector(); - - Fluxmap::Position recordStart = fmr.tell(); - _sector->clock = advanceToNextRecord(); - if (fmr.eof() || !_sector->clock) - break; - - /* Read the sector record. */ - - Fluxmap::Position before = fmr.tell(); - decodeSectorRecord(); - Fluxmap::Position after = fmr.tell(); - pushRecord(before, after); - - if (_sector->status != Sector::DATA_MISSING) - { - _sector->position = before.bytes; - _sector->dataStartTime = before.ns(); - _sector->dataEndTime = after.ns(); - } - else - { - /* The data is in a separate record. */ - - _sector->headerStartTime = before.ns(); - _sector->headerEndTime = after.ns(); - - _sector->clock = advanceToNextRecord(); - if (fmr.eof() || !_sector->clock) - break; - - before = fmr.tell(); - decodeDataRecord(); - _sector->data = _sector->data.slice(0, _ltl->sectorSize); - after = fmr.tell(); - - if (_sector->status != Sector::DATA_MISSING) - { - _sector->position = before.bytes; - _sector->dataStartTime = before.ns(); - _sector->dataEndTime = after.ns(); - pushRecord(before, after); - } - else - { - fmr.skipToEvent(F_BIT_PULSE); - resetFluxDecoder(); - } - } - - if (_sector->status != Sector::MISSING) - _trackdata->allSectors.push_back(_sector); - } - - return _trackdata; -} - -void Decoder::pushRecord( - const Fluxmap::Position& start, const Fluxmap::Position& end) -{ - Fluxmap::Position here = _fmr->tell(); - - auto record = std::make_shared(); - _trackdata->records.push_back(record); - _sector->records.push_back(record); - - record->position = start.bytes; - record->startTime = start.ns(); - record->endTime = end.ns(); - record->clock = _sector->clock; - - record->rawData = toBytes(_recordBits); - _recordBits.clear(); -} - -void Decoder::resetFluxDecoder() -{ - _decoder.reset(new FluxDecoder(_fmr, _sector->clock, _config)); -} - -nanoseconds_t Decoder::seekToPattern(const FluxMatcher& pattern) -{ - nanoseconds_t clock = _fmr->seekToPattern(pattern); - _decoder.reset(new FluxDecoder(_fmr, clock, _config)); - return clock; -} - -void Decoder::seekToIndexMark() -{ - _fmr->skipToEvent(F_BIT_PULSE); - _fmr->seekToIndexMark(); -} - -std::vector Decoder::readRawBits(unsigned count) -{ - auto bits = _decoder->readBits(count); - _recordBits.insert(_recordBits.end(), bits.begin(), bits.end()); - return bits; -} - -uint8_t Decoder::readRaw8() -{ - return toBytes(readRawBits(8)).reader().read_8(); -} - -uint16_t Decoder::readRaw16() -{ - return toBytes(readRawBits(16)).reader().read_be16(); -} - -uint32_t Decoder::readRaw20() -{ - std::vector bits(4); - for (bool b : readRawBits(20)) - bits.push_back(b); - - return toBytes(bits).reader().read_be24(); -} - -uint32_t Decoder::readRaw24() -{ - return toBytes(readRawBits(24)).reader().read_be24(); -} - -uint32_t Decoder::readRaw32() -{ - return toBytes(readRawBits(32)).reader().read_be32(); -} - -uint64_t Decoder::readRaw48() -{ - return toBytes(readRawBits(48)).reader().read_be48(); -} - -uint64_t Decoder::readRaw64() -{ - return toBytes(readRawBits(64)).reader().read_be64(); -} diff --git a/lib/decoders/decoders.h b/lib/decoders/decoders.h deleted file mode 100644 index 83ef883b7..000000000 --- a/lib/decoders/decoders.h +++ /dev/null @@ -1,118 +0,0 @@ -#ifndef DECODERS_H -#define DECODERS_H - -#include "lib/core/bytes.h" -#include "lib/data/sector.h" -#include "lib/data/fluxmapreader.h" -#include "lib/decoders/fluxdecoder.h" - -class Config; -class DecoderProto; -class FluxMatcher; -class Fluxmap; -class FluxmapReader; -class PhysicalTrackLayout; -class RawBits; -class Sector; - -#include "lib/data/disk.h" - -extern void setDecoderManualClockRate(double clockrate_us); - -extern Bytes decodeFmMfm(std::vector::const_iterator start, - std::vector::const_iterator end); -extern void encodeMfm(std::vector& bits, - unsigned& cursor, - const Bytes& input, - bool& lastBit); -extern void encodeFm( - std::vector& bits, unsigned& cursor, const Bytes& input); -extern Bytes encodeMfm(const Bytes& input, bool& lastBit); - -static inline Bytes decodeFmMfm(const std::vector bits) -{ - return decodeFmMfm(bits.begin(), bits.end()); -} - -class Decoder -{ -public: - Decoder(const DecoderProto& config): _config(config) {} - - virtual ~Decoder() {} - - static std::unique_ptr create(Config& config); - static std::unique_ptr create(const DecoderProto& config); - -public: - enum RecordType - { - SECTOR_RECORD, - DATA_RECORD, - UNKNOWN_RECORD - }; - -public: - std::shared_ptr decodeToSectors( - std::shared_ptr fluxmap, - const std::shared_ptr& ptl); - - void pushRecord( - const Fluxmap::Position& start, const Fluxmap::Position& end); - - void resetFluxDecoder(); - std::vector readRawBits(unsigned count); - uint8_t readRaw8(); - uint16_t readRaw16(); - uint32_t readRaw20(); - uint32_t readRaw24(); - uint32_t readRaw32(); - uint64_t readRaw48(); - uint64_t readRaw64(); - - Fluxmap::Position tell() - { - return _fmr->tell(); - } - - void rewind() - { - _fmr->rewind(); - } - - void seek(const Fluxmap::Position& pos) - { - return _fmr->seek(pos); - } - - nanoseconds_t seekToPattern(const FluxMatcher& pattern); - void seekToIndexMark(); - - bool eof() const - { - return _fmr->eof(); - } - - nanoseconds_t getFluxmapDuration() const - { - return _fmr->getDuration(); - } - -protected: - virtual void beginTrack() {}; - virtual nanoseconds_t advanceToNextRecord() = 0; - virtual void decodeSectorRecord() = 0; - virtual void decodeDataRecord() {}; - - const DecoderProto& _config; - std::shared_ptr _ltl; - std::shared_ptr _trackdata; - std::shared_ptr _sector; - std::unique_ptr _decoder; - std::vector _recordBits; - -private: - FluxmapReader* _fmr = nullptr; -}; - -#endif diff --git a/lib/decoders/decoders.proto b/lib/decoders/decoders.proto deleted file mode 100644 index df448ec56..000000000 --- a/lib/decoders/decoders.proto +++ /dev/null @@ -1,73 +0,0 @@ -syntax = "proto2"; - -import "arch/agat/agat.proto"; -import "arch/aeslanier/aeslanier.proto"; -import "arch/amiga/amiga.proto"; -import "arch/apple2/apple2.proto"; -import "arch/brother/brother.proto"; -import "arch/c64/c64.proto"; -import "arch/f85/f85.proto"; -import "arch/fb100/fb100.proto"; -import "arch/ibm/ibm.proto"; -import "arch/macintosh/macintosh.proto"; -import "arch/micropolis/micropolis.proto"; -import "arch/mx/mx.proto"; -import "arch/northstar/northstar.proto"; -import "arch/rolandd20/rolandd20.proto"; -import "arch/smaky6/smaky6.proto"; -import "arch/tartu/tartu.proto"; -import "arch/tids990/tids990.proto"; -import "arch/victor9k/victor9k.proto"; -import "arch/zilogmcz/zilogmcz.proto"; -import "lib/fluxsink/fluxsink.proto"; -import "lib/config/common.proto"; - -//NEXT: 33 -message DecoderProto { - optional double pulse_debounce_threshold = 1 [default = 0.30, - (help) = "ignore pulses with intervals shorter than this, in fractions of a clock"]; - optional double bit_error_threshold = 2 [default = 0.40, - (help) = "amount of error to tolerate in pulse timing, in fractions of a clock"]; - optional double minimum_clock_us = 4 [default = 0.75, - (help) = "refuse to detect clocks shorter than this, to avoid false positives"]; - - optional double pll_adjust = 25 [default = 0.04]; - optional double pll_phase = 26 [default = 0.60]; - optional double flux_scale = 27 [default = 1.0]; - - oneof format { - AesLanierDecoderProto aeslanier = 7; - AgatDecoderProto agat = 28; - AmigaDecoderProto amiga = 8; - Apple2DecoderProto apple2 = 13; - BrotherDecoderProto brother = 6; - Commodore64DecoderProto c64 = 9; - F85DecoderProto f85 = 10; - Fb100DecoderProto fb100 = 11; - IbmDecoderProto ibm = 5; - MacintoshDecoderProto macintosh = 12; - MicropolisDecoderProto micropolis = 14; - MxDecoderProto mx = 15; - NorthstarDecoderProto northstar = 24; - RolandD20DecoderProto rolandd20 = 31; - Smaky6DecoderProto smaky6 = 30; - TartuDecoderProto tartu = 32; - Tids990DecoderProto tids990 = 16; - Victor9kDecoderProto victor9k = 17; - ZilogMczDecoderProto zilogmcz = 18; - } - - optional FluxSinkProto copy_flux_to = 19 - [(help) = "while decoding, write a copy of the flux here"]; - optional bool dump_records = 20 [default = false, - (help) = "if set, then dump the parsed but undecoded disk records"]; - optional bool dump_sectors = 21 [default = false, - (help) = "if set, then dump the decoded sectors to this file"]; - optional int32 retries = 22 [default = 5, - (help) = "how many times to retry each track in the event of a read failure"]; - optional string write_csv_to = 23 - [(help) = "if set, write a CSV report of the disk state"]; - optional bool skip_unnecessary_tracks = 29 [default = true, - (help) = "don't read tracks if we already have all necessary sectors"]; -} - diff --git a/lib/decoders/fluxdecoder.cc b/lib/decoders/fluxdecoder.cc deleted file mode 100644 index ddc18dc32..000000000 --- a/lib/decoders/fluxdecoder.cc +++ /dev/null @@ -1,118 +0,0 @@ -#include "lib/core/globals.h" -#include "lib/data/fluxmap.h" -#include "lib/data/fluxmapreader.h" -#include "lib/decoders/fluxdecoder.h" -#include "lib/decoders/decoders.pb.h" - -/* This is a port of the samdisk code: - * - * https://github.com/simonowen/samdisk/blob/master/src/FluxDecoder.cpp - * - * I'm not actually terribly sure how it works, but it does, and much better - * than my code. - */ - -FluxDecoder::FluxDecoder( - FluxmapReader* fmr, nanoseconds_t bitcell, const DecoderProto& config): - _fmr(fmr), - _pll_phase(config.pll_phase()), - _pll_adjust(config.pll_adjust()), - _flux_scale(config.flux_scale()), - _clock(bitcell), - _clock_centre(bitcell), - _clock_min(bitcell * (1.0 - _pll_adjust)), - _clock_max(bitcell * (1.0 + _pll_adjust)), - _flux(0), - _leading_zeroes(fmr->tell().zeroes) -{ -} - -bool FluxDecoder::readBit() -{ - if (_leading_zeroes > 0) - { - _leading_zeroes--; - return false; - } - else if (_leading_zeroes == 0) - { - _leading_zeroes--; - return true; - } - - while (!_fmr->eof() && (_flux < (_clock / 2))) - { - _flux += nextFlux() * _flux_scale; - ; - _clocked_zeroes = 0; - } - - _flux -= _clock; - if (_flux >= (_clock / 2)) - { - _clocked_zeroes++; - _goodbits++; - return false; - } - - /* PLL adjustment: change the clock frequency according to the phase - * mismatch */ - if (_clocked_zeroes <= 3) - { - /* In sync: adjust base clock */ - - _clock += _flux * _pll_adjust; - } - else - { - /* Out of sync: adjust the base clock back towards the centre */ - - _clock += (_clock_centre - _clock) * _pll_adjust; - - /* We require 256 good bits before reporting another sync loss event. */ - - if (_goodbits >= 256) - _sync_lost = true; - _goodbits = 0; - } - - /* Clamp the clock's adjustment range. */ - - _clock = std::min(std::max(_clock_min, _clock), _clock_max); - - /* I'm not sure what this does, but the original comment is: - * Authentic PLL: Do not snap the timing window to each flux transition - */ - - _flux = _flux * (1.0 - _pll_phase); - - _goodbits++; - return true; -} - -std::vector FluxDecoder::readBits(unsigned count) -{ - std::vector result; - while (!_fmr->eof() && count--) - { - bool b = readBit(); - result.push_back(b); - } - return result; -} - -std::vector FluxDecoder::readBits(const Fluxmap::Position& until) -{ - std::vector result; - while (!_fmr->eof() && (_fmr->tell().bytes < until.bytes)) - { - bool b = readBit(); - result.push_back(b); - } - return result; -} - -nanoseconds_t FluxDecoder::nextFlux() -{ - return _fmr->readInterval(_clock_centre) * NS_PER_TICK; -} diff --git a/lib/decoders/fluxdecoder.h b/lib/decoders/fluxdecoder.h deleted file mode 100644 index 539338f06..000000000 --- a/lib/decoders/fluxdecoder.h +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef FLUXDECODER_H -#define FLUXDECODER_H - -class FluxmapReader; - -class FluxDecoder -{ -public: - FluxDecoder( - FluxmapReader* fmr, nanoseconds_t bitcell, const DecoderProto& config); - - bool readBit(); - std::vector readBits(unsigned count); - std::vector readBits(const Fluxmap::Position& until); - - std::vector readBits() - { - return readBits(UINT_MAX); - } - -private: - nanoseconds_t nextFlux(); - -private: - FluxmapReader* _fmr; - double _pll_phase; - double _pll_adjust; - double _flux_scale; - nanoseconds_t _clock = 0; - nanoseconds_t _clock_centre; - nanoseconds_t _clock_min; - nanoseconds_t _clock_max; - nanoseconds_t _flux = 0; - unsigned _clocked_zeroes = 0; - unsigned _goodbits = 0; - bool _index = false; - bool _sync_lost = false; - int _leading_zeroes; -}; - -#endif diff --git a/lib/decoders/fmmfm.cc b/lib/decoders/fmmfm.cc deleted file mode 100644 index 6a75178c2..000000000 --- a/lib/decoders/fmmfm.cc +++ /dev/null @@ -1,122 +0,0 @@ -#include "lib/core/globals.h" -#include "lib/decoders/decoders.h" - -Bytes decodeFmMfm( - std::vector::const_iterator ii, std::vector::const_iterator end) -{ - /* - * FM is dumb as rocks, consisting on regular clock pulses with data pulses - * in the gaps. 0x00 is: - * - * X-X-X-X-X-X-X-X- - * - * 0xff is: - * - * XXXXXXXXXXXXXXXX - * - * So we just need to extract all the odd bits. - * - * MFM and M2FM are slightly more complicated, where the first bit of each - * pair can be either 0 or 1... but the second bit is always the data bit, - * and at this point we simply don't care what the first bit is, so - * decoding MFM uses just the same code! - */ - - Bytes bytes; - ByteWriter bw(bytes); - - int bitcount = 0; - uint8_t fifo = 0; - - while (ii != end) - { - ii++; /* skip clock bit */ - if (ii == end) - break; - fifo = (fifo << 1) | *ii++; - - bitcount++; - if (bitcount == 8) - { - bw.write_8(fifo); - bitcount = 0; - } - } - - if (bitcount != 0) - { - fifo <<= 8 - bitcount; - bw.write_8(fifo); - } - - return bytes; -} - -void encodeFm(std::vector& bits, unsigned& cursor, const Bytes& input) -{ - if (bits.size() == 0) - return; - unsigned len = bits.size() - 1; - - for (uint8_t b : input) - { - for (int i = 0; i < 8; i++) - { - bool bit = b & 0x80; - b <<= 1; - - if (cursor >= len) - return; - - bits[cursor++] = true; - bits[cursor++] = bit; - } - } -} - -void encodeMfm(std::vector& bits, - unsigned& cursor, - const Bytes& input, - bool& lastBit) -{ - if (bits.size() == 0) - return; - unsigned len = bits.size() - 1; - - for (uint8_t b : input) - { - for (int i = 0; i < 8; i++) - { - bool bit = b & 0x80; - b <<= 1; - - if (cursor >= len) - return; - - bits[cursor++] = !lastBit && !bit; - bits[cursor++] = bit; - lastBit = bit; - } - } -} - -Bytes encodeMfm(const Bytes& input, bool& lastBit) -{ - ByteReader br(input); - BitReader bitr(br); - Bytes b; - ByteWriter bw(b); - BitWriter bitw(bw); - - while (!bitr.eof()) - { - uint8_t bit = bitr.get(); - - bitw.push(!lastBit && !bit); - bitw.push(bit); - lastBit = bit; - } - - bitw.flush(); - return b; -} diff --git a/lib/decoders/rawbits.h b/lib/decoders/rawbits.h deleted file mode 100644 index abb0a5c7f..000000000 --- a/lib/decoders/rawbits.h +++ /dev/null @@ -1,46 +0,0 @@ -#ifndef RAWBITS_H -#define RAWBITS_H - -class RawBits -{ -public: - RawBits(std::unique_ptr> bits, - std::unique_ptr> indices): - _bits(std::move(bits)), - _indices(std::move(indices)) - { - } - - typedef std::vector::const_iterator const_iterator; - - const_iterator begin() const - { - return _bits->begin(); - } - - const_iterator end() const - { - return _bits->end(); - } - - size_t size() const - { - return _bits->size(); - } - - const bool operator[](size_t pos) const - { - return _bits->at(pos); - } - - const std::vector indices() const - { - return *_indices; - } - -private: - std::unique_ptr> _bits; - std::unique_ptr> _indices; -}; - -#endif diff --git a/lib/encoders/encoders.proto b/lib/encoders/encoders.proto deleted file mode 100644 index 6dfd6cbe7..000000000 --- a/lib/encoders/encoders.proto +++ /dev/null @@ -1,33 +0,0 @@ -syntax = "proto2"; - -import "arch/agat/agat.proto"; -import "arch/amiga/amiga.proto"; -import "arch/apple2/apple2.proto"; -import "arch/brother/brother.proto"; -import "arch/c64/c64.proto"; -import "arch/ibm/ibm.proto"; -import "arch/macintosh/macintosh.proto"; -import "arch/micropolis/micropolis.proto"; -import "arch/northstar/northstar.proto"; -import "arch/tartu/tartu.proto"; -import "arch/tids990/tids990.proto"; -import "arch/victor9k/victor9k.proto"; - -message EncoderProto -{ - oneof format - { - IbmEncoderProto ibm = 3; - BrotherEncoderProto brother = 4; - AmigaEncoderProto amiga = 5; - MacintoshEncoderProto macintosh = 6; - Tids990EncoderProto tids990 = 7; - Commodore64EncoderProto c64 = 8; - NorthstarEncoderProto northstar = 9; - MicropolisEncoderProto micropolis = 10; - Victor9kEncoderProto victor9k = 11; - Apple2EncoderProto apple2 = 12; - AgatEncoderProto agat = 13; - TartuEncoderProto tartu = 14; - } -} diff --git a/lib/external/a2r.h b/lib/external/a2r.h deleted file mode 100644 index 8730be096..000000000 --- a/lib/external/a2r.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef A2R_H -#define A2R_H - -// The canonical reference for the A2R format is: -// https://applesaucefdc.com/a2r2-reference/ All data is stored little-endian - -// Note: The first chunk begins at byte offset 8, not 12 as given in a2r2 -// reference version 2.0.1 - -#define A2R_CHUNK_INFO (0x4F464E49) -#define A2R_CHUNK_STRM (0x4D525453) -#define A2R_CHUNK_META (0x4154454D) - -#define A2R_INFO_CHUNK_VERSION (1) - -enum A2RDiskType -{ - A2R_DISK_525 = 1, - A2R_DISK_35 = 2, -}; - -enum A2RCaptureType -{ - A2R_TIMING = 1, - A2R_BITS = 2, - A2R_XTIMING = 3, -}; - -extern const uint8_t a2r2_fileheader[8]; - -#define A2R_NS_PER_TICK (125) - -#endif diff --git a/lib/external/fl2.proto b/lib/external/fl2.proto deleted file mode 100644 index dadba35fe..000000000 --- a/lib/external/fl2.proto +++ /dev/null @@ -1,49 +0,0 @@ -syntax = "proto2"; - -import "google/protobuf/descriptor.proto"; - -extend google.protobuf.FieldOptions -{ - optional bool isflux = 60000 [default = false]; -} - -enum FluxMagic { - MAGIC = 0x466c7578; -} - -enum FluxFileVersion { - VERSION_1 = 1; - VERSION_2 = 2; -} - -message TrackFluxProto { - optional int32 track = 1; - optional int32 head = 2; - repeated bytes flux = 3 [(isflux) = true]; -} - -enum DriveType { - DRIVETYPE_UNKNOWN = 0; - DRIVETYPE_40TRACK = 1; - DRIVETYPE_80TRACK = 2; - DRIVETYPE_APPLE2 = 3; -} - -enum FormatType { - FORMATTYPE_UNKNOWN = 0; - FORMATTYPE_40TRACK = 1; - FORMATTYPE_80TRACK = 2; -} - -// NEXT: 8 -message FluxFileProto { - optional int32 magic = 1; - optional FluxFileVersion version = 2; - repeated TrackFluxProto track = 3; - optional double rotational_period_ms = 4; - optional DriveType drive_type = 6 [default = DRIVETYPE_UNKNOWN]; - optional FormatType format_type = 7 [default = FORMATTYPE_UNKNOWN]; - - reserved 5; -} - diff --git a/lib/fluxsink/fluxsink.proto b/lib/fluxsink/fluxsink.proto deleted file mode 100644 index dd08f6582..000000000 --- a/lib/fluxsink/fluxsink.proto +++ /dev/null @@ -1,42 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message HardwareFluxSinkProto {} - -message AuFluxSinkProto { - optional string directory = 1 [default = "aufiles", (help) = "directory to write .au files to"]; - optional bool index_markers = 2 [default = true, (help) = "show index markers in the right-hand channel"]; -} - -message A2RFluxSinkProto { - optional string filename = 1 [default = "flux.a2r", (help) = ".a2r file to write to"]; -} - -message VcdFluxSinkProto { - optional string directory = 1 [default = "vcdfiles", (help) = "directory to write .vcd files to"]; -} - -message ScpFluxSinkProto { - optional string filename = 2 [default = "flux.scp", (help) = ".scp file to write to"]; - optional bool align_with_index = 3 [default = false, (help) = "discard data before the first index pulse"]; - optional int32 type_byte = 4 [default = 0xff, (help) = "set the SCP disk type byte"]; -} - -message Fl2FluxSinkProto { - optional string filename = 1 [default = "flux.fl2", (help) = ".fl2 file to write to"]; -} - -// Next: 10 -message FluxSinkProto { - optional FluxSourceSinkType type = 9 - [default = FLUXTYPE_NOT_SET, (help) = "flux sink type"]; - - optional HardwareFluxSinkProto drive = 2; - optional A2RFluxSinkProto a2r = 8; - optional AuFluxSinkProto au = 3; - optional VcdFluxSinkProto vcd = 4; - optional ScpFluxSinkProto scp = 5; - optional Fl2FluxSinkProto fl2 = 6; -} - diff --git a/lib/fluxsource/a2rfluxsource.cc b/lib/fluxsource/a2rfluxsource.cc deleted file mode 100644 index 3f5117092..000000000 --- a/lib/fluxsource/a2rfluxsource.cc +++ /dev/null @@ -1,203 +0,0 @@ -#include "lib/core/globals.h" -#include "lib/data/fluxmap.h" -#include "lib/data/layout.h" -#include "lib/fluxsource/fluxsource.pb.h" -#include "lib/fluxsource/fluxsource.h" -#include "lib/config/proto.h" -#include "lib/data/locations.h" -#include "lib/core/logger.h" -#include -#include - -struct A2Rv2Flux -{ - std::vector flux; - nanoseconds_t index; -}; - -class A2rv2FluxSourceIterator : public FluxSourceIterator -{ -public: - A2rv2FluxSourceIterator(A2Rv2Flux& flux): _flux(flux) {} - - bool hasNext() const override - { - return _count != _flux.flux.size(); - } - - std::unique_ptr next() override - { - nanoseconds_t index = _flux.index; - Bytes& asbytes = _flux.flux[_count++]; - ByteReader br(asbytes); - - auto fluxmap = std::make_unique(); - while (!br.eof()) - { - unsigned aticks = 0; - for (;;) - { - unsigned i = br.read_8(); - aticks += i; - if (i != 0xff) - break; - } - - nanoseconds_t interval = aticks * 125; - if ((index >= 0) && (index < interval)) - { - fluxmap->appendInterval(index); - fluxmap->appendIndex(); - interval -= index; - } - index -= interval; - - fluxmap->appendInterval(interval / NS_PER_TICK); - fluxmap->appendPulse(); - } - - return fluxmap; - } - -private: - A2Rv2Flux& _flux; - int _count = 0; -}; - -class A2rFluxSource : public FluxSource -{ -public: - A2rFluxSource(const A2rFluxSourceProto& config): _config(config) - { - _data = Bytes::readFromFile(_config.filename()); - ByteReader br(_data); - - switch (br.read_be32()) - { - case 0x41325232: - { - _version = 2; - Bytes info = findChunk("INFO"); - int disktype = info[33]; - if (disktype == 1) - { - /* 5.25" with quarter stepping. */ - _extraConfig.mutable_drive()->set_drive_type( - DRIVETYPE_APPLE2); - } - else - { - /* 3.5". */ - _extraConfig.mutable_drive()->set_drive_type( - DRIVETYPE_80TRACK); - } - - Bytes stream = findChunk("STRM"); - ByteReader bsr(stream); - for (;;) - { - unsigned location = bsr.read_8(); - if (location == 0xff) - break; - auto key = (disktype == 1) - ? CylinderHead{location, 0} - : CylinderHead{location >> 1, location & 1}; - - bsr.skip(1); - uint32_t len = bsr.read_le32(); - nanoseconds_t index = (nanoseconds_t)bsr.read_le32() * 125; - auto it = _v2data.find(key); - if (it == _v2data.end()) - { - _v2data[key] = std::make_unique(); - it = _v2data.find(key); - it->second->index = index; - } - - it->second->flux.push_back(bsr.read(len)); - } - - auto keys = std::views::keys(_v2data); - std::vector chs{keys.begin(), keys.end()}; - unsigned minCylinder = std::ranges::min( - chs | std::views::transform(&CylinderHead::cylinder)); - unsigned maxCylinder = std::ranges::min( - chs | std::views::transform(&CylinderHead::cylinder)); - unsigned minHead = std::ranges::min( - chs | std::views::transform(&CylinderHead::head)); - unsigned maxHead = std::ranges::min( - chs | std::views::transform(&CylinderHead::head)); - log("A2R: reading A2R {} file with {} cylinders and {} head{}", - (disktype == 1) ? "Apple II" - : (disktype == 2) ? "normal" - : "unknown", - maxCylinder - minCylinder + 1, - maxHead - minHead + 1, - (maxHead == minHead) ? "" : "s"); - - _extraConfig.mutable_drive()->set_tracks( - convertCylinderHeadsToString(chs)); - break; - } - - default: - error("unsupported A2R version"); - } - } - -public: - std::unique_ptr readFlux(int track, int head) override - { - switch (_version) - { - case 2: - { - auto i = - _v2data.find(CylinderHead{(unsigned)track, (unsigned)head}); - if (i != _v2data.end()) - return std::make_unique( - *i->second); - else - return std::make_unique(); - } - - default: - error("unsupported A2R version"); - } - } - - void recalibrate() override {} - -private: - Bytes findChunk(Bytes id) - { - uint32_t offset = 8; - while (offset < _data.size()) - { - ByteReader br(_data); - br.seek(offset); - if (br.read(4) == id) - { - uint32_t size = br.read_le32(); - return br.read(size); - } - - offset += br.read_le32() + 8; - } - - error("A2R file missing chunk"); - } - -private: - const A2rFluxSourceProto& _config; - Bytes _data; - std::ifstream _if; - int _version; - std::map> _v2data; -}; - -std::unique_ptr FluxSource::createA2rFluxSource( - const A2rFluxSourceProto& config) -{ - return std::unique_ptr(new A2rFluxSource(config)); -} diff --git a/lib/fluxsource/fluxsource.proto b/lib/fluxsource/fluxsource.proto deleted file mode 100644 index fa377049c..000000000 --- a/lib/fluxsource/fluxsource.proto +++ /dev/null @@ -1,63 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message HardwareFluxSourceProto {} - -message TestPatternFluxSourceProto { - optional double interval_us = 1 [default = 4.0, (help) = "interval between pulses"]; - optional double sequence_length_ms = 2 [default = 166.0, (help) = "length of test sequence"]; -} - -message EraseFluxSourceProto {} - -message KryofluxFluxSourceProto { - optional string directory = 1 [(help) = "path to Kryoflux stream directory"]; -} - -message ScpFluxSourceProto { - optional string filename = 1 [default = "flux.scp", - (help) = ".scp file to read flux from"]; -} - -message A2rFluxSourceProto { - optional string filename = 1 [default = "flux.a2r", - (help) = ".a2r file to read flux from"]; -} - -message CwfFluxSourceProto { - optional string filename = 1 [default = "flux.cwf", - (help) = ".cwf file to read flux from"]; -} - -message DmkFluxSourceProto { - optional string directory = 1 [ - (help) = "path to DMK directory"]; -} - -message Fl2FluxSourceProto { - optional string filename = 1 [default = "flux.fl2", - (help) = ".fl2 file to read flux from"]; -} - -message FlxFluxSourceProto { - optional string directory = 1 [(help) = "path to FLX stream directory"]; -} - -// NEXT: 13 -message FluxSourceProto { - optional FluxSourceSinkType type = 9 - [default = FLUXTYPE_NOT_SET, (help) = "flux source type"]; - - optional A2rFluxSourceProto a2r = 11; - optional CwfFluxSourceProto cwf = 7; - optional DmkFluxSourceProto dmk = 12; - optional EraseFluxSourceProto erase = 4; - optional Fl2FluxSourceProto fl2 = 8; - optional FlxFluxSourceProto flx = 10; - optional HardwareFluxSourceProto drive = 2; - optional KryofluxFluxSourceProto kryoflux = 5; - optional ScpFluxSourceProto scp = 6; - optional TestPatternFluxSourceProto test_pattern = 3; -} - diff --git a/lib/imagereader/imagereader.proto b/lib/imagereader/imagereader.proto deleted file mode 100644 index 6622d8c7b..000000000 --- a/lib/imagereader/imagereader.proto +++ /dev/null @@ -1,42 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message ImgInputOutputProto { - optional bool filesystem_sector_order = 1 [ - (help) = "read/write sector image in filesystem order", - default = false - ]; -} - -message DiskCopyInputProto {} -message ImdInputProto {} -message Jv3InputProto {} -message D64InputProto {} -message NsiInputProto {} -message Td0InputProto {} -message DimInputProto {} -message FdiInputProto {} -message D88InputProto {} -message NfdInputProto {} - -// NEXT_TAG: 14 -message ImageReaderProto -{ - optional string filename = 1 [ (help) = "filename of input sector image" ]; - - optional ImageReaderWriterType type = 13 - [default = IMAGETYPE_NOT_SET, (help) = "input image type"]; - - optional ImgInputOutputProto img = 2; - optional DiskCopyInputProto diskcopy = 3; - optional ImdInputProto imd = 4; - optional Jv3InputProto jv3 = 5; - optional D64InputProto d64 = 6; - optional NsiInputProto nsi = 7; - optional Td0InputProto td0 = 8; - optional DimInputProto dim = 9; - optional FdiInputProto fdi = 10; - optional D88InputProto d88 = 11; - optional NfdInputProto nfd = 12; -} diff --git a/lib/imagewriter/imagewriter.proto b/lib/imagewriter/imagewriter.proto deleted file mode 100644 index 8b8d05a18..000000000 --- a/lib/imagewriter/imagewriter.proto +++ /dev/null @@ -1,82 +0,0 @@ -syntax = "proto2"; - -import "lib/imagereader/imagereader.proto"; -import "lib/config/common.proto"; - -message D64OutputProto {} - -message LDBSOutputProto -{ - enum DataRate - { - RATE_HD = 0; - RATE_DD = 1; - RATE_SD = 2; - RATE_ED = 3; - RATE_GUESS = -1; - } - - enum RecordingMode - { - RECMODE_MFM = 0; - RECMODE_FM = 1; - RECMODE_GCR_MAC = 0x12; - RECMODE_GCR_PRODOS = 0x14; - RECMODE_GCR_LISA = 0x22; - RECMODE_GUESS = -1; - } - - optional DataRate data_rate = 1 - [ default = RATE_GUESS, (help) = "data rate to use in LDBS file" ]; - optional RecordingMode recording_mode = 2 [ - default = RECMODE_GUESS, - (help) = "recording mode to use in LDBS file" - ]; -} - -message DiskCopyOutputProto {} -message NsiOutputProto {} -message RawOutputProto {} -message D88OutputProto {} -message ImdOutputProto -{ - enum DataRate - { - RATE_HD = 0; - RATE_DD = 1; - RATE_SD = 2; - RATE_GUESS = -1; - } - - enum RecordingMode - { - RECMODE_MFM = 0; - RECMODE_FM = 1; - RECMODE_GUESS = -1; - } - optional DataRate data_rate = 1 - [ default = RATE_GUESS, (help) = "data rate to use in IMD file" ]; - optional RecordingMode recording_mode = 2 [ - default = RECMODE_GUESS, - (help) = "recording mode (FM or MFM encoding) to use in IMD file" - ]; - optional string comment = 3 [ (help) = "comment to set in IMD file" ]; -} - -// NEXT_TAG: 12 -message ImageWriterProto -{ - optional string filename = 1 [ (help) = "filename of output sector image" ]; - - optional ImageReaderWriterType type = 10 - [ default = IMAGETYPE_NOT_SET, (help) = "image writer type" ]; - - optional ImgInputOutputProto img = 2; - optional D64OutputProto d64 = 3; - optional LDBSOutputProto ldbs = 4; - optional DiskCopyOutputProto diskcopy = 5; - optional NsiOutputProto nsi = 6; - optional RawOutputProto raw = 7; - optional D88OutputProto d88 = 8; - optional ImdOutputProto imd = 9; -} diff --git a/lib/usb/greaseweazleusb.cc b/lib/usb/greaseweazleusb.cc deleted file mode 100644 index 2f6817b30..000000000 --- a/lib/usb/greaseweazleusb.cc +++ /dev/null @@ -1,438 +0,0 @@ -#include "lib/core/globals.h" -#include "protocol.h" -#include "lib/data/fluxmap.h" -#include "lib/core/bytes.h" -#include "lib/usb/usb.pb.h" -#include "lib/external/greaseweazle.h" -#include "lib/usb/serial.h" -#include "lib/usb/usb.h" -#include - -static const char* gw_error(int e) -{ - switch (e) - { - case ACK_OKAY: - return "OK"; - case ACK_BAD_COMMAND: - return "Bad command"; - case ACK_NO_INDEX: - return "No index"; - case ACK_NO_TRK0: - return "No track 0"; - case ACK_FLUX_OVERFLOW: - return "Overflow"; - case ACK_FLUX_UNDERFLOW: - return "Underflow"; - case ACK_WRPROT: - return "Write protected"; - case ACK_NO_UNIT: - return "No unit"; - case ACK_NO_BUS: - return "No bus"; - case ACK_BAD_UNIT: - return "Invalid unit"; - case ACK_BAD_PIN: - return "Invalid pin"; - case ACK_BAD_CYLINDER: - return "Invalid track"; - default: - return "Unknown error"; - } -} - -static uint32_t ss_rand_next(uint32_t x) -{ - return (x & 1) ? (x >> 1) ^ 0x80000062 : x >> 1; -} - -class GreaseweazleUsb : public USB -{ -private: - uint32_t read_28() - { - uint8_t buffer[4]; - _serial->read(buffer, sizeof(buffer)); - - return ((buffer[0] & 0xfe) >> 1) | ((buffer[1] & 0xfe) << 6) | - ((buffer[2] & 0xfe) << 13) | ((buffer[3] & 0xfe) << 20); - } - - void do_command(const Bytes& command) - { - _serial->write(command); - - uint8_t buffer[2]; - _serial->read(buffer, sizeof(buffer)); - - if (buffer[0] != command[0]) - error( - "command returned garbage (0x{:x} != 0x{:x} with status " - "0x{:x})", - buffer[0], - command[0], - buffer[1]); - if (buffer[1]) - error("Greaseweazle error: {}", gw_error(buffer[1])); - } - -public: - GreaseweazleUsb(const std::string& port, const GreaseweazleProto& config): - _serial(SerialPort::openSerialPort(port)), - _config(config) - { - int version = getVersion(); - if (version >= 29) - _version = V29; - else if (version >= 24) - _version = V24; - else if (version == 22) - _version = V22; - else - { - error( - "only Greaseweazle firmware versions 22 and 24 or above are " - "currently " - "supported, but you have version {}. Please file a bug.", - version); - } - - /* Twiddle the baud rate, which indicates to the Greaseweazle that the - * data stream has been reset. */ - - _serial->setBaudRate(10000); - usleep(100000); - _serial->setBaudRate(9600); - - /* Configure the hardware. */ - - do_command({CMD_SET_BUS_TYPE, 3, (uint8_t)config.bus_type()}); - } - -private: - int getVersion() - { - do_command({CMD_GET_INFO, 3, GETINFO_FIRMWARE}); - - Bytes response = _serial->readBytes(32); - ByteReader br(response); - - br.seek(4); - nanoseconds_t freq = br.read_le32(); - _clock = 1000000000 / freq; - - br.seek(0); - return br.read_be16(); - } - -public: - void seek(int track) override - { - do_command({CMD_SEEK, 3, (uint8_t)track}); - } - - nanoseconds_t getRotationalPeriod(int hardSectorCount) override - { - if (hardSectorCount != 0) - error("hard sectors are currently unsupported on the Greaseweazle"); - - /* The Greaseweazle doesn't have a command to fetch the period directly, - * so we have to do a flux read. */ - - switch (_version) - { - case V22: - do_command({CMD_READ_FLUX, 2}); - break; - - case V24: - case V29: - { - Bytes cmd(8); - cmd.writer() - .write_8(CMD_READ_FLUX) - .write_8(cmd.size()) - .write_le32(0) // ticks default value (guessed) - .write_le16(2); // revolutions - do_command(cmd); - } - } - - uint32_t ticks_gw = 0; - uint32_t firstindex = ~0; - uint32_t secondindex = ~0; - for (;;) - { - uint8_t b = _serial->readByte(); - if (!b) - break; - - if (b == 255) - { - switch (_serial->readByte()) - { - case FLUXOP_INDEX: - { - uint32_t index = read_28() + ticks_gw; - if (firstindex == ~0) - firstindex = index; - else if (secondindex == ~0) - secondindex = index; - break; - } - - case FLUXOP_SPACE: - ticks_gw += read_28(); - break; - - default: - error("bad opcode in Greaseweazle stream"); - } - } - else - { - if (b < 250) - ticks_gw += b; - else - { - int delta = 250 + (b - 250) * 255 + _serial->readByte() - 1; - ticks_gw += delta; - } - } - } - - if (secondindex == ~0) - error( - "unable to determine disk rotational period (is a disk in the " - "drive?)"); - do_command({CMD_GET_FLUX_STATUS, 2}); - - _revolutions = (nanoseconds_t)(secondindex - firstindex) * _clock; - return _revolutions; - } - - void testBulkWrite() override - { - std::cout << "Writing data: " << std::flush; - const int LEN = 10 * 1024 * 1024; - Bytes cmd; - switch (_version) - { - case V22: - case V24: - { - cmd.resize(6); - ByteWriter bw(cmd); - bw.write_8(CMD_SINK_BYTES); - bw.write_8(cmd.size()); - bw.write_le32(LEN); - break; - } - - case V29: - { - cmd.resize(10); - ByteWriter bw(cmd); - bw.write_8(CMD_SINK_BYTES); - bw.write_8(cmd.size()); - bw.write_le32(LEN); - bw.write_le32(0); // seed - break; - } - } - do_command(cmd); - - Bytes junk(LEN); - uint32_t seed = 0; - for (int i = 0; i < LEN; i++) - { - junk[i] = seed; - seed = ss_rand_next(seed); - } - double start_time = getCurrentTime(); - _serial->write(junk); - _serial->readBytes(1); - double elapsed_time = getCurrentTime() - start_time; - - std::cout << fmt::format( - "transferred {} bytes from PC -> device in {} ms ({} kb/s)\n", - LEN, - int(elapsed_time * 1000.0), - int((LEN / 1024.0) / elapsed_time)); - } - - void testBulkRead() override - { - std::cout << "Reading data: " << std::flush; - const int LEN = 10 * 1024 * 1024; - Bytes cmd; - switch (_version) - { - case V22: - case V24: - { - cmd.resize(6); - ByteWriter bw(cmd); - bw.write_8(CMD_SOURCE_BYTES); - bw.write_8(cmd.size()); - bw.write_le32(LEN); - break; - } - - case V29: - { - cmd.resize(10); - ByteWriter bw(cmd); - bw.write_8(CMD_SOURCE_BYTES); - bw.write_8(cmd.size()); - bw.write_le32(LEN); - bw.write_le32(0); // seed - break; - } - } - do_command(cmd); - - double start_time = getCurrentTime(); - _serial->readBytes(LEN); - double elapsed_time = getCurrentTime() - start_time; - - std::cout << fmt::format( - "transferred {} bytes from device -> PC in {} ms ({} kb/s)\n", - LEN, - int(elapsed_time * 1000.0), - int((LEN / 1024.0) / elapsed_time)); - } - - Bytes read(int side, - bool synced, - nanoseconds_t readTime, - nanoseconds_t hardSectorThreshold) override - { - if (hardSectorThreshold != 0) - error("hard sectors are currently unsupported on the Greaseweazle"); - - do_command({CMD_HEAD, 3, (uint8_t)side}); - - switch (_version) - { - case V22: - { - int revolutions = (readTime + _revolutions - 1) / _revolutions; - Bytes cmd(4); - cmd.writer() - .write_8(CMD_READ_FLUX) - .write_8(cmd.size()) - .write_le32(revolutions + (synced ? 1 : 0)); - do_command(cmd); - break; - } - - case V24: - case V29: - { - Bytes cmd(8); - cmd.writer() - .write_8(CMD_READ_FLUX) - .write_8(cmd.size()) - .write_le32( - (readTime + (synced ? _revolutions : 0)) / _clock) - .write_le16(0); - do_command(cmd); - } - } - - Bytes buffer; - ByteWriter bw(buffer); - for (;;) - { - uint8_t b = _serial->readByte(); - if (!b) - break; - bw.write_8(b); - } - - do_command({CMD_GET_FLUX_STATUS, 2}); - - Bytes fldata = greaseweazleToFluxEngine(buffer, _clock); - if (synced) - fldata = stripPartialRotation(fldata); - return fldata; - } - - void write(int side, - const Bytes& fldata, - nanoseconds_t hardSectorThreshold) override - { - if (hardSectorThreshold != 0) - error("hard sectors are currently unsupported on the Greaseweazle"); - - do_command({CMD_HEAD, 3, (uint8_t)side}); - switch (_version) - { - case V22: - do_command({CMD_WRITE_FLUX, 3, 1}); - break; - - case V24: - case V29: - do_command({CMD_WRITE_FLUX, 4, 1, 1}); - break; - } - _serial->write(fluxEngineToGreaseweazle(fldata, _clock)); - _serial->readByte(); /* synchronise */ - - do_command({CMD_GET_FLUX_STATUS, 2}); - } - - void erase(int side, nanoseconds_t hardSectorThreshold) override - { - if (hardSectorThreshold != 0) - error("hard sectors are currently unsupported on the Greaseweazle"); - - do_command({CMD_HEAD, 3, (uint8_t)side}); - - Bytes cmd(6); - ByteWriter bw(cmd); - bw.write_8(CMD_ERASE_FLUX); - bw.write_8(cmd.size()); - bw.write_le32(200e6 / _clock); - do_command(cmd); - _serial->readByte(); /* synchronise */ - - do_command({CMD_GET_FLUX_STATUS, 2}); - } - - void setDrive(int drive, bool high_density, int index_mode) override - { - do_command({CMD_SELECT, 3, (uint8_t)drive}); - do_command({CMD_MOTOR, 4, (uint8_t)drive, 1}); - do_command({CMD_SET_PIN, 4, 2, (uint8_t)(high_density ? 1 : 0)}); - } - - void measureVoltages(struct voltages_frame* voltages) override - { - error("unsupported operation on the Greaseweazle"); - } - -private: - enum - { - V22, - V24, - V29 - }; - - std::unique_ptr _serial; - const GreaseweazleProto& _config; - int _version; - nanoseconds_t _clock; - nanoseconds_t _revolutions; -}; - -USB* createGreaseweazleUsb( - const std::string& port, const GreaseweazleProto& config) -{ - return new GreaseweazleUsb(port, config); -} - -// vim: sw=4 ts=4 et diff --git a/lib/usb/usb.proto b/lib/usb/usb.proto deleted file mode 100644 index 1826341b3..000000000 --- a/lib/usb/usb.proto +++ /dev/null @@ -1,32 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message GreaseweazleProto { - enum BusType { /* note that these must match CMD_SET_BUS codes */ - BUSTYPE_INVALID = 0; - IBMPC = 1; - SHUGART = 2; - APPLE2 = 3; - }; - - optional string port = 1 - [(help) = "Greaseweazle serial port to use"]; - optional BusType bus_type = 2 - [(help) = "which FDD bus type is in use", default = IBMPC]; -} - -message ApplesauceProto { - optional string port = 1 - [(help) = "Applesauce serial port to use"]; - optional bool verbose = 2 - [(help) = "Enable verbose protocol logging", default = false]; -} - -message UsbProto { - optional string serial = 1 - [(help) = "serial number of FluxEngine or Greaseweazle device to use"]; - - optional GreaseweazleProto greaseweazle = 2 [(help) = "Greaseweazle-specific options"]; - optional ApplesauceProto applesauce = 3 [(help) = "Applesauce-specific options"]; -} diff --git a/lib/usb/usbfinder.cc b/lib/usb/usbfinder.cc deleted file mode 100644 index 4453ccb4b..000000000 --- a/lib/usb/usbfinder.cc +++ /dev/null @@ -1,85 +0,0 @@ -#include "lib/core/globals.h" -#include "lib/config/flags.h" -#include "lib/usb/usb.h" -#include "lib/core/bytes.h" -#include "lib/usb/usbfinder.h" -#include "lib/external/applesauce.h" -#include "lib/external/greaseweazle.h" -#include "protocol.h" -#include "libusbp.hpp" - -static const std::set VALID_DEVICES = { - GREASEWEAZLE_ID, FLUXENGINE_ID, APPLESAUCE_ID}; - -static const std::string get_serial_number(const libusbp::device& device) -{ - try - { - return device.get_serial_number(); - } - catch (const libusbp::error& e) - { - if (e.has_code(LIBUSBP_ERROR_NO_SERIAL_NUMBER)) - return "n/a"; - throw; - } -} - -std::vector> findUsbDevices() -{ - try - { - std::vector> candidates; - for (const auto& it : libusbp::list_connected_devices()) - { - auto candidate = std::make_unique(); - candidate->device = it; - - uint32_t id = (it.get_vendor_id() << 16) | it.get_product_id(); - if (VALID_DEVICES.find(id) != VALID_DEVICES.end()) - { - candidate->id = id; - candidate->serial = get_serial_number(it); - - if (id == GREASEWEAZLE_ID) - candidate->type = DEVICE_GREASEWEAZLE; - else if (id == APPLESAUCE_ID) - candidate->type = DEVICE_APPLESAUCE; - else if (id == FLUXENGINE_ID) - candidate->type = DEVICE_FLUXENGINE; - - if ((id == GREASEWEAZLE_ID) || (id == APPLESAUCE_ID)) - { - libusbp::serial_port port(candidate->device); - candidate->serialPort = port.get_name(); - } - - candidates.push_back(std::move(candidate)); - } - } - - return candidates; - } - catch (const libusbp::error& e) - { - error("USB error: {}", e.message()); - } -} - -std::string getDeviceName(DeviceType type) -{ - switch (type) - { - case DEVICE_GREASEWEAZLE: - return "Greaseweazle"; - - case DEVICE_FLUXENGINE: - return "FluxEngine"; - - case DEVICE_APPLESAUCE: - return "Applesauce"; - - default: - return "unknown"; - } -} diff --git a/lib/usb/usbfinder.h b/lib/usb/usbfinder.h deleted file mode 100644 index 435c7e3ba..000000000 --- a/lib/usb/usbfinder.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifndef USBSERIAL_H -#define USBSERIAL_H - -#include "libusbp_config.h" -#include "libusbp.hpp" - -enum DeviceType -{ - DEVICE_FLUXENGINE, - DEVICE_GREASEWEAZLE, - DEVICE_APPLESAUCE, -}; - -extern std::string getDeviceName(DeviceType type); - -struct CandidateDevice -{ - DeviceType type; - libusbp::device device; - uint32_t id; - std::string serial; - std::string serialPort; -}; - -extern std::vector> findUsbDevices(); - -#endif diff --git a/lib/vfs/vfs.proto b/lib/vfs/vfs.proto deleted file mode 100644 index 6a0c8d82d..000000000 --- a/lib/vfs/vfs.proto +++ /dev/null @@ -1,156 +0,0 @@ -syntax = "proto2"; - -import "lib/config/common.proto"; - -message AcornDfsProto -{ - enum Flavour - { - UNDEFINED = 0; - ACORN_DFS = 1; - } - - optional Flavour flavour = 1 - [ default = ACORN_DFS, (help) = "which flavour of DFS to implement" ]; -} - -message Brother120FsProto {} - -message FatFsProto { - optional uint32 cluster_size = 1 - [ (help) = "cluster size (for new filesystems); 0 to select automatically", - default = 0 ]; - optional uint32 root_directory_entries = 2 - [ (help) = "number of entries in the root directory (for new filesystems); 0 to select automatically", - default = 0 ]; -} - -message CpmFsProto -{ - message Location - { - optional uint32 track = 1 [ (help) = "track number" ]; - optional uint32 side = 2 [ (help) = "side number" ]; - optional uint32 sector = 3 [ (help) = "sector ID" ]; - } - - message Padding - { - optional uint32 amount = 1 - [ (help) = "number of sectors of padding to insert" ]; - optional uint32 every = 2 - [ (help) = "insert padding after this many sectors" ]; - } - - optional Location filesystem_start = 1 - [ (help) = "position of the start of the filesystem" ]; - optional int32 block_size = 2 [ (help) = "allocation block size" ]; - optional int32 dir_entries = 3 - [ (help) = "number of entries in the directory" ]; - optional Padding padding = 4 - [ (help) = "wasted sectors not considered part of the filesystem" ]; -} - -message AmigaFfsProto {} - -message MacHfsProto {} - -message CbmfsProto -{ - optional uint32 directory_track = 1 [ - default = 17, - (help) = "which track the directory is on (zero-based numbering)" - ]; -} - -message ProdosProto {} - -message AppledosProto -{ - optional uint32 filesystem_offset_sectors = 1 [ - default = 0, - (help) = "offset the entire offset up the disk this many sectors" - ]; -} - -message Smaky6FsProto {} - -message PhileProto -{ - optional uint32 block_size = 1 - [ default = 1024, (help) = "Phile filesystem block size" ]; -} - -message LifProto -{ - optional uint32 block_size = 1 - [ default = 256, (help) = "LIF filesystem block size" ]; -} - -message MicrodosProto {} - -// NEXT_TAG: 16 -message ZDosProto -{ - message Location - { - optional uint32 track = 1 [ (help) = "track number" ]; - optional uint32 sector = 3 [ (help) = "sector ID" ]; - } - - optional Location filesystem_start = 1 - [ (help) = "position of the filesystem superblock" ]; -} - -message RolandFsProto -{ - optional uint32 directory_track = 1 - [ (help) = "position of the directory", default = 39 ]; - optional uint32 block_size = 2 - [ (help) = "filesystem block size", default = 3072 ]; - optional uint32 directory_entries = 3 - [ (help) = "number of directory entries", default = 79 ]; -} - -// NEXT_TAG: 18 -message FilesystemProto -{ - enum FilesystemType - { - NOT_SET = 0; - ACORNDFS = 1; - BROTHER120 = 2; - FATFS = 3; - CPMFS = 4; - AMIGAFFS = 5; - MACHFS = 6; - CBMFS = 7; - PRODOS = 8; - SMAKY6 = 9; - APPLEDOS = 10; - PHILE = 11; - LIF = 12; - MICRODOS = 13; - ZDOS = 14; - ROLAND = 15; - } - - optional FilesystemType type = 10 - [ default = NOT_SET, (help) = "filesystem type" ]; - - optional AcornDfsProto acorndfs = 1; - optional Brother120FsProto brother120 = 2; - optional FatFsProto fatfs = 3; - optional CpmFsProto cpmfs = 4; - optional AmigaFfsProto amigaffs = 5; - optional MacHfsProto machfs = 6; - optional CbmfsProto cbmfs = 7; - optional ProdosProto prodos = 8; - optional AppledosProto appledos = 12; - optional Smaky6FsProto smaky6 = 11; - optional PhileProto phile = 13; - optional LifProto lif = 14; - optional MicrodosProto microdos = 15; - optional ZDosProto zdos = 16; - optional RolandFsProto roland = 17; -} diff --git a/opencode.json b/opencode.json new file mode 100644 index 000000000..9cc8361ed --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} diff --git a/src/fe-rpm.cc b/src/fe-rpm.cc deleted file mode 100644 index f54c294fa..000000000 --- a/src/fe-rpm.cc +++ /dev/null @@ -1,48 +0,0 @@ -#include "lib/core/globals.h" -#include "lib/config/config.h" -#include "lib/config/flags.h" -#include "lib/usb/usb.h" -#include "lib/fluxsource/fluxsource.h" -#include "protocol.h" -#include "lib/config/proto.h" - -static FlagGroup flags; - -static StringFlag sourceFlux({"-s", "--source"}, - "'drive:' flux source to use", - "", - [](const auto& value) - { - globalConfig().setFluxSource(value); - }); - -int mainRpm(int argc, const char* argv[]) -{ - globalConfig().set("flux_source.type", "FLUXTYPE_DRIVE"); - flags.parseFlagsWithConfigFiles(argc, argv, {}); - - if (globalConfig()->flux_source().type() != FLUXTYPE_DRIVE) - error("this only makes sense with a real disk drive"); - - usbSetDrive(globalConfig()->drive().drive(), - false, - globalConfig()->drive().index_mode()); - nanoseconds_t period = - usbGetRotationalPeriod(globalConfig()->drive().hard_sector_count()); - if (period != 0) - std::cout << "Rotational period is " << period / 1000000 << " ms (" - << 60e9 / period << " rpm)" << std::endl; - else - { - std::cout - << "No index pulses detected from the disk. Common causes of this " - "are:\n" - " - no drive is connected\n" - " - the drive doesn't have an index sensor (e.g. BBC Micro " - "drives)\n" - " - the disk has no index holes (e.g. reversed flippy disks)\n" - " - (most common) no disk is inserted in the drive!\n"; - } - - return 0; -} diff --git a/src/fe-seek.cc b/src/fe-seek.cc deleted file mode 100644 index 038f61698..000000000 --- a/src/fe-seek.cc +++ /dev/null @@ -1,35 +0,0 @@ -#include "lib/core/globals.h" -#include "lib/config/config.h" -#include "lib/config/flags.h" -#include "lib/usb/usb.h" -#include "lib/fluxsource/fluxsource.h" -#include "lib/config/proto.h" -#include "protocol.h" - -static FlagGroup flags; - -static StringFlag sourceFlux({"-s", "--source"}, - "'drive:' flux source to use", - "", - [](const auto& value) - { - globalConfig().setFluxSource(value); - }); - -static IntFlag track({"--cylinder", "-t"}, "track to seek to", 0); - -extern const std::map readables; - -int mainSeek(int argc, const char* argv[]) -{ - flags.parseFlagsWithConfigFiles(argc, argv, {}); - - if (globalConfig()->flux_source().type() != FLUXTYPE_DRIVE) - error("this only makes sense with a real disk drive"); - - usbSetDrive(globalConfig()->drive().drive(), - false, - globalConfig()->drive().index_mode()); - usbSeek(track); - return 0; -} diff --git a/src/fe-testbandwidth.cc b/src/fe-testbandwidth.cc deleted file mode 100644 index 99b58e85a..000000000 --- a/src/fe-testbandwidth.cc +++ /dev/null @@ -1,13 +0,0 @@ -#include "lib/core/globals.h" -#include "lib/config/flags.h" -#include "lib/usb/usb.h" - -static FlagGroup flags; - -int mainTestBandwidth(int argc, const char* argv[]) -{ - flags.parseFlagsWithConfigFiles(argc, argv, {}); - usbTestBulkWrite(); - usbTestBulkRead(); - return 0; -} diff --git a/src/fe-testdevices.cc b/src/fe-testdevices.cc deleted file mode 100644 index 25b60920f..000000000 --- a/src/fe-testdevices.cc +++ /dev/null @@ -1,41 +0,0 @@ -#include "lib/core/globals.h" -#include "lib/config/flags.h" -#include "lib/usb/usbfinder.h" -#include "fmt/format.h" - -static FlagGroup flags; - -int mainTestDevices(int argc, const char* argv[]) -{ - flags.parseFlagsWithConfigFiles(argc, argv, {}); - - auto candidates = findUsbDevices(); - switch (candidates.size()) - { - case 0: - fmt::print("Detected no devices.\n"); - break; - - case 1: - fmt::print("Detected one device:\n"); - break; - - default: - fmt::print("Detected {} devices:\n", candidates.size()); - } - - if (!candidates.empty()) - { - fmt::print( - "{:15} {:30} {}\n", "Type", "Serial number", "Port (if any)"); - for (auto& candidate : candidates) - { - fmt::print("{:15} {:30} {}\n", - getDeviceName(candidate->type), - candidate->serial, - candidate->serialPort); - } - } - - return 0; -} diff --git a/src/fe-testvoltages.cc b/src/fe-testvoltages.cc deleted file mode 100644 index 7c91c3bd2..000000000 --- a/src/fe-testvoltages.cc +++ /dev/null @@ -1,37 +0,0 @@ -#include "lib/core/globals.h" -#include "lib/config/flags.h" -#include "lib/usb/usb.h" -#include "protocol.h" - -static FlagGroup flags; - -static std::string display_voltages(struct voltages& v) -{ - return fmt::format(" Logic 1 / 0: {:.2f}V / {:.2f}V\n", - v.logic0_mv / 1000.0, - v.logic1_mv / 1000.0); -} - -int mainTestVoltages(int argc, const char* argv[]) -{ - flags.parseFlagsWithConfigFiles(argc, argv, {}); - struct voltages_frame f; - usbMeasureVoltages(&f); - - std::cout - << "Output voltages:\n" - << " Both drives deselected\n" - << display_voltages(f.output_both_off) << " Drive 0 selected\n" - << display_voltages(f.output_drive_0_selected) << " Drive 1 selected\n" - << display_voltages(f.output_drive_1_selected) << " Drive 0 running\n" - << display_voltages(f.output_drive_0_running) << " Drive 1 running\n" - << display_voltages(f.output_drive_1_running) << "Input voltages:\n" - << " Both drives deselected\n" - << display_voltages(f.input_both_off) << " Drive 0 selected\n" - << display_voltages(f.input_drive_0_selected) << " Drive 1 selected\n" - << display_voltages(f.input_drive_1_selected) << " Drive 0 running\n" - << display_voltages(f.input_drive_0_running) << " Drive 1 running\n" - << display_voltages(f.input_drive_1_running); - - return 0; -} diff --git a/src/formats/BUILD.bazel b/src/formats/BUILD.bazel new file mode 100644 index 000000000..e1c9c046c --- /dev/null +++ b/src/formats/BUILD.bazel @@ -0,0 +1,64 @@ +package(default_visibility = ["//visibility:public"]) + +FORMATS = [ + "acornadfs", + "acorndfs", + "aeslanier", + "agat", + "amiga", + "ampro", + "apple2", + "atarist", + "bk", + "brother", + "commodore", + "eco1", + "epsonpf10", + "f85", + "fb100", + "_global_options", + "hplif", + "ibm", + "icl30", + "juku", + "mac", + "micropolis", + "ms2000", + "mx", + "n88basic", + "northstar", + "psos", + "rolandd20", + "rx50", + "smaky6", + "tartu", + "ti99", + "tids990", + "tiki", + "victor9k", + "zilogmcz", +] + +[ + genrule( + name = "%s_bin" % f, + srcs = ["%s.textpb" % f], + outs = ["formats/%s.bin" % f], + tools = ["//java/com/cowlark/fluxengine/buildtools:protoencode"], + cmd = "$(location //java/com/cowlark/fluxengine/buildtools:protoencode) " + + "$(location %s.textpb) $(location formats/%s.bin)" % (f, f), + ) + for f in FORMATS +] + +genrule( + name = "names", + srcs = ["%s.textpb" % f for f in FORMATS], + outs = ["formats/names.txt"], + cmd = "printf '%%s\\n' %s > $(location formats/names.txt)" % " ".join(FORMATS), +) + +filegroup( + name = "formats_files", + srcs = ["formats/%s.bin" % f for f in FORMATS] + ["formats/names.txt"], +) diff --git a/src/formats/_global_options.textpb b/src/formats/_global_options.textpb index b64b90939..f4b5f77a1 100644 --- a/src/formats/_global_options.textpb +++ b/src/formats/_global_options.textpb @@ -64,7 +64,7 @@ option_group { option { name: "auto" - comment: 'Autodetect from hardware' + comment: 'Autodetect rotational speed from hardware' set_by_default: true config { diff --git a/src/formats/atarist.textpb b/src/formats/atarist.textpb index 260ea6b98..9207c3a58 100644 --- a/src/formats/atarist.textpb +++ b/src/formats/atarist.textpb @@ -50,6 +50,11 @@ decoder { } } +image_writer { + filename: "atarist.st" + type: IMAGETYPE_IMG +} + layout { format_type: FORMATTYPE_80TRACK }