# Workabout MX barcode scanner — API notes (reverse-engineered)

There is no official SDK documentation for the Workabout MX integral laser scanner.
The Psion SIBO C SDK and I/O Devices Reference predate the MX and describe only the
older *external* barcode modules (wands / wand-emulation), not the integral laser.
These notes are reverse-engineered from device behaviour and from the Workabout MX
ROM `w2mx_v7.20f_eng.bin` (strings), so treat unconfirmed items as such.

## Hardware

- Integral **laser** scanner, Symbol engine. The demo (`DEMMAN.APP`) reports
  `Type: Laser 1223` (some units `1222`).
- It is a **decoded** scanner: it decodes in hardware and lights a green good-read
  LED. It does **not** emit an undecoded (HHLC) signal.
- Trigger: the keyboard **scan key**, which the Window Server reports as key
  code **368**. The key alone does not fire the laser from an arbitrary app; the
  scanner software arms/reads the engine.

## Device driver and ports

`LLDEV` on the device lists a logical driver **`wl2`** (`units=1`). The scanner is
reached through this driver. The demo's "Select Scanner" screen lets you pick both
a **decoder type** and a **port** (device driver + unit letter):

- Port device drivers offered: **`TTY`**, **`WLS`**, **`WL2`**.
  The MX integral laser uses **`WL2`**.
- Units seen in the ROM: **`WL2:A`** and **`WL2:D`**.

