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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/host-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Host regression tests

on:
push:
paths:
- "addon/usbcdgadget/**"
- "addon/cueparser/**"
- "addon/discimage/**"
- "addon/libchdr-src/**"
- "integration-tests/**"
- ".github/workflows/host-tests.yml"
pull_request:
paths:
- "addon/usbcdgadget/**"
- "addon/cueparser/**"
- "addon/discimage/**"
- "addon/libchdr-src/**"
- "integration-tests/**"
- ".github/workflows/host-tests.yml"
workflow_dispatch:

jobs:
host-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# The CHD build compiles the vendored libchdr, which is a git submodule
# (addon/libchdr-src). Fetch just that submodule so libchdr/chd.h is
# present; scoping it to the path avoids cloning the large circle-stdlib
# submodule, which these host tests do not need.
- name: Fetch libchdr submodule (for the CHD build)
run: git submodule update --init addon/libchdr-src

# Command-layer + real-image tests (ISO and synthetic CUE/BIN). Fully
# self-contained: no external libraries needed.
- name: Build and run SCSI/BOT + real-image regression tests
run: make -C integration-tests

# Same suite plus the real CHD image, compiling libchdr with its
# vendored zlib/zstd/lzma decoders (still no system dependencies).
- name: Build and run including CHD image tests
run: make -C integration-tests clean && make -C integration-tests WITH_CHD=1
4 changes: 3 additions & 1 deletion addon/usbcdgadget/tcdstate_update.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,10 @@ void CUSBCDGadget::Update()
{
#if AARCH == 64
asm volatile("dc cvac, %0" : : "r"(addr) : "memory");
#else
#elif AARCH == 32
asm volatile("mcr p15, 0, %0, c7, c10, 1" : : "r"(addr) : "memory");
#else
(void)addr; // host test build: no cache maintenance needed
#endif
}

Expand Down
1 change: 1 addition & 0 deletions addon/usbcdgadget/usbcdgadget.h
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ class CUSBCDGadget : public CDWUSBGadget
friend class SCSIToolbox;
friend class SCSIMisc;
friend class CDUtils;
friend class CGadgetTestBench; // host regression tests (test/host)

boolean m_bPendingDiscSwap = false;
unsigned m_nDiscSwapStartTick = 0;
Expand Down
1 change: 1 addition & 0 deletions integration-tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
out/
155 changes: 155 additions & 0 deletions integration-tests/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Host-compiled regression tests for the USBODE SCSI/BOT layer.
#
# make -C integration-tests build + run
# make -C integration-tests build build only
# make -C integration-tests WITH_CHD=1 build + run including CHD tests
#
# Compiles the real gadget sources (addon/usbcdgadget + cueparser) and the
# real CUE/BIN/ISO reader (addon/discimage/cuebinfile.cpp) for the build
# machine against the stub Circle headers in harness/stubs/, links them with
# the test harness, and runs the resulting binary. The real-image tests drive
# those readers with actual image files. No cross-compiler or circle-stdlib
# checkout is needed.
#
# CHD support (WITH_CHD=1) additionally compiles addon/discimage/chdfile.cpp
# and libchdr with its vendored zlib/zstd/lzma decoders, so .chd images load
# through the real decompressor. It is opt-in only because those decoders add
# a heavier first-build cost.

CXX ?= c++
CC ?= cc
CXXFLAGS ?= -O1 -g
CXXFLAGS += -std=c++17 -Wall -Wno-format -Wno-unused-variable -Wno-unused-but-set-variable
CFLAGS ?= -O1 -g -w

ADDON := ../addon
BUILD := out
IMAGES := $(BUILD)/images

INCLUDES := -Iharness/stubs -I$(ADDON) -Iharness
DEFINES := -DUSBODE_TESTDATA=\"$(IMAGES)\" -DUSBODE_SDCARD=\"../sdcard\"

# ---------------------------------------------------------------------------
# Real firmware sources under test (compiled from the repo, unchanged)
# ---------------------------------------------------------------------------
GADGET_SRCS := \
$(ADDON)/usbcdgadget/usbcdgadget.cpp \
$(ADDON)/usbcdgadget/usbcdgadgetendpoint.cpp \
$(ADDON)/usbcdgadget/tcdstate_update.cpp \
$(ADDON)/usbcdgadget/scsi_inquiry.cpp \
$(ADDON)/usbcdgadget/scsi_read.cpp \
$(ADDON)/usbcdgadget/scsi_toc.cpp \
$(ADDON)/usbcdgadget/scsi_misc.cpp \
$(ADDON)/usbcdgadget/scsi_toolbox.cpp \
$(ADDON)/usbcdgadget/cd_utils.cpp \
$(ADDON)/cueparser/cueparser.cpp \
$(ADDON)/cueparser/cueutil.cpp

# Real CUE/BIN/ISO reader. Its only host-side dependency is the FatFs seam
# (harness/fatfs_host.cpp) plus the two FatFsOptimizer entry points provided
# in harness/discimage_host.cpp. No reader logic is reimplemented.
DISCIMAGE_SRCS := $(ADDON)/discimage/cuebinfile.cpp

CHDR_OBJS :=
ifeq ($(WITH_CHD),1)
DISCIMAGE_SRCS += $(ADDON)/discimage/chdfile.cpp
DEFINES += -DWITH_CHD=1
LIBCHDR_DIR := $(ADDON)/libchdr-src
LIBCHDR_SRCS := \
$(LIBCHDR_DIR)/src/libchdr_chd.c \
$(LIBCHDR_DIR)/src/libchdr_cdrom.c \
$(LIBCHDR_DIR)/src/libchdr_flac.c \
$(LIBCHDR_DIR)/src/libchdr_huffman.c \
$(LIBCHDR_DIR)/src/libchdr_bitstream.c
ZLIB_DIR := $(LIBCHDR_DIR)/deps/zlib-1.3.1
ZLIB_SRCS := $(ZLIB_DIR)/adler32.c $(ZLIB_DIR)/crc32.c $(ZLIB_DIR)/inflate.c \
$(ZLIB_DIR)/inftrees.c $(ZLIB_DIR)/inffast.c $(ZLIB_DIR)/zutil.c
ZSTD_DIR := $(LIBCHDR_DIR)/deps/zstd-1.5.6/lib
ZSTD_SRCS := $(wildcard $(ZSTD_DIR)/common/*.c) $(wildcard $(ZSTD_DIR)/decompress/*.c)
LZMA_DIR := $(LIBCHDR_DIR)/deps/lzma-24.05
LZMA_SRCS := $(LZMA_DIR)/src/LzmaDec.c $(LZMA_DIR)/src/LzmaEnc.c $(LZMA_DIR)/src/LzFind.c \
$(LZMA_DIR)/src/Sort.c $(LZMA_DIR)/src/Alloc.c $(LZMA_DIR)/src/CpuArch.c \
$(LZMA_DIR)/src/Delta.c
C_SRCS := $(LIBCHDR_SRCS) $(ZLIB_SRCS) $(ZSTD_SRCS) $(LZMA_SRCS)
LIBCHDR_INC := -I$(LIBCHDR_DIR)/include -I$(ZLIB_DIR) -I$(ZSTD_DIR) -I$(ZSTD_DIR)/common \
-I$(LZMA_DIR)/include
CHDR_OBJS := $(addprefix $(BUILD)/,$(notdir $(C_SRCS:.c=.o)))
endif

HARNESS_SRCS := $(wildcard harness/*.cpp)
TEST_SRCS := $(wildcard test-suite/*.cpp)

CXX_SRCS := $(GADGET_SRCS) $(DISCIMAGE_SRCS) $(HARNESS_SRCS) $(TEST_SRCS)
CXX_OBJS := $(addprefix $(BUILD)/,$(notdir $(CXX_SRCS:.cpp=.o)))
OBJS := $(CXX_OBJS) $(CHDR_OBJS)

VPATH := $(sort $(dir $(CXX_SRCS) $(C_SRCS)))

BINARY := $(BUILD)/usbode-host-tests

# Prepared test images (real files the real readers open at run time).
# The .gz sources live in testdata/ and are decompressed here; the .cue and
# .chd are copied as-is so the readers find them next to their data.
TEST_IMAGES := $(IMAGES)/image.iso \
$(IMAGES)/freedos-test.iso \
$(IMAGES)/shareware.iso \
$(IMAGES)/audiocd.bin $(IMAGES)/audiocd.cue \
$(IMAGES)/mixed.bin $(IMAGES)/mixed.cue \
$(IMAGES)/mixed.chd

all: run

build: $(BINARY)

run: $(BINARY) $(TEST_IMAGES)
./$(BINARY)

$(BINARY): $(OBJS)
$(CXX) $(CXXFLAGS) -o $@ $(OBJS)

# -MMD/-MP emit .d dependency files so edits to a header (a stub, a firmware
# header) trigger the right recompiles; without this an incremental build can
# silently keep a stale object when only a header changed.
$(BUILD)/%.o: %.cpp
@mkdir -p $(BUILD)
$(CXX) $(CXXFLAGS) $(INCLUDES) $(LIBCHDR_INC) $(DEFINES) -MMD -MP -c $< -o $@

# Z7_ST builds the LZMA SDK single-threaded, so LzFindMt (the multithreaded
# matcher) is not pulled in; libchdr only needs the decoder anyway.
# ZSTD_DISABLE_ASM keeps zstd on its portable C decode path: on x86-64 zstd
# otherwise enables an assembly Huffman fast-loop (huf_decompress_amd64.S)
# that we do not build, which fails at link time. The C path is equivalent.
$(BUILD)/%.o: %.c
@mkdir -p $(BUILD)
$(CC) $(CFLAGS) -DZ7_ST -DZSTD_DISABLE_ASM=1 $(LIBCHDR_INC) -MMD -MP -c $< -o $@

-include $(OBJS:.o=.d)

# Real ISO image: decompress the tracked sdcard/image.iso.gz so the reader
# opens an actual ISO9660 disc.
$(IMAGES)/image.iso: ../sdcard/image.iso.gz
@mkdir -p $(IMAGES)
gzip -dc $< > $@

# Real disc-image fixtures under testdata/ (see testdata/README-testdata.md).
# .gz images are decompressed; .cue/.chd are copied as-is next to their data.
$(IMAGES)/%.iso: testdata/%.iso.gz
@mkdir -p $(IMAGES)
gzip -dc $< > $@

$(IMAGES)/%.bin: testdata/%.bin.gz
@mkdir -p $(IMAGES)
gzip -dc $< > $@

$(IMAGES)/%.cue: testdata/%.cue
@mkdir -p $(IMAGES)
cp $< $@

$(IMAGES)/%.chd: testdata/%.chd
@mkdir -p $(IMAGES)
cp $< $@

clean:
rm -rf $(BUILD)

.PHONY: all build run clean
176 changes: 176 additions & 0 deletions integration-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# USBODE integration tests

Automated tests for the SCSI/Bulk-Only-Transport layer and the disc-image
readers that run on an ordinary PC — no Raspberry Pi, no cross-compiler, no
circle-stdlib checkout. CI runs them on every push and pull request that
touches the gadget or reader code (`.github/workflows/host-tests.yml`).

```
make -C integration-tests # build + run (command-layer + ISO/CUE/BIN images)
make -C integration-tests WITH_CHD=1 # also run the real .chd image through libchdr
USBODE_TEST_VERBOSE=1 integration-tests/out/usbode-host-tests # with firmware logs
```

## What this is

The **real firmware sources** — all of `addon/usbcdgadget` (command
handlers, BOT state machine, `Update()` chunked-read path), the real
`cueparser`, and the real disc-image readers (`addon/discimage/cuebinfile.cpp`
and, under `WITH_CHD`, `chdfile.cpp` + libchdr) — are compiled for the build
machine against a set of thin stub headers (`harness/stubs/`) that stand in for
Circle. Nothing under test is reimplemented or mocked: the same `scsi_toc.cpp`
and `cuebinfile.cpp` that answer a Windows 98 machine answer the tests.

The harness then behaves exactly like a USB host:

1. `CGadgetTestBench` builds a CBW and copies it into the buffer the
gadget queued on its OUT endpoint, then calls `OnTransferComplete()` —
the same entry point the DWC interrupt handler uses on hardware.
2. The gadget runs its production dispatch (`HandleSCSICommand`), unit
attention gating, and response builders. Its `BeginTransfer()` calls
land in a test sink instead of DWC registers.
3. The bench completes data phases (calling `Update()` when the gadget
enters `DataInRead`, so multi-chunk READs work) until the CSW arrives.
4. Tests assert on the exact wire bytes: data phase, CSW status, and
**data residue** — the field Win9x storage stacks are strict about.

Two flavours of disc back the bench, both driven through the same
`IImageDevice` interface the firmware uses:

- **Command-layer tests** use a fake in-memory `IImageDevice`
(`harness/fakedisc.h`) with deterministic sector contents, to exercise the
SCSI/BOT layer in isolation.
- **Real-image tests** (`test-suite/test_realimages.cpp`) load actual files
through the real readers:
- the tracked `sdcard/image.iso.gz` and a **real FreeDOS ISO9660 + Joliet
disc** (`testdata/freedos-test.iso.gz`, built from genuine FreeDOS 1.3 GPL
files — see `testdata/README-testdata.md`), checked against real
on-disc structure (primary + Joliet volume descriptors, volume id);
- a **real game disc** (`testdata/shareware.iso.gz`: the Descent shareware
episode and the SkyRoads freeware game, both cleared for redistribution)
read back in full and compared byte-for-byte against the file — a real
~3.5 MB filesystem with multi-megabyte files spanning many sectors and
read-ahead-cache refills;
- synthetic CUE/BIN pairs written at run time, plus committed **real cue
sheets loaded off the filesystem through the FatFs shim**
(`testdata/audiocd.*`, `testdata/mixed.*`), with known bytes so reads,
per-track offsets across the 2048->2352 boundary, and TOC/medium-type are
checked exactly;
- with `WITH_CHD=1`, the tracked `sdcard/usbode-audio-test.chd` and a
**mixed-mode CHD built with `chdman`** (`testdata/mixed.chd`), whose data
track is decoded through real libchdr and checked byte-exact.

All committed test images are free-to-redistribute (FreeDOS GPL files or
generated content); see `testdata/README-testdata.md`.

The only host-side seams the readers get are the raw file-access boundary
(`harness/fatfs_host.cpp`, a FatFs shim over stdio) and the two
`FatFsOptimizer` fast-seek entry points (`harness/discimage_host.cpp`, a no-op
since there is no FAT under the host filesystem). All reader logic — cue
parsing, per-track sector-size math, the read-ahead cache, CHD hunk
decompression — is the real firmware code.

## Why these tests exist

Every case encodes behavior that a real host depends on, and most encode a
bug USBODE actually shipped:

| Test | Shipped bug it would have caught |
| --- | --- |
| `mode_sense10_medium_type_*` | #164: hardcoded medium type 0x13 broke Win98 CD audio ("data or no disc loaded") |
| `mode_sense10_page0e_win98_golden` | Byte-exact MODE SENSE page 0x0E response retail Win98 SE accepted before PLAY AUDIO (from a Trace Lab golden capture); also the pad-to-allocation-length fix |
| `read_toc_legacy_cdb9_session_info` | Win9x session-info request encoded in CDB[9] answered with a full TOC |
| `read_toc_format0_lba` residue check | CSW residue reported as 0 on short responses made Win98 usbstor.sys discard and retry forever |
| `read_blocked_by_unit_attention` | Missing STALL before CSW reset the device on Windows 11 xHCI |
| `read10_*` | Boundary clamping, multi-chunk `Update()` batching (32 blocks HS / 16 blocks FS), residue on truncated reads |
| `play_audio_*`, `read_subchannel_*` | The full MCICDA analog-audio sequence (the one Win98 QuickInstall's replaced USB stack never sends — oerg866/win98-quickinstall#151) |
| `real_iso_*`, `real_cuebin_*`, `real_chd_*` | The reader path: cue parsing, per-track offsets across the 2048->2352 boundary, the read-ahead cache, and real CHD hunk decompression, driven from real files rather than a fake |

The bench itself found one latent firmware bug on day one: passing a
device to the `CUSBCDGadget` constructor makes `SetDevice()` delete the
device it was just handed and continue using the freed pointer
(production code always passes `nullptr` and calls `SetDevice()` later,
so the path is dormant — see `harness/bench.cpp`).

## Layout

```
integration-tests/
Makefile host build; `make` = build + run, WITH_CHD=1 adds CHD
harness/
stubs/ minimal Circle/service headers (circle/, cdplayer/, fatfs/, ...)
stubs.cpp logger/scheduler/timer/endpoint implementations
testbus.h records BeginTransfer()/Stall() from the gadget
fakedisc.* in-memory disc images + cue sheets
fatfs_host.cpp FatFs f_open/f_read/... over host stdio (real-image reads)
discimage_host.cpp FatFsOptimizer no-op backing (fast seek n/a on host)
bench.* the virtual USB host
framework.* tiny TEST()/CHECK() runner
test-suite/ one file per command family, plus test_realimages.cpp
```

Two production accommodations (both inert on the device):

- `tcdstate_update.cpp`: the ARM cache-maintenance asm is guarded by
`#if AARCH == 64 / #elif AARCH == 32 / #else (host: no-op)`.
- `usbcdgadget.h`: one `friend class CGadgetTestBench;` declaration.

## Adding a test

```cpp
#include "bench.h"
#include "framework.h"

TEST(my_new_case)
{
CFakeImageDevice *disc = MakeDataISO(1200); // 1200-sector data CD
CGadgetTestBench bench(disc); // optional: player fake
bench.Activate(); // enumerate
bench.RequestSense(); // clear unit attention

const u8 cdb[10] = {0x43, /* ... */};
auto r = bench.SendCommand(cdb, sizeof(cdb), /*transferLen=*/100);