Decoder types (from the demo, "chosen from those used in standard Workabout
products"):

| Decoder | Meaning |
| --- | --- |
| `Generic` | External non-configurable reader |
| `Symbol1` | Workabout standard scanner |
| `Datalogic` | Workabout CCD |
| `HP` | Workabout Wand |
| **`Symbol2`** | **Workabout MX scanner (the integral laser)** |

## Opening the device

```c
VOID *h;
INT  err = p_open(&h, "WL2:D", (UINT)-1);
```

Observed results:

| Call | Result | Meaning |
| --- | --- | --- |
| `p_open("WL2:D", -1)` | `0` | **opens** — this is the integral-laser unit |
| `p_open("WL2:A", -1)` | `-9` (`E_GEN_INUSE`) | in use — held by the resident scanner software |
| `p_open("WL2:B" / ":C")` | (untested) | — |

`WL2` has a single unit, so only one process may hold it. `WL2:A` is permanently
`-9` even with the demo closed, indicating a resident holder; `WL2:D` is the unit
an application opens.

## The 11-byte parameter block

The scanner is configured by an **eleven-byte parameter block**. The application
interprets these bytes and converts them into commands for the selected decoder
(so the same block means different wire commands for `Symbol2` vs `HP`, etc.):

| Byte | Parameter |
| --- | --- |
| Param0 | Decode security |
| Param1 | Code type (symbology select) |
| Param2 | Decode options A |
| Param3 | Decode options B |
| Param4 | General parameters |
| Param5 | ITF length 1 |
| Param6 | ITF length 2 |
| Param7 | Preamble |
| Param8 | Postamble byte 1 |
| Param9 | Postamble byte 2 |
| Param10 | General decode options |

The preamble/postamble bytes mean decoded output may carry configurable leading /
trailing characters — parsing code must account for them.

## Access model

Applications do not drive `WL2` byte-by-byte themselves; they use the ROM library
**`SCANNER.DYL`** (used by `SCANAPP.APP` and `DEMMAN.APP`). That library opens the
port, converts the 11-byte block to decoder commands, configures and enables the
engine, and returns decoded scans.

## Paths that do NOT work for the integral laser

Recorded so they are not retried:

- **`bar*.ldd` decoders (`BAREAN`, `BARC39`, …) + `BAR:` device.** These are
  software decoders for the *external* wand modules. Loading `BAREAN.LDD` and
  opening `BAR:A`/`BAR:B` returns `-41` (`E_FILE_DEVICE`, "no interface found in
  slot") — the integral laser is not a `BAR:` expansion interface. `BAR:D`/`BAR:E`
  return `-38` (`E_FILE_NAME`, invalid unit).
- **`TTY:D` serial reads.** Opening `TTY:D` powers the laser (it fires briefly),
  but no decoded bytes ever arrive on the serial channel — not with default
  config, not after `P_FSET` to 9600/8/1, not with `P_OBEY_DSR` cleared, not with
  the intelligent-reader escape commands (`<Esc>-y1J` / `<Esc>-y1K`), not reading
  one byte at a time. The integral laser's decoded data is not on `TTY:D`.

## Initialisation sequence (reverse-engineered from the ROM)

Opening `WL2:D` is not enough — the scanner must be configured and enabled first.
The sequence was recovered by disassembling `SCANNER.DYL` in the v7.20f ROM
(around file offset `0xD7AA4`). It uses the SIBO I/O executive **`int 0xCF`**,
whose convention here is: **`CL` = I/O function code, `BX` = channel handle,
`DX` = argument (by value)**, result in `AX`. From C this is the `p_iow(chan,
func, ...)` layer.

Function codes observed on the `WL2` channel (from `p_file.h`, plus WL2-specific
ones above the standard range):

| CL | Meaning |
| --- | --- |
| 6 | (`P_FDETACH` slot) used as a scanner enable/control op |
| 7 | (`P_FSET`) used as a scanner enable/control op |
| 8 | `P_FSENSE` |
| 9 | `P_FFLUSH` |
| 0x0C (12) | **WL2-specific: write a config/command byte** (byte passed in `DX`) |

The driver keeps a per-channel structure; the param block sits at **channel+4**,
with the **decoder type at offset +6** (dispatch values 1..4) and the **11-byte
parameter block at +8..+0x12**. Configuration is pushed to the engine as a series
of `cl=0x0C` writes (one command byte per call in `DX`), then enable ops
(`cl=6`, `cl=7`).

### Default Symbol2 parameter block (from the ROM)

The routine at `0xD7AB3` fills the 11-byte block with these defaults for the
Workabout MX (Symbol2) scanner:

| Byte | Param | Value |
| --- | --- | --- |
| +8  | Param0 Decode security   | `0x04` |
| +9  | Param1 Code type         | `0x3F` (all symbologies) |
| +0xA| Param2 Decode options A  | `0x01` |
| +0xB| Param3 Decode options B  | `0x15` |
| +0xC| Param4 General params    | `0x06` |
| +0xD| Param5 ITF length 1      | `0x04` |
| +0xE| Param6 ITF length 2      | `0x1E` |
| +0xF| Param7 Preamble          | `0x80` |
| +0x10| Param8 Postamble 1      | `0x0D` (CR) |
| +0x11| Param9 Postamble 2      | `0x0A` (LF) |
| +0x12| Param10 General decode  | `0x06` |

So **decoded output is terminated by CR LF** (`0x0D 0x0A`), and Code type `0x3F`
enables all symbologies (UPC included). A reader should assemble bytes until
CR/LF and strip the preamble/postamble.

### Confirmed on-device

- `p_open(&h, "WL2:D", -1)` returns `0`.
- Issuing control ops **6 then 7** on the channel (`p_iow(h, 6)`, `p_iow(h, 7)`)
  **triggers a scan**: the laser fires and decodes (green good-read LED). This is
  reproducible. The trigger is one-shot — it must be re-issued for each scan.

### NOT yet solved: retrieving the decoded data

After a confirmed good read, the decoded barcode could **not** be retrieved from
the `WL2:D` channel by any tried method:

- `p_iow(h, P_FREAD, buf)` (count-in-`buf[0]` style) — no data.
- `p_read(h, buf, len)` (synchronous, with length) — no data.
- `p_ioc(h, P_FREAD, &stat, buf, &len)` + trigger + `p_iowait()` (async) — no data.

The retrieval logic in `SCANNER.DYL`/`SCANAPP` fetches the decoded data on a
**separate handle** (a driver global, e.g. `[0x13b5]`) obtained through
**OS-service stubs** of the form `mov ah,N; int 0x84/0x85/0x86/0x87; ret` — not a
plain `P_FREAD` on the `WL2:D` control channel. This resolves an earlier puzzle:
the SIBO executive uses **different register conventions per interrupt vector** —
the `int 0x84` service family passes the function in `AH`, whereas the `int 0xCF`
I/O executive uses `CL`.

`SCANNER.DYL` is an **object-oriented ROM library (a DYL)**. This strongly
suggests the scanner is driven by **creating a scanner object and sending it
messages** (the OLIB/OO model), not by raw device I/O on `WL2:D`. That would
explain why every raw `WL2:D` read returned no data — wrong paradigm.

**Confirmed working on device:** `p_open("WL2:D")` + control ops `6` then `7`
trigger the laser and a good decode (green LED). Only *retrieving* the decoded
bytes is unsolved.

### Decompilation (Ghidra) — the read path

`SCANNER.DYL`/`SCANAPP` decompile cleanly in Ghidra (16-bit real-mode x86). The
app-level read routine (`FUN_858d`) is:

```c
obj->flags |= 8;
obj->[0x190] = 0;
FUN_8ce8(); FUN_8cd3(); FUN_8cd3();       // pre-read setup
obj->[0x194] = FUN_8d8e();                // acquire a read handle  (int 0x84)
obj->[0x192] = FUN_8c8b(0,2,1,obj);       // set up the read buffer (int 0x84; int 0xd2)
swi(0xcf)(); swi(0xcf)();                 // config / enable ops on the channel
if (obj->[0x196] == 0)
    obj->[0x196] = swi(0xcf)();           // P_FREAD -> decoded result stored at +0x196
else
    swi(0xcf)();                          // P_FCANCEL
```

The decoder-side helpers also decompile clearly: `FUN_7ae3` is the per-decoder
init (dispatch on `[channel+10]`; case 4 = the Symbol path, which calls
`FUN_6dc4` to build the `0xff 0xee …` command frame and writes the 11-byte param
block); `FUN_7aa4` is the trigger (two `int 0xCF` ops = the `6`/`7` we use).

### The scanner is an OO library object (paradigm correction)

The System Services vector table names the services: **`int 0x84` = `LIBMANAGER`**
(the Library Manager) and **`int 0x83` = `MESSMANAGER`** (object messaging).
`LIBMANAGER` sub-functions (in `AH`) are `NMLIBLOAD=0, UNLOAD=1, LINK, FIND,
HANDLE, CREATE(=5), CREATEBYHANDLE(=6), …`. So the read's `FUN_8d8e`/`FUN_8c8b`
call **`NMLIBCREATE` / `NMLIBCREATEBYHANDLE`** — they *create a scanner library
object*.

**Therefore the scanner is driven as an object-oriented library object, not by
device I/O.** `WL2:D` is what `SCANNER.DYL` opens and reads *internally*; an
application never `p_open`/`p_read`s it. Every earlier raw-device attempt was the
wrong paradigm — which is why they returned nothing.

The correct C approach uses the standard OO API (the same one the SDK's
`growbar.c` demo uses):
- `p_getlibh(category)` — get the library handle for a category.
- `p_newsend` / `f_newsend(category, class, O_..._INIT, &args)` — create the
  scanner object and send its init message.
- `p_send2` / `p_send3(obj, O_...)` — send it messages (configure, trigger, read).

These C wrappers sit over `LIBMANAGER` (create/handle) and `MESSMANAGER` (send).

**Next RE target (well-defined):** extract from `SCANNER.DYL` in the ROM its
**category id**, **class**, and the **message ordinals** (`O_...`) for
init / set-params / trigger / read, plus their parameter structs. With those, a C
client can create the scanner object and message it — the documented OO way.
Validation still requires the physical device (MAME cannot inject a scan).

To close it, one of:
1. The **Workabout MX C SDK** `SCANNER.DYL` header / OO category definition (what
   `SCANAPP` was built against) — the clean answer, and likely the only practical
   one, because the interface is object-oriented.
2. A deep RE effort under MAME (`psionwamx`): automate the UI to launch `SCANAPP`,
   breakpoint the read routine, and trace it. Caveats: MAME cannot inject a real
   barcode (no laser input), so only the read *setup* is observable in emulation;
   the reconstructed sequence must still be validated on the physical device.

## Error codes seen (from `epocdefs.h`)

| Value | Name | Meaning |
| --- | --- | --- |
| `-9` | `E_GEN_INUSE` | device already open / in use |
| `-32` | `E_FILE_EXIST` | (LDD) already loaded |
| `-38` | `E_FILE_NAME` | invalid device name / unit |
| `-41` | `E_FILE_DEVICE` | device / interface not present |