CHECK_EQ(r.csw.bmCSWStatus, 0);
CHECK_EQ(r.csw.dCSWDataResidue, 80u);
CHECK_BYTES(r.data.data(), r.data.size(), expected, sizeof(expected));
}
```

Drop the file in `test-suite/` — the Makefile globs it, the `TEST` macro
registers it. To drive a real file instead of the fake, construct the reader
directly (see `test_realimages.cpp`) and hand it to the same bench.

When a Trace Lab capture from a real machine shows a request/response
pair worth preserving (like the Win98 page 0x0E case), turn it into a
byte-exact test: that is the cheapest way to convert one afternoon of
hardware debugging into permanent coverage.

## What this does not cover (Tier 2 ideas)

Host-side tests cannot see real USB timing, the DWC controller, IRQ
interleaving, or actual host OS quirks. (The real FatFs is now partially
stood in for by a stdio shim; genuine on-SD-card behavior like fragmentation
and fast-seek still only runs on hardware.) A few production paths are also
deliberately out of scope here and only run on hardware:

- **The image-loader factory** (`util.cpp` `loadCueBinIsoFileDevice`): format
dispatch by file extension and `.cue`->`.bin` filename resolution. The
real-image tests now read a `.cue` off the filesystem through the FatFs shim
and parse it, but they open the matching `.bin` by known name rather than
going through the production factory (it pulls in the MDS chain, which host
tests don't build).
- **`FatFsOptimizer` fast-seek**: CLMT allocation, `CREATE_LINKMAP`, and
fragmented-file seeking. The host shim makes fast-seek a no-op, so the
reader falls back to plain seeks.
- **The property-tag serial-number success path**: the host property-tag stub
always reports failure, so the gadget always takes its fallback-serial
branch; the hardware success branch and serial-descriptor formatting are
uncovered.

The natural next tier is
hardware-in-the-loop: a PC with `sg3_utils` issuing the same command set
to a real USBODE over USB, comparing Trace Lab captures against golden
traces (`usbode_trace.py compare`). QEMU is not a shortcut here — its
dwc2 model is host-mode only, so the gadget side cannot run under
emulation.
Loading
Loading