diff --git a/backend/genesys.conf.in b/backend/genesys.conf.in index 6e6a8d7b..017a0975 100644 --- a/backend/genesys.conf.in +++ b/backend/genesys.conf.in @@ -167,5 +167,14 @@ usb 0x1803 0x162e # Plustek OpticBook 3800 usb 0x07b3 0x1300 +# Plustek OpticPro A320E +# The mechanical scan origin varies between individual units. If your scans +# are shifted, correct them with the advanced options --x-origin-offset and +# --y-origin-offset (millimetres, range -5..5, default 0). To measure your +# own values: place a sheet squarely in the origin corner, scan it, and +# measure how far the sheet edge sits from where it should be; enter that +# difference. Positive values move the scanned area away from the origin. +usb 0x07b3 0x1826 + # Plustek OpticFilm 7600i usb 0x07b3 0x0c3b diff --git a/backend/genesys/device.h b/backend/genesys/device.h index 842856b6..c83982da 100644 --- a/backend/genesys/device.h +++ b/backend/genesys/device.h @@ -320,6 +320,48 @@ struct Genesys_Device std::unique_ptr interface; + /* A320E: SHADOW COPY of REG_0x31 (GPIO). + * + * REG_0x31 is a GPIO register, and reading it returns the PIN STATES + * rather than the value last written: a measured read gave 0x7f where + * 0x80 had been written. A read-modify-write would push the pin states + * back out as outputs -- and once it did clear the lower seven bits, + * after which the device made a noise and had to be power-cycled + * (2026-08-20). So the driver keeps its own record of the value last + * written and changes bit 0x80 only (gl124_setup_scan_gpio). + * The fields live with the device and reset when it is reopened -- an + * earlier file-scope static wrongly survived a device power cycle + * inside a long-lived process. + */ + std::uint8_t a320e_r31_shadow = 0; + bool a320e_r31_valid = false; + + /* A320E: user origin correction in millimetres (OPT_X/Y_ORIGIN_OFFSET). + * model->x_offset and y_offset were measured on ONE unit (runs 158, + * 168-173) and hardcoded; another user's device has its mechanical + * origin somewhere else. The vendor equivalent is AdjustOrigin. + * Default 0 = the measured origin as it stands. + */ + float a320e_origin_offset_x = 0.f; + float a320e_origin_offset_y = 0.f; + + /* A320E: has the image been read to the end? + * + * The lamp is switched off for the return run (low.cpp, + * a320e_lamp_off_on_return), and the return is made of ordinary backward + * move sessions. But a backward move is not proof of a return: there is + * one before the image scan too, and MEASURED in run 362 keying the lamp + * off `Direction::BACKWARD` alone gave an extra off/on pair right before + * the image (frames 8407 and 8907) that run 361 did not have. The user + * saw the lamp blink during the preparation and asked whether that was + * intended -- it was not: the vendor keeps the lamp lit through the whole + * job apart from the dark calibration. + * + * This flag is what separates the two. Set when the last image byte has + * been delivered, cleared when a new scan starts. + */ + bool a320e_image_read_done = false; + bool is_head_pos_known(ScanHeadId scan_head) const; unsigned head_pos(ScanHeadId scan_head) const; void set_head_pos_unknown(ScanHeadId scan_head); diff --git a/backend/genesys/enums.cpp b/backend/genesys/enums.cpp index 343a2208..0a502151 100644 --- a/backend/genesys/enums.cpp +++ b/backend/genesys/enums.cpp @@ -125,6 +125,7 @@ std::ostream& operator<<(std::ostream& out, ModelId id) case ModelId::PLUSTEK_OPTICFILM_7500I: out << "PLUSTEK_OPTICFILM_7500I"; break; case ModelId::PLUSTEK_OPTICFILM_8200I: out << "PLUSTEK_OPTICFILM_8200I"; break; case ModelId::PLUSTEK_OPTICPRO_3600: out << "PLUSTEK_OPTICPRO_3600"; break; + case ModelId::PLUSTEK_OPTICPRO_A320E: out << "PLUSTEK_OPTICPRO_A320E"; break; case ModelId::PLUSTEK_OPTICPRO_ST12: out << "PLUSTEK_OPTICPRO_ST12"; break; case ModelId::PLUSTEK_OPTICPRO_ST24: out << "PLUSTEK_OPTICPRO_ST24"; break; case ModelId::SYSCAN_DOCKETPORT_465: out << "SYSCAN_DOCKETPORT_465"; break; @@ -176,6 +177,7 @@ std::ostream& operator<<(std::ostream& out, SensorId id) case SensorId::CCD_PLUSTEK_OPTICFILM_7500I: out << "CCD_PLUSTEK_OPTICFILM_7500I"; break; case SensorId::CCD_PLUSTEK_OPTICFILM_8200I: out << "CCD_PLUSTEK_OPTICFILM_8200I"; break; case SensorId::CCD_PLUSTEK_OPTICPRO_3600: out << "CCD_PLUSTEK_OPTICPRO_3600"; break; + case SensorId::CCD_PLUSTEK_OPTICPRO_A320E: out << "CCD_PLUSTEK_OPTICPRO_A320E"; break; case SensorId::CCD_ROADWARRIOR: out << "CCD_ROADWARRIOR"; break; case SensorId::CCD_ST12: out << "CCD_ST12"; break; case SensorId::CCD_ST24: out << "CCD_ST24"; break; @@ -226,6 +228,7 @@ std::ostream& operator<<(std::ostream& out, AdcId id) case AdcId::PLUSTEK_OPTICFILM_7500I: out << "PLUSTEK_OPTICFILM_7500I"; break; case AdcId::PLUSTEK_OPTICFILM_8200I: out << "PLUSTEK_OPTICFILM_8200I"; break; case AdcId::PLUSTEK_OPTICPRO_3600: out << "PLUSTEK_OPTICPRO_3600"; break; + case AdcId::PLUSTEK_OPTICPRO_A320E: out << "PLUSTEK_OPTICPRO_A320E"; break; case AdcId::WOLFSON_5345: out << "WOLFSON_5345"; break; case AdcId::WOLFSON_DSM600: out << "WOLFSON_DSM600"; break; case AdcId::WOLFSON_HP2300: out << "WOLFSON_HP2300"; break; @@ -275,6 +278,7 @@ std::ostream& operator<<(std::ostream& out, GpioId id) case GpioId::PLUSTEK_OPTICFILM_7500I: out << "PLUSTEK_OPTICFILM_7500I"; break; case GpioId::PLUSTEK_OPTICFILM_8200I: out << "PLUSTEK_OPTICFILM_8200I"; break; case GpioId::PLUSTEK_OPTICPRO_3600: out << "PLUSTEK_OPTICPRO_3600"; break; + case GpioId::PLUSTEK_OPTICPRO_A320E: out << "PLUSTEK_OPTICPRO_A320E"; break; case GpioId::ST12: out << "ST12"; break; case GpioId::ST24: out << "ST24"; break; case GpioId::UMAX: out << "UMAX"; break; @@ -320,6 +324,7 @@ std::ostream& operator<<(std::ostream& out, MotorId id) case MotorId::PLUSTEK_OPTICFILM_7500I: out << "PLUSTEK_OPTICFILM_7500I"; break; case MotorId::PLUSTEK_OPTICFILM_8200I: out << "PLUSTEK_OPTICFILM_8200I"; break; case MotorId::PLUSTEK_OPTICPRO_3600: out << "PLUSTEK_OPTICPRO_3600"; break; + case MotorId::PLUSTEK_OPTICPRO_A320E: out << "PLUSTEK_OPTICPRO_A320E"; break; case MotorId::ROADWARRIOR: out << "ROADWARRIOR"; break; case MotorId::ST24: out << "ST24"; break; case MotorId::UMAX: out << "UMAX"; break; diff --git a/backend/genesys/enums.h b/backend/genesys/enums.h index 02cfc43e..15ece9d3 100644 --- a/backend/genesys/enums.h +++ b/backend/genesys/enums.h @@ -186,6 +186,7 @@ enum class ModelId : unsigned PLUSTEK_OPTICFILM_7500I, PLUSTEK_OPTICFILM_8200I, PLUSTEK_OPTICPRO_3600, + PLUSTEK_OPTICPRO_A320E, PLUSTEK_OPTICPRO_ST12, PLUSTEK_OPTICPRO_ST24, SYSCAN_DOCKETPORT_465, @@ -247,6 +248,7 @@ enum class SensorId : unsigned CCD_PLUSTEK_OPTICFILM_7500I, CCD_PLUSTEK_OPTICFILM_8200I, CCD_PLUSTEK_OPTICPRO_3600, + CCD_PLUSTEK_OPTICPRO_A320E, CCD_ROADWARRIOR, CCD_ST12, // SONY ILX548: 5340 Pixel ??? CCD_ST24, // SONY ILX569: 10680 Pixel ??? @@ -308,6 +310,7 @@ enum class AdcId : unsigned PLUSTEK_OPTICFILM_7500I, PLUSTEK_OPTICFILM_8200I, PLUSTEK_OPTICPRO_3600, + PLUSTEK_OPTICPRO_A320E, WOLFSON_5345, WOLFSON_DSM600, WOLFSON_HP2300, @@ -368,6 +371,7 @@ enum class GpioId : unsigned PLUSTEK_OPTICFILM_7500I, PLUSTEK_OPTICFILM_8200I, PLUSTEK_OPTICPRO_3600, + PLUSTEK_OPTICPRO_A320E, ST12, ST24, UMAX, @@ -411,6 +415,7 @@ enum class MotorId : unsigned PLUSTEK_OPTICFILM_7500I, PLUSTEK_OPTICFILM_8200I, PLUSTEK_OPTICPRO_3600, + PLUSTEK_OPTICPRO_A320E, ROADWARRIOR, ST24, UMAX, diff --git a/backend/genesys/genesys.cpp b/backend/genesys/genesys.cpp index d7ec4229..9d1340c9 100644 --- a/backend/genesys/genesys.cpp +++ b/backend/genesys/genesys.cpp @@ -52,6 +52,7 @@ #include "../include/sane/sanei_config.h" #include +#include #include #include #include @@ -155,6 +156,15 @@ static const SANE_Range expiration_range = { 1 /* quantization */ }; +/** + * range for the A320E user origin correction, in mm + */ +static const SANE_Range origin_offset_range = { + float_to_fixed(-5), // minimum + float_to_fixed(5), // maximum + float_to_fixed(0.01f) // quantization +}; + const Genesys_Sensor& sanei_genesys_find_sensor_any(const Genesys_Device* dev) { DBG_HELPER(dbg); @@ -332,6 +342,62 @@ void sanei_genesys_create_gamma_table(std::vector& gamma_table, i DBG(DBG_proc, "%s: completed\n", __func__); } +/* White-level compensation for the A320E (measured 2026-08-28, runs 324-327). + * + * The shading divisor maps the CALIBRATION white to target_code = 0x8000, + * half of full scale, and that margin is mandatory: the ASIC's division + * WRAPS instead of saturating, so an original brighter than the calibration + * reference turns black. See the target_code comment for this sensor in + * genesys_send_shading_coefficient, and runs 122-132 behind it. + * + * The consequence is at the other end. Ordinary white paper measures 1.36x + * the calibration reference on this scanner, so it leaves the driver at + * 174/255 instead of near white: the image is uniformly 1.4x too dark and + * only some 150 of the 256 output codes are ever used. Measured against the + * vendor's own driver, on the same original in the same position, that one + * factor was the ONLY difference between the two images -- tone 1.42x, noise + * 1.41x and gradient 1.48x all scale together, so signal-to-noise and + * sharpness are identical and nothing but the scale differs. Neither + * histogram has a single empty bin, so the vendor's wider range is not a + * stretch of an 8-bit image either; both come from the same 16-bit data. + * + * The brightness therefore belongs here, in the gamma LUT, which the ASIC + * applies AFTER the shading division and which saturates rather than wraps: + * + * y = SCALE * x, clamped to full scale + * + * a straight gain, and above the calibration white a clip. + * + * This first shipped with a knee at 90 % of full scale that compressed + * everything above it, so that the curve mapped 1 to 1 and could not clip at + * all. Measured on a real original that was the wrong trade (run K001, + * 2026-08-28): the knee gave the top third of the raw range a slope five + * times shallower than the rest, squeezing 91 raw codes into 25 output codes, + * and 13 % of the red channel's pixels sat in that band -- bright areas came + * out flat. A flat field cannot show this, only real content can, which is + * why the knee survived the runs that a white sheet was enough for. + * + * Straight gain costs almost nothing at the other end. The calibration white + * is 0.714 of raw full scale, so ordinary paper lands just below the top and + * only an original brighter than the calibration reference clips: on K001 the + * brightest raw value is 0.734 and 41 pixels of 3.6 million reach full scale, + * while the former knee band regains a 4.6x wider spread. The vendor clips + * 99.4 % of its blue channel on plain white paper by comparison. + * + * 16-bit output keeps the linear data: gamma is off at depth 16. + */ +static const float A320E_WHITE_SCALE = 1.40f; + +static void a320e_apply_white_scale(std::vector& table, int max) +{ + for (auto& entry : table) { + float x = static_cast(entry) / max; + int scaled = static_cast(std::lround(A320E_WHITE_SCALE * x * max)); + entry = static_cast(std::min(std::max(scaled, 0), max)); + } +} + + void sanei_genesys_create_default_gamma_table(Genesys_Device* dev, std::vector& gamma_table, float gamma) { @@ -355,6 +421,10 @@ void sanei_genesys_create_default_gamma_table(Genesys_Device* dev, max = 65535; } sanei_genesys_create_gamma_table(gamma_table, size, max, max, gamma); + + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + a320e_apply_white_scale(gamma_table, max); + } } /* computes the exposure_time on the basis of the given vertical dpi, @@ -533,6 +603,45 @@ void scanner_send_slope_table(Genesys_Device* dev, const Genesys_Sensor& sensor, } } + /* A320E: THE TABLE IS PADDED TO THE VENDOR'S FULL 512 ENTRIES. + * + * The slope table addresses are fixed (0x10000000 + 0x4000 * table_nr) + * and only table.size() bytes are written, so everything past the entries + * this session uses is whatever the previous session left there -- a + * 200 dpi run writes 192 entries, a 600 dpi run after it writes 6 and + * leaves 186 of the earlier ones standing. Nothing reads them today, + * because every length register the driver writes (STEPNO, FASTNO, + * FSHDEC, FMOVNO, FMOVDEC) is table.size(); this makes the region + * defined before FMOVNO is set to the vendor's 512, which is the change + * that would read it. See notes/ENNAKKOANALYYSIT.md, chapter + * "0xac/0xad FSHDEC, 0xae/0xaf FMOVNO, 0xb0/0xb1 FMOVDEC". + * + * 512 entries is measured, not assumed: `ahbdump.py` on the vendor's + * captures shows 1024 bytes to each of the five tables, at 200, 600 and + * 800 dpi. It is NOT get_slope_table_max_size(GL124) = 1024 entries, + * which would be twice what the vendor loads. + * + * The padding value is the last entry, i.e. the plateau -- the same + * choice upstream makes above, and the safe continuation: "keep running + * at the speed the ramp ended at". + * + * A/B without recompiling: A320E_FULL_SLOPE_TABLE=0 + */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E && !slope_table.empty()) { + bool full_table = true; + if (const char* env = std::getenv("A320E_FULL_SLOPE_TABLE")) { + full_table = std::strtoul(env, nullptr, 0) != 0; + } + if (full_table) { + const std::size_t a320e_table_entries = 512; + table.reserve(a320e_table_entries * 2); + while (table.size() < a320e_table_entries * 2) { + table.push_back(slope_table.back() & 0xff); + table.push_back(slope_table.back() >> 8); + } + } + } + if (dev->interface->is_mock()) { dev->interface->record_slope_table(table_nr, slope_table); } @@ -788,6 +897,15 @@ void scanner_move(Genesys_Device& dev, ScanMethod scan_method, unsigned steps, D session.params.flags |= ScanFlag::DISABLE_BUFFER_FULL_MOVE; } + // A320E: the return runs with the lamp already dark, like the vendor's. + // Only backward moves: a forward move is the fast approach to the scan + // area right before the image scan, and power-cycling the LED there would + // change the light level the shading correction rests on. See low.cpp, + // a320e_lamp_off_on_return. + if (direction == Direction::BACKWARD && a320e_lamp_off_on_return(dev)) { + session.params.flags |= ScanFlag::DISABLE_LAMP; + } + if (direction == Direction::BACKWARD) { session.params.flags |= ScanFlag::REVERSE; } @@ -921,6 +1039,10 @@ void scanner_move_back_home(Genesys_Device& dev, bool wait_until_home) if (status.is_at_home) { dbg.log(DBG_info, "already at home"); + // A320E: an earlier open-ended home run may still be in progress even + // though HOMESNR is already up -- the leftover steps grind against the + // end stop. + a320e_stop_motor_at_home(dev); dev.set_head_pos_zero(ScanHeadId::PRIMARY); return; } @@ -952,6 +1074,11 @@ void scanner_move_back_home(Genesys_Device& dev, bool wait_until_home) ScanFlag::IGNORE_COLOR_OFFSET | ScanFlag::REVERSE; + // A320E: the slow home run is part of the return, so it too goes dark. + if (a320e_lamp_off_on_return(dev)) { + session.params.flags |= ScanFlag::DISABLE_LAMP; + } + if (dev.model->asic_type == AsicType::GL843) { session.params.flags |= ScanFlag::DISABLE_BUFFER_FULL_MOVE; } @@ -989,7 +1116,13 @@ void scanner_move_back_home(Genesys_Device& dev, bool wait_until_home) } if (wait_until_home) { - for (unsigned i = 0; i < 300; ++i) { + // A320E: 300 x 100 ms = 30 s of driving the motor against the wall if + // HOMESNR never rises. When it succeeds, home is found on the first poll. + unsigned max_polls = 300; + if (unsigned a320e_ms = a320e_home_timeout_ms(dev)) { + max_polls = a320e_ms / 100; + } + for (unsigned i = 0; i < max_polls; ++i) { auto status = scanner_read_status(dev); if (status.is_at_home) { @@ -999,6 +1132,9 @@ void scanner_move_back_home(Genesys_Device& dev, bool wait_until_home) { scanner_stop_action(dev); } + // A320E (GL124): upstream leaves the motor running on anything + // other than GL846/847. See low.cpp, a320e_stop_motor_at_home. + a320e_stop_motor_at_home(dev); dev.set_head_pos_zero(ScanHeadId::PRIMARY); return; } @@ -1383,8 +1519,461 @@ bool should_calibrate_only_active_area(const Genesys_Device& dev, return false; } +/** @brief calibration pixel count for the Plustek OpticPro A320E + * + * Two corrections to the same number: + * + * 1. `Genesys_Model::x_size_calib_mm` is a `float`, so 304.8 is stored as + * 304.79998779296875 and `304.8 * 400 / 25.4` truncates 4800 down to + * **4799**. + * 2. GL124 handles pixels in groups whose size is + * `full_resolution / optical_resolution` (800/400 = **2** on the A320E). + * An odd request leaves the hardware with an incomplete group: the device + * sends the full group and the bulk read receives more than it asked for + * -> `Overflow`. + * + * Rounding up to a multiple of the group size fixes both. Measured in run 8, + * see notes/usb/HAVAINNOT.md, chapter "Overflow'n mekanismi mitattu". + */ +static unsigned a320e_align_calib_pixels(unsigned pixels, const Genesys_Sensor& sensor, + unsigned resolution) +{ + unsigned group = (resolution > 0) ? sensor.full_resolution / resolution : 1; + if (group == 0) { + group = 1; + } + return ((pixels + group - 1) / group) * group; +} + +/** @brief the Windows driver's calibration window for the Plustek OpticPro A320E + * + * In capture 05b the offset and gain calibrations run with a 512-pixel window + * (STRPIXEL = 23, ENDPIXEL = 535), not across the full A3 width as SANE's + * generic code assumes. 512 x 3 = 1536 bytes, and the number is a power of two, + * hence always a multiple of the hardware's pixel group. + */ +static const unsigned A320E_CALIB_PIXELS = 512; + +/* Offset calibration for the Plustek OpticPro A320E + * ------------------------------------------------- + * The generic search below calibrates nothing on this device: it ends at its + * own lower bound of 11 on every channel, every resolution and every run. + * Four separate causes were measured (2026-08-26, notes/SEURAAMATTOMAT.md + * group A, tools/kalibtavoite.py): + * + * 1. THE MEASUREMENT. Upstream reads the shielded black pixels at depth 8, + * where one AFE code moves the reading by 0.078 DN. The whole search then + * sees 0..2 DN and any bisection settles at its bound. The same pixels + * read at depth 16 give 19.9 DN per code, which is the vendor's own slope + * at gain 0 (19.2..19.7 measured from four captures). Depth 16 is a + * precondition, not a refinement. + * 2. THE SIGN BIT. The AD9822's offset register is 9 bit, D8 = sign and + * D7..D0 = magnitude (docs/AD9822.pdf, Table 6). Searching 10..255 leaves + * D8 at zero and can never reach the negative side; the vendor ends at -52 + * for red at 800 dpi (capture 07, tools/afedump.py). + * 3. THE TARGET. The bisection keeps the half with the SMALLER dark average, + * i.e. it minimises. The vendor solves the dark end of the distribution to + * zero and then climbs two fixed steps of `PixelDark` = 0x100 DN each, + * landing one to two 8 bit counts above zero. A pedestal, not a minimum: + * at zero the ADC rectifies the dark noise and the shading offset + * coefficient saturates. + * 4. THE GAIN. The offset DAC sits before the PGA, so the offset that hits a + * given pedestal changes once the gain is set. The vendor runs the whole + * round twice, the second time with the operating gain; at 800 dpi its + * solution moves from -48 to -58. Hence the `second_round` parameter and + * the second call after the coarse gain calibration. + */ + +// `PixelDark` from the vendor's ScanApi_07b3_1826.ini. Its unit is a DN of the +// 16 bit calibration read, i.e. 0x100 is one count of an 8 bit output; the +// scale is measured in notes/linux/README.md, "`.ini`:n mittakaava mitattu". +static const int A320E_PIXEL_DARK = 0x100; + +// Where the vendor's two refinement steps leave the dark level. +static const int A320E_DARK_PEDESTAL = 2 * A320E_PIXEL_DARK; + +// A tap difference beyond this is a broken read, not a pedestal. +static const int A320E_MAX_TAP_SPLIT = 8 * A320E_PIXEL_DARK; + +/* A320E: how close the dark level must come to the target before the + * refinement loop calls itself done. + * + * This used to be A320E_PIXEL_DARK itself, and that was never a choice: the + * same constant defines the target (2x) and served as the tolerance (1x), so + * the loop accepted any dark level between 256 and 768 against a target of + * 512. MEASURED 2026-09-02 (runs 377-386): every dark level the R channel + * produces (401..746) falls inside that band, so the loop always finished at + * pass 0 and A320E_REFINE_PASSES = 4 never ran more than one pass. The image + * level is bimodal as a result -- R lands at ~136.4 or ~141.8 DN depending on + * which side of offset code 19/20 the single unrefined solve happens to fall. + * + * The phenomenon it has to catch is 215 DN, about 3 offset codes: the R + * channel's black margin holds a per-run DC state, stable within a run to + * 44 DN but stepping ~200 DN between runs. A tolerance of 256 DN is wider + * than the whole phenomenon. + * + * A tolerance below half the coarsest code step cannot be reached at all: + * one code moves the dark level 72..75 DN on R, 46 on G, 52 on B, so the + * best achievable error is about 37 DN. + * + * A320E_OFFSET_TOL= overrides it. The default is unchanged from the + * measured behaviour on purpose -- see ROADMAP.md, tolerance experiment. + */ +static int a320e_offset_tolerance() +{ + if (const char* env = std::getenv("A320E_OFFSET_TOL")) { + return static_cast(std::strtoul(env, nullptr, 0)); + } + return A320E_PIXEL_DARK; +} + +// Pixels skipped between the black margin and the lit window: the transition +// is not sharp and the first lit columns still carry part of the shield. +static const unsigned A320E_TAP_SPLIT_GUARD = 4; + +// Below this many usable samples the lit window is not trustworthy and the +// measurement falls back to the black margin. +static const unsigned A320E_TAP_SPLIT_MIN_SAMPLES = 64; + +// A sample this high may be clipped, and a clipped pixel has no parity. +static const std::uint16_t A320E_TAP_SPLIT_CEILING = 64000; + +static const int A320E_PROBE_LOW = 128; +static const int A320E_PROBE_HIGH = 255; +static const int A320E_OFFSET_MAX = 255; +static const unsigned A320E_REFINE_PASSES = 4; + +/** @brief AD9822 offset code: 9 bits, D8 is the sign and D7..D0 the magnitude + */ +static std::uint16_t a320e_offset_code(int value) +{ + int magnitude = std::min(std::abs(value), A320E_OFFSET_MAX); + return static_cast(value < 0 ? (0x100 | magnitude) : magnitude); +} + +/** @brief period 2 parity of one window, as a local shape + * + * The sensor clocks odd and even pixels out through two separate pins, so a + * tap difference appears as a strictly alternating component. Measuring it as + * "mean of the even columns minus mean of the odd ones" also picks up anything + * that merely slopes across the window: on run 273 that form read 1095 DN on a + * channel whose true difference was 2 DN. This form, + * + * a(x) = (-1)^x * ( v[x] - (v[x-1] + v[x+1]) / 2 ) + * + * cancels everything that changes more slowly than every other pixel, and for + * a pure alternation of a and b it returns a - b, the whole difference. It is + * necessary but not sufficient: on the black margin of that same run it still + * read 405 DN, because the margin carries an alternating artefact of its own. + * The window matters as much as the form -- see a320e_tap_split. + * + * @param samples receives the number of usable samples; the caller decides + * whether they are enough + */ +static double a320e_window_parity(const Image& image, unsigned first, unsigned last, + unsigned channel, unsigned& samples) +{ + double sum = 0.0; + samples = 0; + + if (last > image.get_width()) { + last = image.get_width(); + } + if (first + 2 >= last) { + return 0.0; + } + + // Row 0 is skipped for the same reason as in dark_average_channel. + for (std::size_t y = 1; y < image.get_height(); y++) { + for (unsigned x = first + 1; x + 1 < last; x++) { + std::uint16_t prev = image.get_raw_channel(x - 1, y, channel); + std::uint16_t curr = image.get_raw_channel(x, y, channel); + std::uint16_t next = image.get_raw_channel(x + 1, y, channel); + if (prev >= A320E_TAP_SPLIT_CEILING || curr >= A320E_TAP_SPLIT_CEILING || + next >= A320E_TAP_SPLIT_CEILING) + { + continue; + } + double a = curr - (prev + next) / 2.0; + sum += (x % 2 == 0) ? a : -a; + samples++; + } + } + return samples > 0 ? sum / samples : 0.0; +} + +/** @brief distance between the two taps of the sensor + * + * The sensor clocks odd and even pixels out through two separate pins whose + * pedestals differ; on this device the difference is about 1400 DN at 600 dpi + * and it is additive, i.e. the same at every light level + * (notes/linux/README.md, "MIKSI mustataso on pielessa"). The AD9822 has ONE + * offset register per channel, which is a common mode control: it decides + * where the pair sits as a whole and cannot narrow the gap. Aiming the MEAN at + * the pedestal would therefore leave the lower tap below zero, where the ADC + * rectifies it, so the offset search aims the LOWER tap (a320e_dark_level). + * This function measures how far apart the two are, which is worth following + * per run even though the target no longer contains it. + * + * The measurement is taken on the LIT part of the calibration window, not on + * the black margin. MEASURED 2026-08-27 from the probe scans of runs 272-277, + * against the difference the loaded coefficients show: + * + * window 600 dpi, share of the true difference + * black margin 63 .. 88 %, and R moved 29 % between two runs + * lit window 92 .. 101 %, and the two runs agree within 2 % + * + * The margin reads low because it is shielded: what little signal it carries + * is close to the bottom of the range, where the lower tap is already partly + * rectified -- exactly the effect being measured. The lit window has signal to + * spare (5 .. 31 kDN in those runs, nothing near clipping) and the local form + * above rejects the image content the window may carry. + * + * The black margin remains as the fallback for the case where the lit window + * is clipped away. Since 2026-08-27 the estimate no longer enters the target + * at all -- a320e_dark_level reads the lower tap itself -- so this is a + * diagnostic: it is what the log reports and what tools/tapit.py is compared + * against, not a term the calibration depends on. + */ +static int a320e_tap_split(const Image& image, unsigned black_pixels, unsigned channel) +{ + unsigned samples = 0; + double parity = a320e_window_parity(image, black_pixels + A320E_TAP_SPLIT_GUARD, + image.get_width(), channel, samples); + + if (samples < A320E_TAP_SPLIT_MIN_SAMPLES) { + parity = a320e_window_parity(image, 0, black_pixels, channel, samples); + if (samples == 0) { + return 0; + } + } + return static_cast(std::abs(parity)); +} + +/** @brief dark level of the black margin: the LOWER TAP, per parity group + * + * Upstream's dark_average_channel takes the mean of the margin, and on this + * device that is not the level the search is after. Three things stand in the + * way, all MEASURED on the probe scans of runs 272-300: + * + * - single hot columns. Run 280 has one at 29912 DN where its neighbours + * read 5045; one such column in a margin of 43 moves the mean by 290 DN. + * - stray light leaking in from the lit edge, which raises the last columns + * of the margin in a ramp. Which parity of column carries the ramp varies + * from run to run, like everything else about this sensor's parity. + * - the tap difference itself, which the margin carries in full: the even + * columns sit at ~1650 and the odd ones at ~2990, 44 of each, so the + * distribution is BIMODAL with its two modes some 1300 DN apart. + * + * A plain median answers the first two -- it put the mean 662..685 DN above + * itself on the green channel of runs 280 and 281, and an inflated level makes + * the search choose a code that much too low, where nothing downstream can see + * the damage: the probes run with the LAMP ON while the dark shading that has + * to survive runs with it off, and run 280's green channel came out of the dark + * shading with 69 % of its samples rectified to zero. + * + * Against the third it is defenceless. It sat on the LOWER mode only because + * the first ~24 columns of the margin are irregular, a balance that measured + * 49.7..62.2 % over 15 channel-runs, and it has already tipped: run 290 read + * R 2876 and G 2689 where the lower tap was 1771 and 1718, and two otherwise + * identical runs of the same series (290 and 299) differed by more than 1100 DN + * on the same channel. + * + * Splitting by parity FIRST makes each distribution single-peaked, so a group's + * median is stable, and the lower of the two groups is the lower tap -- which + * is what the offset search aims at (see a320e_tap_split). The groups separate + * by 1279..1567 DN on every channel of every run of burst 7 (runs 288-300), so + * the lower one is unambiguous. WHICH parity it is varies from run to run -- + * run 292 has them the other way round, the same coin toss as the sign of the + * calibration parity -- hence min(), never a fixed group. + * + * Because the level is now the lower tap itself, the caller's target no longer + * has to carry the tap difference as a safety margin: it is the vendor's own + * 2 x PixelDark. Aiming the LOW END of the window's distribution is what the + * vendor's procedure implies as well (battery test B3). + */ +static int a320e_dark_level(const Image& image, unsigned black_pixels, unsigned channel) +{ + if (image.get_height() < 2 || black_pixels == 0) { + return 0; + } + + std::array, 2> groups; + const std::size_t per_group = (image.get_height() - 1) * (black_pixels / 2 + 1); + groups[0].reserve(per_group); + groups[1].reserve(per_group); + + // Row 0 is skipped for the same reason as in dark_average_channel. + for (std::size_t y = 1; y < image.get_height(); y++) { + for (unsigned x = 0; x < black_pixels; x++) { + groups[x % 2].push_back(image.get_raw_channel(x, y, channel)); + } + } + + int level = 0; + bool found = false; + for (auto& group : groups) { + if (group.empty()) { + continue; // a margin of a single column has no parity + } + auto middle = group.begin() + group.size() / 2; + std::nth_element(group.begin(), middle, group.end()); + if (!found || *middle < level) { + level = *middle; + found = true; + } + } + return level; +} + +/** @brief the vendor's offset procedure, for the A320E only + * + * Two probes (+128 and +255) give the slope in DN per code, the slope gives + * the code that puts the dark level at the target, and a few refinement passes + * correct what the linearity assumption misses. Four to six scans, where the + * generic bisection needs nine. + * + * @param second_round false for the round at gain 0, true for the round that + * runs after the coarse gain calibration with the operating gain + */ +static void a320e_offset_calibration(Genesys_Device& dev, const Genesys_Sensor& calib_sensor, + Genesys_Register_Set& regs, const ScanSession& session, + unsigned black_pixels, bool second_round) +{ + DBG_HELPER_ARGS(dbg, "%s round", second_round ? "second" : "first"); + + if (!second_round) { + // The first round measures at gain 0, as upstream does. The second one + // must NOT reset the gain: its whole purpose is to redo the offset with + // the gain the coarse gain calibration has just computed. + for (unsigned ch = 0; ch < 3; ch++) { + dev.frontend.set_gain(ch, 0); + } + } + + std::array codes = {{ A320E_PROBE_LOW, A320E_PROBE_LOW, A320E_PROBE_LOW }}; + std::array split = {{ 0, 0, 0 }}; + + auto write_afe = [&]() { + for (unsigned ch = 0; ch < 3; ch++) { + dev.frontend.set_offset(ch, a320e_offset_code(codes[ch])); + } + dev.cmd_set->set_fe(&dev, calib_sensor, AFE_SET); + dev.interface->write_registers(regs); + }; + + auto measure = [&](bool want_split) { + write_afe(); + dev.cmd_set->begin_scan(&dev, calib_sensor, ®s, true); + + auto image = read_unshuffled_image_from_scanner(&dev, session, + session.output_total_bytes); + std::array avg = {{ 0, 0, 0 }}; + for (unsigned ch = 0; ch < 3; ch++) { + avg[ch] = a320e_dark_level(image, black_pixels, ch); + if (want_split) { + // One probe is enough. The two probes were compared on the + // captures of runs 272-277 and their estimates agree to within + // 8 %, on every channel of every run, so taking the larger of + // the two would buy nothing for the price of carrying it. + split[ch] = std::min(a320e_tap_split(image, black_pixels, ch), + A320E_MAX_TAP_SPLIT); + } + } + return avg; + }; + + if (is_testing_mode()) { + // The test backend reads no images; keep upstream's contract of a + // single checkpoint after the first scan is started. + write_afe(); + dev.cmd_set->begin_scan(&dev, calib_sensor, ®s, true); + dev.interface->test_checkpoint("offset_calibration"); + return; + } + + const std::array low = measure(true); + codes = {{ A320E_PROBE_HIGH, A320E_PROBE_HIGH, A320E_PROBE_HIGH }}; + const std::array high = measure(false); + + std::array slope = {{ 0.0, 0.0, 0.0 }}; + std::array target = {{ 0, 0, 0 }}; + + for (unsigned ch = 0; ch < 3; ch++) { + slope[ch] = static_cast(high[ch] - low[ch]) / + (A320E_PROBE_HIGH - A320E_PROBE_LOW); + // The vendor's own target, unmodified. Until 2026-08-27 the whole + // tap difference was added to it as a safety margin, because the + // level being aimed was the median of a BIMODAL distribution and + // could jump a mode between two identical runs (run 290). Now that + // a320e_dark_level reads the lower tap directly, the margin has + // nothing left to protect against, and it cost a black level of some + // 3 % of full scale. split[ch] stays as a diagnostic only: the log + // line below is how the tap difference is followed from run to run. + target[ch] = A320E_DARK_PEDESTAL; + + DBG(DBG_info, "%s: ch %d: %d -> %d, %d -> %d, slope %.2f DN/code, " + "tap split %d, target %d\n", __func__, ch, + A320E_PROBE_LOW, low[ch], A320E_PROBE_HIGH, high[ch], slope[ch], + split[ch], target[ch]); + + if (slope[ch] <= 0.0) { + // The offset does not move the reading: the scan is broken or the + // black margin is saturated. Keep the probe that lands closer to + // the target rather than extrapolating from a slope of zero. + DBG(DBG_error, "%s: ch %d: offset does not move the dark level " + "(%d -> %d), this channel is not calibrated\n", + __func__, ch, low[ch], high[ch]); + codes[ch] = std::abs(low[ch] - target[ch]) <= std::abs(high[ch] - target[ch]) + ? A320E_PROBE_LOW : A320E_PROBE_HIGH; + slope[ch] = 0.0; + continue; + } + + int solved = A320E_PROBE_LOW + + static_cast(std::lround((target[ch] - low[ch]) / slope[ch])); + codes[ch] = clamp(solved, -A320E_OFFSET_MAX, A320E_OFFSET_MAX); + } + + const int tolerance = a320e_offset_tolerance(); + + for (unsigned pass = 0; pass < A320E_REFINE_PASSES; pass++) { + const std::array avg = measure(false); + bool done = true; + + for (unsigned ch = 0; ch < 3; ch++) { + int error = target[ch] - avg[ch]; + DBG(DBG_info, "%s: pass %d ch %d: offset %d -> dark %d (target %d)\n", + __func__, pass, ch, codes[ch], avg[ch], target[ch]); + + if (slope[ch] <= 0.0 || std::abs(error) <= tolerance) { + continue; + } + int step = static_cast(std::lround(error / slope[ch])); + if (step == 0) { + step = error > 0 ? 1 : -1; + } + int next = clamp(codes[ch] + step, -A320E_OFFSET_MAX, A320E_OFFSET_MAX); + if (next == codes[ch]) { + continue; // already at the end of the range + } + codes[ch] = next; + done = false; + } + if (done) { + break; + } + } + + write_afe(); + + DBG(DBG_info, "%s: offset=(%d,%d,%d), AFE 0x%03x/0x%03x/0x%03x\n", __func__, + codes[0], codes[1], codes[2], a320e_offset_code(codes[0]), + a320e_offset_code(codes[1]), a320e_offset_code(codes[2])); +} + void scanner_offset_calibration(Genesys_Device& dev, const Genesys_Sensor& sensor, - Genesys_Register_Set& regs) + Genesys_Register_Set& regs, bool second_round) { DBG_HELPER(dbg); @@ -1433,6 +2022,31 @@ void scanner_offset_calibration(Genesys_Device& dev, const Genesys_Sensor& senso unsigned resolution = sensor.full_resolution; const Genesys_Sensor* calib_sensor = &sensor; + + if (dev.model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // The A320E is the only GL124 device that gets this far (the others + // have a ti AFE). In its sensor table the optical resolution is 400 or + // 800 depending on what was requested, so calibration has to run at the + // scan's own optical resolution; full_resolution would exceed it and + // compute_session would throw "output resolution higher than optical + // resolution". + resolution = sensor.get_optical_resolution(); + calib_sensor = &sanei_genesys_find_sensor(&dev, resolution, channels, + dev.settings.scan_method); + target_pixels = a320e_align_calib_pixels(A320E_CALIB_PIXELS, sensor, resolution); + black_pixels = (calib_sensor->black_pixels * resolution) / sensor.full_resolution; + + + // dark_average_channel starts its loop at row y = 1 (upstream's own + // FIXME: the first row carries noise from the previous scan). With + // `lines = 1` the loop never runs, count stays at zero and the average + // is always 0 -- the offset calibration's binary search sees the same 0 + // for every AFE value and calibrates nothing. Only GL843 and + // CANON_5600F set this above 1 so far; LiDE devices never get here + // (SIFSEL == 3 returns earlier), so the A320E is the first device to + // hit it. The same 8 as on GL843. + lines = 8; + } if (dev.model->asic_type == AsicType::GL843) { lines = 8; @@ -1492,7 +2106,9 @@ void scanner_offset_calibration(Genesys_Device& dev, const Genesys_Sensor& senso session.params.starty = 0; session.params.pixels = target_pixels; session.params.lines = lines; - session.params.depth = 8; + // A320E: at depth 8 one AFE code moves the shielded pixels by 0.078 DN and + // no search can see it; see the comment above a320e_offset_calibration. + session.params.depth = dev.model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E ? 16 : 8; session.params.channels = channels; session.params.scan_method = dev.settings.scan_method; session.params.scan_mode = ScanColorMode::COLOR_SINGLE_PASS; @@ -1501,6 +2117,13 @@ void scanner_offset_calibration(Genesys_Device& dev, const Genesys_Sensor& senso session.params.contrast_adjustment = dev.settings.contrast; session.params.brightness_adjustment = dev.settings.brightness; session.params.exposure_lperiod = dev.settings.exposure_lperiod; + if (dev.model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // A320E: the calibration session runs at calib_sensor's resolution, so + // the exposure has to come from the same sensor entry. Otherwise the + // scan's lperiod (6256 @ 200 dpi, say) matches no 400 dpi motor profile + // and get_motor_profile throws "Motor slope is not configured". + session.params.exposure_lperiod = calib_sensor->exposure_lperiod; + } session.params.flags = flags; compute_session(&dev, session, *calib_sensor); @@ -1510,6 +2133,11 @@ void scanner_offset_calibration(Genesys_Device& dev, const Genesys_Sensor& senso sanei_genesys_set_motor_power(regs, false); + if (dev.model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + a320e_offset_calibration(dev, *calib_sensor, regs, session, black_pixels, second_round); + return; + } + int top[3], bottom[3]; int topavg[3], bottomavg[3], avg[3]; @@ -1786,6 +2414,13 @@ void scanner_coarse_gain_calibration(Genesys_Device& dev, const Genesys_Sensor& dev.settings.scan_method); } + if (dev.model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // see the same rationale in scanner_offset_calibration + resolution = sensor.get_optical_resolution(); + calib_sensor = &sanei_genesys_find_sensor(&dev, resolution, channels, + dev.settings.scan_method); + } + ScanFlag flags = ScanFlag::DISABLE_SHADING | ScanFlag::DISABLE_GAMMA | ScanFlag::SINGLE_LINE | @@ -1804,6 +2439,18 @@ void scanner_coarse_gain_calibration(Genesys_Device& dev, const Genesys_Sensor& session.params.startx = 0; session.params.starty = 0; session.params.pixels = dev.model->x_size_calib_mm * resolution / MM_PER_INCH; + if (dev.model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // Gain is measured across the WHOLE width of the strip, not from a + // 512 px window at the edge: that window lies entirely within the + // vignetting ramp (in the vendor's white data the level there is ~10 % + // of the centre), so a gain aimed at its average overcompensates by + // ~10x and saturates the strip's bright centre during white shading. + // Evenness has to be forced here: align is a no-op when group = 1. + unsigned gain_pixels = static_cast( + dev.model->x_size_calib_mm * resolution / MM_PER_INCH); + gain_pixels = a320e_align_calib_pixels(gain_pixels, sensor, resolution); + session.params.pixels = gain_pixels & ~1u; + } session.params.lines = lines; session.params.depth = dev.model->asic_type == AsicType::GL841 ? 16 : 8; session.params.channels = channels; @@ -1813,6 +2460,13 @@ void scanner_coarse_gain_calibration(Genesys_Device& dev, const Genesys_Sensor& session.params.contrast_adjustment = dev.settings.contrast; session.params.brightness_adjustment = dev.settings.brightness; session.params.exposure_lperiod = dev.settings.exposure_lperiod; + if (dev.model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // A320E: the calibration session runs at calib_sensor's resolution, so + // the exposure has to come from the same sensor entry. Otherwise the + // scan's lperiod (6256 @ 200 dpi, say) matches no 400 dpi motor profile + // and get_motor_profile throws "Motor slope is not configured". + session.params.exposure_lperiod = calib_sensor->exposure_lperiod; + } session.params.flags = flags; compute_session(&dev, session, *calib_sensor); @@ -1888,6 +2542,19 @@ void scanner_coarse_gain_calibration(Genesys_Device& dev, const Genesys_Sensor& curr_output = static_cast(values[unsigned((values.size() - 1) * 0.95)]); target_value = calib_sensor->gain_white_ref * coeff; + } else if (dev.model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // The 95th percentile instead of the average: the gain has to keep + // the BRIGHTEST part of the strip on scale, not lift the average to + // the target -- the average path would saturate the centre again. + // Row 0, because the GL124 read fetches only one row. + std::vector values; + for (std::size_t x = pixels / 4; x < (pixels * 3 / 4); x++) { + values.push_back(image.get_raw_channel(x, 0, ch)); + } + std::sort(values.begin(), values.end()); + curr_output = static_cast(values[unsigned((values.size() - 1) * 0.95)]); + target_value = calib_sensor->gain_white_ref * coeff; + } else { // FIXME: use the GL843 approach auto width = image.get_width(); @@ -1903,6 +2570,7 @@ void scanner_coarse_gain_calibration(Genesys_Device& dev, const Genesys_Sensor& std::uint8_t out_gain = compute_frontend_gain(curr_output, target_value, dev.frontend.layout.type); + dev.frontend.set_gain(ch, out_gain); DBG(DBG_proc, "%s: channel %d, curr=%f, target=%f, out_gain:%d\n", __func__, ch, @@ -2009,6 +2677,13 @@ SensorExposure scanner_led_calibration(Genesys_Device& dev, const Genesys_Sensor session.params.contrast_adjustment = dev.settings.contrast; session.params.brightness_adjustment = dev.settings.brightness; session.params.exposure_lperiod = dev.settings.exposure_lperiod; + if (dev.model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // A320E: the calibration session runs at calib_sensor's resolution, so + // the exposure has to come from the same sensor entry. Otherwise the + // scan's lperiod (6256 @ 200 dpi, say) matches no 400 dpi motor profile + // and get_motor_profile throws "Motor slope is not configured". + session.params.exposure_lperiod = calib_sensor.exposure_lperiod; + } session.params.flags = ScanFlag::DISABLE_SHADING | ScanFlag::DISABLE_GAMMA | ScanFlag::SINGLE_LINE | @@ -3407,6 +4082,54 @@ static void genesys_send_shading_coefficient(Genesys_Device* dev, const Genesys_ coeff, target_code); break; + case SensorId::CCD_PLUSTEK_OPTICPRO_A320E: + /* EARLIER ASSUMPTION REFUTED (run 48 + capture 03, frame 2145): + * the shading memory is NOT planar but INTERLEAVED per pixel -- the + * dark values in the Windows upload repeat with a period of 3 + * (R,G,B,R,G,B), and the whole window is uploaded as a SINGLE AHB + * write to address 0x0a*8192+0x10000000 (0xd0=0xd1=0xd2=0x0a in every + * mode). The planar layout plus three writes to the same address + * produced the one-third effect: data the size of a single channel + * covers exactly the first third of the pixels of the interleaved + * table. + * The element is still (dark 16 bit, coefficient 16 bit) -- only the + * order and the transfer change. The average buffers are already + * interleaved (dark_average_data[channels*x+c]), so + * compute_coefficients fits as is. Transfer: gl124.cpp + * send_shading_data, A320E branch. + */ + /* CONFIRMED (runs 131/132, 2026-08-20, target the only difference): + * 0xdc00 -> 30.7 % of data, 0x8000 -> 100.0 % on every channel. + * The margin is mandatory: see the explanation below. + */ + target_code = 0x8000; + /* OVERFLOW MARGIN (runs 122-132, 2026-08-20). + * + * The ASIC's division: out = (in - dark) * coeff / 0x4000. By + * definition the coefficient is 0x4000*target/(white-dark), so + * out = target when in = white. If the image data's in EXCEEDS the + * white level measured during calibration (say white was measured dim + * but the paper in the image is bright), the product exceeds 65535 and + * WRAPS -> zeros in the brightest places. Run 103 escaped this because + * white had saturated at 65535 (no in can exceed it); in runs 123-127 + * white was genuine (52k-62k) and the middle block died. The vendor + * uses coefficients of 19301-65535 (capture 03, frame 2605), so + * >0x4000 is allowed -- only the product's overflow is not. + * + * A smaller target gives the margin: at 0x8000 in may exceed the local + * white level by 2x before overflowing. In practice in/white stays + * below ~1.3 (the same vignetting in both), so 2x is enough even for a + * reference soiled by lines (run 132). The price: a nominal white level + * of ~0x8000, i.e. the image is linearly darker -- brightness belongs + * to gamma/ICC, not to the shading divisor. + * + */ + length = pixels_per_line * channels * 4; + shading_data.clear(); + shading_data.resize(length, 0); + compute_coefficients(dev, shading_data.data(), pixels_per_line, channels, + ColorOrder::RGB, 0, coeff, target_code); + break; case SensorId::CIS_CANON_LIDE_700F: case SensorId::CIS_CANON_LIDE_100: case SensorId::CIS_CANON_LIDE_200: @@ -3611,6 +4334,18 @@ static void genesys_flatbed_calibration(Genesys_Device* dev, Genesys_Sensor& sen dev->interface->record_progress_message("coarse_gain_calibration"); dev->cmd_set->coarse_gain_calibration(dev, sensor, local_reg, coarse_res); + + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // The AD9822's offset DAC sits before the PGA, so the offset that + // hits a given dark pedestal changes as soon as the gain is set. + // The vendor driver runs the whole offset round a second time here + // for exactly that reason; at 800 dpi its solution moves from -48 + // to -58 (capture 07, tools/afedump.py). Called directly rather + // than through cmd_set, because the repeat is specific to this + // device and the command set's signature has no room for it. + dev->interface->record_progress_message("offset_calibration (2nd round)"); + scanner_offset_calibration(*dev, sensor, local_reg, true); + } } if (dev->model->is_cis && @@ -3935,6 +4670,14 @@ static void init_regs_for_scan(Genesys_Device& dev, const Genesys_Sensor& sensor auto session = dev.cmd_set->calculate_scan_session(&dev, sensor, dev.settings); + // A320E: the vendor arms AGOHOME in the image scan itself (REG_0x02 = + // 0x30 in every capture), so the ASIC returns the carriage without any + // further bus traffic. Off by default -- see low.cpp, + // a320e_agohome_enabled, for why. + if (a320e_agohome_enabled(dev)) { + session.params.flags |= ScanFlag::AUTO_GO_HOME; + } + if (dev.model->asic_type == AsicType::GL124 || dev.model->asic_type == AsicType::GL845 || dev.model->asic_type == AsicType::GL846 || @@ -3965,6 +4708,10 @@ static void genesys_start_scan(Genesys_Device* dev, bool lamp_off) DBG_HELPER(dbg); unsigned int steps, expected; + // A320E: a new job -- everything from here to the last image byte is + // preparation, and the lamp stays lit through it. + dev->a320e_image_read_done = false; + /* since not all scanners are set to wait for head to park * we check we are not still parking before starting a new scan */ @@ -4151,7 +4898,30 @@ static void genesys_read_ordered_data(Genesys_Device* dev, SANE_Byte* destinatio /* end scan if all needed data have been read */ if(dev->total_bytes_read >= dev->total_bytes_to_read) { + // A320E: from here on a backward move is the return, and the lamp + // may go dark. Before this point it is preparation and must not. + dev->a320e_image_read_done = true; + + // A320E with AGOHOME: the ASIC began the return the instant the scan + // ended and needs about 1.7 s for it, producing no bus traffic while + // it does. Wait for home BEFORE end_scan, for two reasons: + // + // 1. end_scan's scanner_stop_action polls for the motor to stop for + // 10 x 100 ms and then throws "could not stop motor". During the + // ASIC's return the motor is running for a perfectly legitimate + // reason, so that throw is guaranteed -- run 367 (2026-09-02) + // lost the whole image to it although the data was already on the + // bus. Once home has been reached the motor is stopped, and + // end_scan then finds it stopped. + // 2. move_back_home feeds `head_pos - 500` steps backwards BEFORE it + // reads the status, so the driver's idea of the head position has + // to be reconciled or those steps grind against the end stop. + // + // Does nothing unless A320E_AGOHOME is set. + a320e_agohome_after_read(*dev); + dev->cmd_set->end_scan(dev, &dev->reg, true); + if (dev->model->is_sheetfed) { dev->cmd_set->eject_document (dev); } @@ -4250,7 +5020,23 @@ static Genesys_Settings calculate_scan_settings(Genesys_Scanner* s) MM_PER_INCH); - unsigned pixels_per_line = static_cast(((br_x - settings.tl_x) * settings.xres) / + const float width_mm = br_x - settings.tl_x; + + /* A320E: the x scale correction (a320e_x_scale, low.cpp). The sensor's + * pitch is ~403 dpi while this grid is 400, so `mm * xres / 25.4` is + * 0.75 % short of the millimetres the frontend asked for. The hardware + * is asked for the missing pixels -- still on the 400 dpi grid, still an + * integer, and still through session_adjust_output_pixels, whose + * alignment the ASIC requires. `requested_pixels` below stays on the + * frontend's own grid, and ImagePipelineNodeScaleRows (low.cpp) scales + * the row down to it. The node is already in place for the 300/600 dpi + * modes, which read on the 400/800 dpi hardware. + * + * The factor is 1 on every other model: nothing there changes. + */ + const float x_scale = a320e_x_scale(*dev); + + unsigned pixels_per_line = static_cast((width_mm * settings.xres * x_scale) / MM_PER_INCH); const auto& sensor = sanei_genesys_find_sensor(dev, settings.xres, settings.get_channels(), @@ -4259,9 +5045,43 @@ static Genesys_Settings calculate_scan_settings(Genesys_Scanner* s) pixels_per_line = session_adjust_output_pixels(pixels_per_line, *dev, sensor, settings.xres, settings.yres, true); - unsigned xres_factor = s->resolution / settings.xres; + /* requested_pixels is the width the frontend expects; if the device scans + * at a different xres, ImagePipelineNodeScaleRows scales to it + * (low.cpp:1276). The integer division `s->resolution / settings.xres` + * only supported the direction "hardware smaller than requested -> + * enlarge": when the hardware is LARGER the factor is 0, requested_pixels + * stays at zero and get_requested_pixels() returns the hardware width -- + * the image is then the hardware's width but tagged with the requested + * resolution, i.e. stretched. + * + * That direction is needed as soon as a model's resolutions_x is sparser + * than its resolutions_y: the A320E reads 300 dpi with 400 dpi hardware + * and 600 dpi with 800 dpi hardware, just as the vendor driver does. The + * factor is therefore computed as a ratio, not as an integer division. In + * the integer cases the result is exactly the same as before. + */ settings.pixels = pixels_per_line; - settings.requested_pixels = pixels_per_line * xres_factor; + settings.requested_pixels = + static_cast(static_cast(pixels_per_line) * s->resolution / + settings.xres); + + /* A320E: the frontend's width is derived BACK from the hardware count, + * not computed from the millimetres a second time. The ratio of the two + * widths is what the image's scale ends up being, and + * session_adjust_output_pixels has already moved `pixels_per_line` to a + * multiple of four. A second, independent rounding would leave that + * ratio off by up to four pixels -- on a narrow window a larger error + * than the 0.75 % being corrected. Derived this way the error is at most + * half a pixel. + * + * At full bed width the number comes out exactly where it was before + * (4796 px at 400 dpi, 9596 at 800): the delivered image keeps its size + * and only its content is rescaled. + */ + if (x_scale != 1.f) { + settings.requested_pixels = static_cast( + std::lround(settings.requested_pixels / static_cast(x_scale))); + } if (s->color_filter == "Red") { settings.color_filter = ColorFilter::RED; @@ -4802,6 +5622,61 @@ static void init_options(Genesys_Scanner* s) s->opt[OPT_EXPIRATION_TIME].constraint.range = &expiration_range; s->expiration_time = 60; // 60 minutes by default + /* A320E: the user's origin correction. + * + * model->x_offset = 18.35 and y_offset = 14.7 were measured on ONE unit + * (runs 158, 168-173). Another user's device will not have its mechanical + * origin in the same place, and until now they had no way to correct it. + * The counterpart in the vendor driver is AdjustOrigin (a persistent user + * setting); this is its run-time form. + * + * A default of 0 means the measured origin as such, so behaviour does not + * change for anyone who leaves these alone. + */ + s->opt[OPT_X_ORIGIN_OFFSET].name = "x-origin-offset"; + s->opt[OPT_X_ORIGIN_OFFSET].title = SANE_I18N ("X origin offset"); + s->opt[OPT_X_ORIGIN_OFFSET].desc = SANE_I18N ( + "Correction to the horizontal scan origin, in mm. The mechanical origin " + "varies between individual units; use this if scans are shifted " + "sideways. Positive values move the scanned area away from the origin " + "corner."); + s->opt[OPT_X_ORIGIN_OFFSET].type = SANE_TYPE_FIXED; + s->opt[OPT_X_ORIGIN_OFFSET].unit = SANE_UNIT_MM; + s->opt[OPT_X_ORIGIN_OFFSET].cap = SANE_CAP_SOFT_SELECT | SANE_CAP_SOFT_DETECT | + SANE_CAP_ADVANCED; + s->opt[OPT_X_ORIGIN_OFFSET].constraint_type = SANE_CONSTRAINT_RANGE; + s->opt[OPT_X_ORIGIN_OFFSET].constraint.range = &origin_offset_range; + s->x_origin_offset = 0; + + s->opt[OPT_Y_ORIGIN_OFFSET].name = "y-origin-offset"; + s->opt[OPT_Y_ORIGIN_OFFSET].title = SANE_I18N ("Y origin offset"); + s->opt[OPT_Y_ORIGIN_OFFSET].desc = SANE_I18N ( + "Correction to the vertical scan origin, in mm. The mechanical origin " + "varies between individual units; use this if scans are shifted up or " + "down. Positive values move the scanned area away from the origin " + "corner."); + s->opt[OPT_Y_ORIGIN_OFFSET].type = SANE_TYPE_FIXED; + s->opt[OPT_Y_ORIGIN_OFFSET].unit = SANE_UNIT_MM; + s->opt[OPT_Y_ORIGIN_OFFSET].cap = SANE_CAP_SOFT_SELECT | SANE_CAP_SOFT_DETECT | + SANE_CAP_ADVANCED; + s->opt[OPT_Y_ORIGIN_OFFSET].constraint_type = SANE_CONSTRAINT_RANGE; + s->opt[OPT_Y_ORIGIN_OFFSET].constraint.range = &origin_offset_range; + s->y_origin_offset = 0; + + /* The device fields have to be cleared HERE, not just s->*_origin_offset: + * Genesys_Device lives in s_devices for the whole life of the process, + * whereas Genesys_Scanner is created anew on every sane_open. Without this + * an open->set->close->open would leave the old shift in force even though + * the option reported zero. + */ + s->dev->a320e_origin_offset_x = 0.f; + s->dev->a320e_origin_offset_y = 0.f; + + if (model->model_id != ModelId::PLUSTEK_OPTICPRO_A320E) { + DISABLE (OPT_X_ORIGIN_OFFSET); + DISABLE (OPT_Y_ORIGIN_OFFSET); + } + /* Powersave time (turn lamp off) */ s->opt[OPT_LAMP_OFF_TIME].name = "lamp-off-time"; s->opt[OPT_LAMP_OFF_TIME].title = SANE_I18N ("Lamp off time"); @@ -5715,6 +6590,12 @@ static void get_option_value(Genesys_Scanner* s, int option, void* val) case OPT_EXPIRATION_TIME: *reinterpret_cast(val) = s->expiration_time; break; + case OPT_X_ORIGIN_OFFSET: + *reinterpret_cast(val) = s->x_origin_offset; + break; + case OPT_Y_ORIGIN_OFFSET: + *reinterpret_cast(val) = s->y_origin_offset; + break; case OPT_CUSTOM_GAMMA: *reinterpret_cast(val) = s->custom_gamma; break; @@ -6018,6 +6899,16 @@ static void set_option_value(Genesys_Scanner* s, int option, void *val, SANE_Int } break; } + case OPT_X_ORIGIN_OFFSET: { + s->x_origin_offset = *reinterpret_cast(val); + dev->a320e_origin_offset_x = fixed_to_float(s->x_origin_offset); + break; + } + case OPT_Y_ORIGIN_OFFSET: { + s->y_origin_offset = *reinterpret_cast(val); + dev->a320e_origin_offset_y = fixed_to_float(s->y_origin_offset); + break; + } case OPT_CUSTOM_GAMMA: { *myinfo |= SANE_INFO_RELOAD_PARAMS | SANE_INFO_RELOAD_OPTIONS; s->custom_gamma = *reinterpret_cast(val); diff --git a/backend/genesys/genesys.h b/backend/genesys/genesys.h index 87855fdc..f2197160 100644 --- a/backend/genesys/genesys.h +++ b/backend/genesys/genesys.h @@ -116,6 +116,12 @@ enum Genesys_Option OPT_FORCE_CALIBRATION, OPT_IGNORE_OFFSETS, + /* A320E: user origin correction, see device.h a320e_origin_offset_x/y. + * Shown on the A320E only; DISABLEd for other models in init_options. + */ + OPT_X_ORIGIN_OFFSET, + OPT_Y_ORIGIN_OFFSET, + /* must come last: */ NUM_OPTIONS }; @@ -198,6 +204,8 @@ struct Genesys_Scanner SANE_Word contrast = 0; SANE_Word brightness = 0; SANE_Word expiration_time = 0; + SANE_Word x_origin_offset = 0; // A320E, SANE_TYPE_FIXED, mm + SANE_Word y_origin_offset = 0; // A320E, SANE_TYPE_FIXED, mm bool custom_gamma = false; SANE_Word exposure_time = 0; diff --git a/backend/genesys/gl124.cpp b/backend/genesys/gl124.cpp index 3a586c87..887c504d 100644 --- a/backend/genesys/gl124.cpp +++ b/backend/genesys/gl124.cpp @@ -22,9 +22,11 @@ #define DEBUG_DECLARE_ONLY #include "gl124.h" +#include #include "gl124_registers.h" #include "test_settings.h" +#include #include namespace genesys { @@ -58,9 +60,12 @@ static Gpio_layout gpios[] = { { 0x9f, 0x53, 0x01, 0x80, 0x5f, 0x01, 0x00 }, + /* Plustek OpticPro A320E -- read from a USB capture (05b) */ + { + 0x80, 0x4e, 0x1e, 0x80, 0xbf, 0x1f, 0x78 + }, }; - /** @brief set all registers to default values . * This function is called only once at the beginning and * fills register startup values for registers reused across scans. @@ -342,6 +347,84 @@ gl124_init_registers (Genesys_Device * dev) dev->reg.init_reg(0xfc, 0x00); dev->reg.init_reg(0xff, 0x00); + + /* Plustek OpticPro A320E: the device's own initialisation table from a USB + capture (05b, frames 7-23). It overrides the LiDE 110 defaults above, and + is written out here in full because the device is neither a LiDE nor a + CIS. The most critical difference is REG_0x0A = 0x40 (SIFSEL = 1), which + routes the AFE to gl124_set_a320e_fe. + + The table is the vendor's BASE layer. Its per-resolution overrides are + applied where the vendor applies them, on the scan's register set: 0x3b + in init_regs_for_scan_session and REG_0x31's speed bit in + gl124_setup_scan_gpio. 0x03b = 0xff below stays the base value, which is + also what the vendor's 200 dpi section restates. */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + static const struct { std::uint16_t address; std::uint8_t value; } a320e_regs[] = { + // 118 registers, capture 05b frames 7-23 + { 0x001, 0x22 }, { 0x002, 0x78 }, { 0x003, 0x10 }, { 0x004, 0x02 }, { 0x005, 0x48 }, { 0x006, 0xd8 }, + { 0x007, 0x00 }, { 0x008, 0x20 }, { 0x009, 0x00 }, { 0x00a, 0x40 }, { 0x00b, 0x6c }, { 0x00c, 0x00 }, + { 0x011, 0x00 }, { 0x012, 0x80 }, { 0x013, 0x48 }, { 0x014, 0x00 }, { 0x015, 0x80 }, { 0x016, 0x23 }, + { 0x017, 0x00 }, { 0x018, 0x10 }, { 0x019, 0x00 }, { 0x01a, 0x00 }, { 0x01b, 0x00 }, { 0x01c, 0x00 }, + { 0x01d, 0x00 }, { 0x01e, 0x10 }, { 0x01f, 0x00 }, { 0x020, 0x28 }, { 0x021, 0x00 }, { 0x022, 0x1a }, + { 0x025, 0x00 }, { 0x026, 0x00 }, { 0x027, 0x00 }, { 0x028, 0x00 }, { 0x029, 0x40 }, { 0x02a, 0x00 }, + { 0x02b, 0x00 }, { 0x02c, 0x04 }, { 0x02d, 0xb0 }, { 0x030, 0x66 }, { 0x031, 0x80 }, { 0x032, 0x0e }, + { 0x033, 0x1e }, { 0x034, 0x80 }, { 0x035, 0xbf }, { 0x036, 0x1f }, { 0x037, 0x00 }, { 0x038, 0x78 }, + { 0x039, 0x00 }, { 0x03b, 0xff }, { 0x03c, 0xff }, { 0x03d, 0x00 }, { 0x03e, 0x00 }, { 0x03f, 0x01 }, + { 0x04f, 0x33 }, { 0x052, 0x01 }, { 0x053, 0x04 }, { 0x054, 0x07 }, { 0x055, 0x0a }, { 0x056, 0x0d }, + { 0x057, 0x10 }, { 0x05c, 0x30 }, { 0x07d, 0x00 }, { 0x07e, 0x03 }, { 0x07f, 0xa0 }, { 0x080, 0x00 }, + { 0x081, 0x3c }, { 0x082, 0x00 }, { 0x083, 0x00 }, { 0x084, 0x14 }, { 0x085, 0x00 }, { 0x086, 0x27 }, + { 0x087, 0xec }, { 0x088, 0x00 }, { 0x089, 0x2a }, { 0x08a, 0x00 }, { 0x08b, 0x00 }, { 0x08c, 0x00 }, + { 0x08d, 0x00 }, { 0x08e, 0x00 }, { 0x08f, 0x00 }, { 0x090, 0x00 }, { 0x091, 0x00 }, { 0x092, 0x00 }, + { 0x093, 0x00 }, { 0x094, 0x00 }, { 0x095, 0x00 }, { 0x096, 0x00 }, { 0x097, 0x00 }, { 0x098, 0x00 }, + { 0x09b, 0x7f }, { 0x09c, 0x7f }, { 0x09f, 0x00 }, { 0x0a0, 0x09 }, { 0x0a1, 0xbf }, { 0x0a2, 0x00 }, + { 0x0a3, 0x01 }, { 0x0a4, 0x00 }, { 0x0a5, 0x04 }, { 0x0a6, 0x00 }, { 0x0a7, 0x50 }, { 0x0a8, 0x00 }, + { 0x0a9, 0x50 }, { 0x0aa, 0x00 }, { 0x0ab, 0x04 }, { 0x0ac, 0x00 }, { 0x0ad, 0x01 }, { 0x0ae, 0x00 }, + { 0x0af, 0x04 }, { 0x0b0, 0x00 }, { 0x0b1, 0x01 }, { 0x0b8, 0x00 }, { 0x0ba, 0x00 }, { 0x0b9, 0x00 }, + { 0x0bd, 0x00 }, { 0x0be, 0x00 }, { 0x114, 0x80 }, { 0x115, 0x80 }, + /* Run 27 zeroed seven LiDE 110 defaults on the theory that the vendor + * writes none of them, so they must really sit at the ASIC's reset + * value. Four of the seven were right; three were not. + * + * MEASURED 2026-09-01 (0 device runs). Capture 07 contains 1216 + * register READS, 288 distinct registers, each read twice + * (tools/regreads.py). "The vendor does not write it" says only that + * the value comes from somewhere else -- and the capture states that + * value outright: + * + * 0x58, 0x59, 0x60, 0x6d read back 0x00 -> zeroing is correct + * 0x99 reads back 0x1f + * 0x9d reads back 0x3f + * 0xbc reads back 0xff + * + * The last three are read back identically in seven captures spanning + * 100/200/300/400/600/800 dpi and gray, so they are device constants, + * not resolution-dependent (unlike 0x3b). And they are NOT in capture + * 05b either, from which this table is otherwise transcribed -- their + * zeros were never in the source. + * + * Upstream's own gl124 defaults are exactly those three values + * (init_reg 0x99=0x1f, 0x9d=0x3f, 0xbc=0xff above), so the fix is to + * drop the three entries and let the defaults stand rather than to + * set anything here. Do not "restore" them from the run 27 list. + * + * 0x9d matters most: gl847 documents its contents as RAMDLY, MOTLAG, + * CMODE, STEPTIM, IFRS, and gl843 derives the motor step multiplier + * from bits 2..3. gl124 never reads it and hardcodes that multiplier + * to 1, so 0x3f is the value gl124 is written against -- see + * notes/ENNAKKOANALYYSIT.md for the full pre-run analysis. + * + * Bit 0x02 of 0x60 is CK4ADC (routing of the sensor clock to the A/D + * converter); that zeroing stays. + */ + { 0x058, 0x00 }, { 0x059, 0x00 }, { 0x060, 0x00 }, { 0x06d, 0x00 }, + }; + for (const auto& reg : a320e_regs) { + dev->reg.init_reg(reg.address, reg.value); + } + + } + // fine tune upon device description const auto& sensor = sanei_genesys_find_sensor_any(dev); const auto& dpihw_sensor = sanei_genesys_find_sensor(dev, sensor.full_resolution, @@ -390,6 +473,152 @@ static void gl124_set_ti_fe(Genesys_Device* dev, std::uint8_t set) } +/** @brief A320E_AFE -- forcing AFE registers from an environment variable (ROADMAP 2.8) + * + * Usage: A320E_AFE="0x05=0x134,0x06=0x10,0x07=0x0f" + * + * The value is 9-bit: in the offsets (0x05-0x07) D8 is the SIGN and D7..D0 the + * magnitude, so 0x134 is a negative 52. That is precisely the side the driver's + * own offset calibration cannot reach: `scanner_offset_calibration` searches + * only the range 10..255 (D8 = 0), whereas the vendor searches both sides and + * ends up with a negative offset for R at 800 dpi (capture 07, + * tools/afedump.py: 0x05 = 0x134, 0x06 = 0x10, 0x07 = 0x0f; G and B are thus + * positive and of the same order of magnitude as our 11). + * + * Called from EVERY exit path of gl124_set_a320e_fe as the last write, so that + * the override beats both the calibration's binary search and the speed block's + * custom_fe_regs (SAFE PRACTICE: force at the last moment, and do not trust the + * debug output). + * + * A development tool, not for upstream -- the same class as A320E_FORCE. + */ +static void gl124_a320e_afe_env_override(Genesys_Device* dev) +{ + const char* env = std::getenv("A320E_AFE"); + if (env == nullptr) { + return; + } + + std::string spec(env); + std::size_t pos = 0; + while (pos < spec.size()) { + std::size_t comma = spec.find(',', pos); + if (comma == std::string::npos) { + comma = spec.size(); + } + std::string item = spec.substr(pos, comma - pos); + std::size_t eq = item.find('='); + if (eq != std::string::npos) { + unsigned r = std::strtoul(item.substr(0, eq).c_str(), nullptr, 0); + unsigned v = std::strtoul(item.substr(eq + 1).c_str(), nullptr, 0); + DBG(DBG_info, "%s: A320E_AFE 0x%02x = 0x%03x (xres %d)\n", __func__, + r, v, dev->settings.xres); + dev->interface->write_fe_register(static_cast(r), + static_cast(v)); + } + pos = comma + 1; + } +} + + +/** @brief AFE write for the Plustek OpticPro A320E (SIFSEL = 1) + * + * The device does not use the LiDE models' "ti" front end (SIFSEL = 3) but an + * Analog Devices AD9822 (docs/AD9822.pdf). The sequence read off the capture + * (tools/afedump.py) is the datasheet's register map as such: + * 0x00 = 0x70 configuration (VREF, 3-channel, CDS mode) -- NOTE: this is + * Speed2's value; 600/800 dpi override it with 0xf8 through the + * sensor's custom_fe_regs, see below + * 0x01 = 0x80 MUX, channel order R-G-B + * 0x02..0x04 gains R/G/B, 6 bits (0..63) + * 0x05..0x07 offsets R/G/B, 9 bits sign plus magnitude + * + * The write is done in address order straight from `frontend.regs`, so that the + * role/address correspondence lives in one place only: the `analog_devices` + * layout (tables_frontend.cpp). + */ +static void gl124_set_a320e_fe(Genesys_Device* dev, const Genesys_Sensor& sensor, + std::uint8_t set) +{ + DBG_HELPER(dbg); + + if (set == AFE_INIT) { + dev->frontend = dev->frontend_initial; + } + + /* The AD9822's register map (docs/AD9822.pdf, Table 6): + * 0x00 Configuration, 0x01 MUX, + * 0x02-0x04 PGA (gain, 6 bits D5..D0, 1.0x-5.7x), + * 0x05-0x07 Offset (9 bits, SIGN AND MAGNITUDE, D8 = sign). + * This is exactly SANE's `analog_devices` layout, which is what we use. + * + * Run 44 (2026-08-19): forcing the vendor's measured AFE values as such + * made the image WORSE (29 % vs 88 %) -- the values were measured against + * the vendor's own exposure and calibration chain and do not transfer on + * their own. Our own calibration stays in use. An observation left open: + * the PGA saturates at its ceiling of 63 (a symptom of the exposure or the + * shading) and the offset's sign bit is the opposite of the vendor's. + */ + for (std::uint8_t addr = 0x00; addr <= 0x07; addr++) { + dev->interface->write_fe_register(addr, dev->frontend.regs.get_value(addr)); + } + + /* Speed-block specific AFE registers from the sensor table. + * + * The AD9822's configuration register 0x00 is NOT a constant: the vendor + * changes it along with the speed block (docs/ScanApi_07b3_1826.ini, the + * "@" lines of the Speed1 and Speed2 blocks; this is confirmed by all six + * resolution captures via tools/afedump.py): + * + * Speed2 (100-400 dpi) 0x00 = 0x70 + * Speed1 (600/800 dpi) 0x00 = 0xf8 + * + * The differing bits are D7 (the ADC's full scale, 2 V -> 4 V) and D3 (the + * input clamp bias, 3 V -> 4 V; at 3 V the input's peak range shrinks to + * 3 volts). The vendor thus doubles the ADC's full scale at exactly the + * point where it doubles the exposure (LPERIOD 6256 -> 12000). + * + * On our side 0x00 was hardcoded to Speed2's value of 0x70 at every + * resolution (the value was read from capture 05b, which is a Speed2 run), + * so 600/800 dpi ran on half the ADC scale. That is the cause of the flat + * field in runs 137/138/143: the gain calibration drove the PGA to its + * ceiling of 63 and the white reference saturated. + * + * A/B without recompiling: A320E_AFE_CONF=0 turns this off (= the old + * behaviour, 0x00 from frontend.regs alone). + */ + if (dev->settings.xres == 0) { + gl124_a320e_afe_env_override(dev); + return; + } + + /* NOTE: the value is taken from the SESSION's requested resolution + * (settings.xres), NOT from the sensor passed in as a parameter. The + * calibration steps call set_fe with a low-resolution calib_sensor: in an + * 800 dpi scan set_fe runs 14 times, and in six of those the sensor is the + * 100 dpi row (optical 400 / dpiset 300). Taken per sensor, the value would + * swing between 0x70 and 0xf8 in mid-session, and the calibration and the + * image scan would then measure on DIFFERENT ADC scales -- exactly the + * inconsistency that ruins the shading coefficients. + * + * The vendor writes 0x00 EXACTLY ONCE at the start of the session and keeps + * it constant across the whole session. Verified from all four captures + * (tools/afedump.py, captures 03/06/07/08: one 0x00 write each; in capture + * 07 frame 607 = 0xf8, before any calibration at all). + */ + const auto& scan_sensor = sanei_genesys_find_sensor(dev, dev->settings.xres, + dev->settings.get_channels(), + dev->settings.scan_method); + + for (const auto& reg : scan_sensor.custom_fe_regs) { + DBG(DBG_info, "%s: nopeuslohkon AFE 0x%02x = 0x%02x (xres %d)\n", __func__, + reg.address, reg.value, dev->settings.xres); + dev->interface->write_fe_register(reg.address, reg.value); + } + + gl124_a320e_afe_env_override(dev); +} + // Set values of analog frontend void CommandSetGl124::set_fe(Genesys_Device* dev, const Genesys_Sensor& sensor, std::uint8_t set) const @@ -397,7 +626,6 @@ void CommandSetGl124::set_fe(Genesys_Device* dev, const Genesys_Sensor& sensor, DBG_HELPER_ARGS(dbg, "%s", set == AFE_INIT ? "init" : set == AFE_SET ? "set" : set == AFE_POWER_SAVE ? "powersave" : "huh?"); - (void) sensor; std::uint8_t val; if (set == AFE_INIT) { @@ -411,8 +639,10 @@ void CommandSetGl124::set_fe(Genesys_Device* dev, const Genesys_Sensor& sensor, case 3: gl124_set_ti_fe(dev, set); break; - case 0: case 1: + gl124_set_a320e_fe(dev, sensor, set); + break; + case 0: case 2: default: throw SaneException("unsupported analog FE 0x%02x", val); @@ -447,7 +677,50 @@ static void gl124_init_motor_regs_scan(Genesys_Device* dev, /* enforce motor minimal scan speed * @TODO extend motor struct for this value */ - if (scan_mode == ScanColorMode::COLOR_SINGLE_PASS) + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // The A320E does not use line skipping from 200 dpi upwards: the + // Windows driver writes LINESEL = 0 at every resolution from 150 to + // 600 dpi (captures 05b, 06, 08, 09; REG_0x1D = 0x80). Apple's plist + // likewise has 'Linesel': '0'. LiDE's min_speed = 900 forced every run + // below 900 dpi to linesel >= 1, whereupon the sensor performs two line + // shifts per output line and the sample stream gets out of step. + // + // BUT 100 dpi is not a hardware run: the motor's top speed is the + // plateau 782 = the step interval of 200 dpi, so a target below that is + // clipped and the image stretches (100 dpi measured at 2.023x in run + // 176). The vendor draws the same conclusion: it runs 100 dpi on 200 dpi + // hardware and drops the lines in software (capture 02: table 782/192 + // and LINCNT = the line count of 200 dpi). + // + // Our counterpart is the ASIC's own line skipping: + // 100 dpi -> yres 200, linesel 1 (8 half steps per line period x2) + // Measured in run 189: 0.998 (normalised to 200 dpi). The profile is + // chosen by session.params.yres (100), and the plateau 782 of its + // {100, 200} group is exactly right for the raised 200. + // + // 150 dpi WAS DROPPED on 2026-08-21, see tables_model.cpp. It was the + // only resolution that hit the BORDERLINE CASE of the generic + // computation below (200/150 - 1 = 0 -> linesel 1, yres 300), and the + // ASIC produced no lines at all in that configuration (runs 190, 198, + // 199). Apple's plist forces LINESEL to zero on exactly the 300 dpi + // hardware ('1d.1f': 0x00, only Color[300] and Gray[300]). Now that 150 + // is gone, the borderline case can no longer arise: the only resolution + // below 200 dpi is 100, and 200/100 - 1 = 1. + // + // For the image scan only. Feed and homing sessions run at yres 100, and + // for them a doubled LINCNT would confuse the readable line count -- + // they are excluded via the FEEDING and REVERSE flags. + // + // A/B: A320E_LINESEL=0 restores the old behaviour (min_speed = 0). + min_speed = 0; + + if (!has_flag(flags, ScanFlag::FEEDING) && + !has_flag(flags, ScanFlag::REVERSE)) + { + min_speed = 200; + } + } + else if (scan_mode == ScanColorMode::COLOR_SINGLE_PASS) { min_speed = 900; } @@ -493,6 +766,20 @@ static void gl124_init_motor_regs_scan(Genesys_Device* dev, if (has_flag(flags, ScanFlag::AUTO_GO_HOME)) { r02 |= REG_0x02_AGOHOME; + + // A320E: the vendor's REG_0x02 is 0x30 at the image scan -- AGOHOME + // and MTRPWR, with NOTHOME CLEAR. Setting AGOHOME on top of NOTHOME + // would be a state the vendor never writes, and the two bits are each + // other's opposites: NOTHOME tells the ASIC not to seek home. + // DO NOT make this conditional. Run 374 (2026-09-02) wrote + // 0x02 = 0xb0 -- AGOHOME on top of NOTHOME -- to find out which of + // the two bits costs the +4.0 % R channel. The ASIC drove the + // carriage backwards and did NOT stop at the home sensor: it ground + // the end stop until the user cut the power. The bits are not + // separable on this hardware. + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + r02 &= ~REG_0x02_NOTHOME; + } } if (has_flag(flags, ScanFlag::DISABLE_BUFFER_FULL_MOVE) || (yres >= sensor.full_resolution)) @@ -525,15 +812,91 @@ static void gl124_init_motor_regs_scan(Genesys_Device* dev, fast_dpi*=3; } */ + /* A320E: THE STOP AND FAST TABLES ARE ON THE MEASURED CURVE TOO. + * + * All four tables are built from the same profile here, and fast_dpi is + * yres, so STOP and FAST are byte for byte the SCAN table. The vendor + * does not do that: its FAST/HOME profile is its own (55555 -> 625 over + * 511 steps), and the measured curve is the image scan's. Matching that + * profile is a separate, still open piece of work. + * + * Changing the acceleration changes the transfer and stop movements too, + * and those -- not the image scan -- were the risk surface: the return + * movement is the only one that ends against a stop (failure modes 1 and + * 3 of the pre-analysis, notes/ENNAKKOANALYYSIT.md, chapter + * "Ramppikayra"). SCAN and BACKTRACK went first (runs 430-435), STOP and + * FAST followed in runs 436-441: interleaved A/B, audio recorded, three + * pairs. Nothing moved. Geometry 0 px in all four bands of every pair, + * the return timeline identical to the millisecond (first movement + * +0.31 s, slow home +6.31 s, last event +7.03 s), HOMESNR 0xc8 in all + * six. On the bus FASTNO/FSHDEC/FMOVNO go 194 -> 192, and FASTNO is then + * the vendor's value. + * + * A/B: A320E_SLOPE_CURVE_FAST=0 keeps STOP and FAST on the modelled + * curve; A320E_SLOPE_CURVE=0 puts everything back on it. + */ + MotorProfile fast_profile = motor_profile; + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + bool fast_curve = true; + if (const char* env = std::getenv("A320E_SLOPE_CURVE_FAST")) { + fast_curve = std::strtoul(env, nullptr, 0) != 0; + } + if (!fast_curve) { + fast_profile.slope.measured_curve = nullptr; + fast_profile.slope.measured_curve_size = 0; + } + } + auto fast_table = create_slope_table(dev->model->asic_type, dev->motor, fast_dpi, - scan_exposure_time, 1, motor_profile); + scan_exposure_time, 1, fast_profile); scanner_send_slope_table(dev, sensor, STOP_TABLE, fast_table.table); - scanner_send_slope_table(dev, sensor, FAST_TABLE, fast_table.table); - reg->set16(REG_FASTNO, fast_table.table.size()); - reg->set16(REG_FSHDEC, fast_table.table.size()); + /* A320E: TABLE 4 (FAST) IS NEVER ENGAGED ON THIS ASIC -- MEASURED. + * + * The vendor gives tables 4 and 5 a profile of their own (55555 -> 625 + * over 510 entries before the image scan, 41666 -> 666 at init; the same + * bytes at 200, 600 and 800 dpi) and its plateau is 625 against our 782. + * That profile was loaded into table 4 and FMOVNO set to its length, and + * runs 447-449 measured the result: NOTHING MOVED. The feed before the + * image (ramppiaika.py: 517 vs 516 ms) and the return timeline were + * identical. + * + * The reason is in REG_0x02: table 4 is the FAST FEED table, and this + * backend never sets FASTFED. gl842.cpp:301 and gl843.cpp:702 set it + * conditionally, gl846.cpp:363 and gl847.cpp:327 clear it explicitly, + * and gl124 does not mention the bit at all -- the value comes from the + * init table and no scan phase ever carries it. On the bus: the vendor's + * image phase is 0x18 (FASTFED|MTRPWR) and then 0x30, while ours is + * 0x90/0x94 in every phase and 0xd4/0x94/0x84 in the return. + * + * So the vendor's feed is a separate fast-feed movement and ours lives + * inside the scan session -- the same structural difference that already + * explains SCANFED (0 vs 4) and FEEDL (1 vs 730). The vendor's fast + * profile is therefore NOT carried here: it would be 512 numbers feeding + * a table this ASIC branch never reaches. See notes/ENNAKKOANALYYSIT.md, + * chapter "0xac/0xad FSHDEC, 0xae/0xaf FMOVNO, 0xb0/0xb1 FMOVDEC", and + * ROADMAP.md, chapter 2026-09-03k. + */ + scanner_send_slope_table(dev, sensor, FAST_TABLE, fast_table.table); reg->set16(REG_FMOVNO, fast_table.table.size()); + /* FASTNO and FSHDEC follow the SCAN table, not the fast one. + * + * gl124 is the only backend that ties them to the fast table; gl842.cpp:335, + * gl843.cpp:736, gl846.cpp:388 and gl847.cpp:437 all use the scan table, + * and so does the vendor -- its FASTNO is 192/6/4, i.e. STEPNO, at every + * resolution. The two tables are the same object here (fast_dpi = yres), + * so this changes no value today; it keeps the fields pointing at the + * table they describe if that ever stops being true. + */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + reg->set16(REG_FASTNO, scan_table.table.size()); + reg->set16(REG_FSHDEC, scan_table.table.size()); + } else { + reg->set16(REG_FASTNO, fast_table.table.size()); + reg->set16(REG_FSHDEC, fast_table.table.size()); + } + /* subtract acceleration distance from feedl */ feedl=feed_steps; feedl <<= static_cast(motor_profile.step_type); @@ -567,10 +930,137 @@ static void gl124_init_motor_regs_scan(Genesys_Device* dev, /* LINESEL */ reg->set8_mask(REG_0x1D, linesel, REG_0x1D_LINESEL); - reg->set8(REG_0xA0, (static_cast(motor_profile.step_type) << REG_0xA0S_STEPSEL) | - (static_cast(motor_profile.step_type) << REG_0xA0S_FSTPSEL)); + /* A320E: FSTPSEL DOES NOT FOLLOW STEPSEL. + * + * Upstream puts the same step_type into both fields. Read as bytes that + * looks right, because the vendor writes 0x09 at six resolutions out of + * seven -- but as fields it is right by coincidence, not by structure. + * Measured (tools/kerros.py, tools/moottoritila.py, the vendor's seven + * captures): + * + * dpi 100 150 200 300 400 600 800 + * STEPSEL 1 1 1 1 1 2 1 + * FSTPSEL 1 1 1 1 1 1 1 + * + * FSTPSEL is 1 -- half step -- at every single resolution; only STEPSEL + * changes, and only once. The two fields serve different movements: + * STEPSEL the image scan, FSTPSEL the fast feed. Tying them together + * makes us write 0x12 at 600 dpi where the vendor writes 0x0a, and 600 + * dpi is the only resolution where the coincidence breaks. + * + * This matters more to us than to the vendor. The vendor feeds in a + * movement of its own before the scan session (SCANFED 0, FEEDL 1); we + * have to feed inside the image session (SCANFED 4, FEEDL 730), so + * FSTPSEL lands on more movements here than there. + * + * A/B without recompiling: A320E_FSTPSEL_FIX=0 restores the old + * behaviour, i.e. FSTPSEL = STEPSEL. + */ + unsigned stepsel = static_cast(motor_profile.step_type); + unsigned fstpsel = stepsel; + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + fstpsel = static_cast(StepType::HALF); + if (const char* env = std::getenv("A320E_FSTPSEL_FIX")) { + if (std::strtoul(env, nullptr, 0) == 0) { + fstpsel = stepsel; + } + } + } + reg->set8(REG_0xA0, (stepsel << REG_0xA0S_STEPSEL) | + (fstpsel << REG_0xA0S_FSTPSEL)); reg->set16(REG_FMOVDEC, fast_table.table.size()); + + /* A320E: FWDSTEP and BWDSTEP for the IMAGE scan only. + * + * Measured on the bus (tools/kerros.py; captures 03, 07 and 08 -- 200, + * 800 and 600 dpi, the same pattern in all three): the vendor does not + * hold one value here. It uses three, in three layers. + * + * initialisation table 0x50 = 80 + * speed block 0x80 = 128 + * calibration phases 0x01 = 1 + * image scan 0x80 = 128 + * + * Our driver never writes the field at all: upstream's gl124 branch is + * missing the write that gl846.cpp:473 and gl847.cpp:415 do, so the + * initialisation table's 80 stands in every one of our eleven sessions, + * the return move included. The difference list's line "128 vs. 80" was + * therefore true in one phase out of six, and in the calibration phases + * the difference runs the other way. + * + * This gives the image scan the vendor's image value and leaves every + * other session alone. DISABLE_SHADING is what separates them: all the + * calibration and setup sessions set it (genesys.cpp and this file), + * while calculate_scan_session gives the user's scan ScanFlag::NONE. + * + * Do NOT do this with A320E_FORCE instead. That block runs in + * begin_scan, which is called for every session, so it would put the + * image value into the calibration phases as well -- the same + * wrong-layer mistake as 0x3b in runs 331/332. + * + * The calibration phases' own value (1) is a separate step and is NOT + * done here; see notes/ENNAKKOANALYYSIT.md, chapter `0xa9`, which also + * carries the run order and the meters. + * + * THE FIELD IS WRITTEN IN EVERY SESSION, not only in the image one, and + * that is deliberate. scanner_move starts from `auto local_reg = + * dev.reg`, a copy of the register set the image scan left behind, and + * the move's own session does not touch this field. Writing 128 only in + * the image session therefore leaked it into the RETURN MOVE as well -- + * measured in run 411, `kerros.py`: `kuvan jalkeen: 0x80`. The return is + * the one move that ends at the home stop, and the vendor has no value + * for it at all, because it has no return run (AGOHOME does the work). + * Writing 80 explicitly in the other sessions keeps them exactly where + * they have been -- 80 is the initialisation table's own value -- and + * makes the field deterministic instead of inherited. + * + * A/B without recompiling: A320E_FWDSTEP=80 restores the old value. + */ + /* THE CALIBRATION VALUE (1) AND WHY IT IS GIVEN TO SINGLE_LINE SESSIONS + * ONLY. + * + * The vendor holds 1 through its calibration phases, and the reason is + * visible in what those phases do: they read the sensor without moving + * the carriage, so the number of steps taken after a direction change + * has nothing to act on. SANE has no flag that says "calibration" -- + * ScanFlag::CALIBRATION exists in enums.h but nothing in the tree ever + * sets it -- and DISABLE_SHADING alone is far too wide: it is also on + * every carriage move, the RETURN HOME included (scanner_move and + * scanner_move_back_home, genesys.cpp). Giving 1 to those would put the + * smallest possible restep on the one move that ends against the home + * stop, and that is the failure that runs 72, 137 and 374 already + * produced. + * + * SINGLE_LINE is the narrow, positive marker: it is set by + * scanner_offset_calibration, scanner_coarse_gain_calibration, + * scanner_led_calibration, move_to_calibration_area and + * init_regs_for_warmup -- stationary reads, every one of them -- and by + * neither of the two move paths. The shading read + * (init_regs_for_shading) does not set it and therefore keeps 80; that + * is deliberate, because it is the one calibration phase that does run + * the carriage. + * + * A/B without recompiling: A320E_FWDSTEP_CALIB=80 restores the old + * value in the calibration phases. + */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + unsigned restep = 80; + if (!has_flag(flags, ScanFlag::DISABLE_SHADING)) { + restep = 128; + if (const char* env = std::getenv("A320E_FWDSTEP")) { + restep = static_cast(std::strtoul(env, nullptr, 0)); + } + } else if (has_flag(flags, ScanFlag::SINGLE_LINE)) { + restep = 1; + if (const char* env = std::getenv("A320E_FWDSTEP_CALIB")) { + restep = static_cast(std::strtoul(env, nullptr, 0)); + } + } + reg->set16(REG_FWDSTEP, restep); + reg->set16(REG_BWDSTEP, restep); + DBG(DBG_info, "%s: A320E FWDSTEP/BWDSTEP = %u\n", __func__, restep); + } } static void gl124_init_optical_regs_scan(Genesys_Device* dev, const Genesys_Sensor& sensor, @@ -585,15 +1075,28 @@ static void gl124_init_optical_regs_scan(Genesys_Device* dev, const Genesys_Sens /* enable shading */ regs_set_optical_off(dev->model->asic_type, *reg); + // gl124 was the only ASIC backend whose condition was missing + // use_host_side_calib (cf. gl847.cpp:478, gl843.cpp:878, gl846.cpp:529, + // gl842.cpp:424). Without this the ASIC would divide by a shading RAM that + // has not been loaded. if (has_flag(session.params.flags, ScanFlag::DISABLE_SHADING) || - has_flag(dev->model->flags, ModelFlag::DISABLE_SHADING_CALIBRATION)) + has_flag(dev->model->flags, ModelFlag::DISABLE_SHADING_CALIBRATION) || + session.use_host_side_calib) { reg->find_reg(REG_0x01).value &= ~REG_0x01_DVDSET; } else { reg->find_reg(REG_0x01).value |= REG_0x01_DVDSET; } - if ((dev->model->sensor_id != SensorId::CIS_CANON_LIDE_120) && (session.params.xres>=600)) { + // The A320E is routed past the lower branch: `|= ~AVEENB` is upstream's + // own, commented bug -- it sets all the other bits, i.e. REG_0x03 = 0xbf, + // which turns on LAMPDOG (the lamp watchdog), XPASEL (the transparency unit + // select) and LAMPTIM = 15. The Windows driver keeps the register at 0x10 + // (LAMPPWR alone) at every resolution. + if ((dev->model->sensor_id != SensorId::CIS_CANON_LIDE_120 && + session.params.xres >= 600) || + dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) + { reg->find_reg(REG_0x03).value &= ~REG_0x03_AVEENB; } else { // BUG: the following is likely incorrect @@ -642,7 +1145,28 @@ static void gl124_init_optical_regs_scan(Genesys_Device* dev, const Genesys_Sens session.params.scan_method); sanei_genesys_set_dpihw(*reg, dpihw_sensor.register_dpihw); - if (should_enable_gamma(session, sensor)) { + bool enable_gamma = should_enable_gamma(session, sensor); + + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + /* The A320E's sensor gamma is 1.0, which makes should_enable_gamma + * return false and leaves the LUT unused. That was harmless while the + * LUT was an identity ramp, but the white-level compensation now lives + * in it (a320e_apply_white_scale in genesys.cpp), so the LUT has to be + * applied to the image scan. + * + * It must NOT be applied to a calibration scan: the coarse gain + * calibration reads at depth 8 and would then measure its own + * correction and drive the PGA to cancel it. Every calibration session + * disables shading, which is the marker used here -- it covers the gain + * calibration and the strip search alike, and it needs no edit in the + * shared session builders. Depth 16 keeps the linear data. + */ + enable_gamma = !has_flag(session.params.flags, ScanFlag::DISABLE_GAMMA) && + !has_flag(session.params.flags, ScanFlag::DISABLE_SHADING) && + session.params.depth != 16; + } + + if (enable_gamma) { reg->find_reg(REG_0x05).value |= REG_0x05_GMMENB; } else { reg->find_reg(REG_0x05).value &= ~REG_0x05_GMMENB; @@ -652,6 +1176,24 @@ static void gl124_init_optical_regs_scan(Genesys_Device* dev, const Genesys_Sens reg->find_reg(REG_0x06).value |= REG_0x06_GAIN4; + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // A320E: SCANMOD has to be cleared before every scan. The device's own + // initialisation table sets 0x06 = 0xd8 (SCANMOD = 6), but the Windows + // driver writes 0x18 (SCANMOD = 0) right after initialisation (capture + // 05b, frame 429) and never returns to 6 -- not in calibration and not + // in the scan proper. SANE's gl124 does not touch SCANMOD at all, so the + // value stayed at the initialisation table's. + reg->find_reg(REG_0x06).value &= ~REG_0x06_SCANMOD; + + // A320E: FWDSTEP (0x22). The device's own initialisation table gives + // 0x1a, but *both* the Windows and the Apple driver write 0x03 before + // the first scan and never return to 0x1a (Windows: capture 05b frame + // 433; Apple: the plist's Speed2 block). The register has no name in + // gl124_registers.h and upstream does not write it, but on every other + // genesys ASIC 0x22 = FWDSTEP (min_restep). + reg->find_reg(0x22).value = 0x03; + } + /* CIS scanners can do true gray by setting LEDADD */ /* we set up LEDADD only when asked */ if (dev->model->is_cis) { @@ -690,10 +1232,143 @@ static void gl124_init_optical_regs_scan(Genesys_Device* dev, const Genesys_Sens // MAXWD is expressed in 2 words unit // BUG: we shouldn't multiply by channels here - reg->set24(REG_MAXWD, session.output_line_bytes_raw * session.params.channels * - session.optical_resolution / session.full_resolution); + unsigned maxwd = session.output_line_bytes_raw * session.params.channels * + session.optical_resolution / session.full_resolution; + + /* MAXWD: on the vendor a PER-MODE CONSTANT (7 captures: colour + * 10874-10933, gray/lineart 32586), independent of width and resolution. + * SANE's formula derives it from the line length (702 @ 20 mm colour, + * 78 @ 20 mm gray, 3528 @ 100 mm colour). + * + * The constant is OFF BY DEFAULT (session 10, a re-measurement of the old + * logs): + * - runs 58 vs 59 changed MAXWD from 3528 to 10874 in a wide run and the + * data did not change (11.4 % -> 11.6 %) -- there is no evidence in + * favour of the constant; + * - the only successful wide run (57: the full line, 78.4 %, carriage + * home on a single poll) used the FORMULA's value of 3528; + * - the only run that combined full-length data with the constant 10874 + * (run 66) ended in the motor overrunning the home end stop. + * FINAL REFUTATION (runs 73/75, 2026-08-19): the vendor's constant 32586 + * against the formula's 392 (an 83-fold difference) produced a + * byte-identical image. The A320E_MAXWD_MFG switch was removed (hardcoding + * round 2, 2026-08-20); the formula is the only one left. + */ + + /* 0x29-0x2b REMOVED (session 10). The write was dead code: REG_MAXWD = + * 0x28 is 24-bit, i.e. it covers 0x28-0x2a, and the set24(REG_MAXWD, ...) + * further down always wrote over 0x29 and 0x2a. Only 0x2b stayed in force. + * The register state at the point of the image scan in runs 57-66 confirms + * this (0x29/0x2a carry MAXWD, never the value 0x2b3528), so the conclusion + * "0x29-0x2b: no effect" drawn from runs 57-61 is not a valid experiment + * but an artefact -- the vendor's value is still UNTESTED. If it is tested, + * the write has to be done AFTER the set24(REG_MAXWD, ...) call, and it + * then overrides MAXWD: the two do not fit in the registers at the same + * time. + */ + + /* DVDSET (0x01 bit 0x20) WAS NOT MISSING FROM US, nor is it a clock + * divider -- it is the ASIC's shading division latch, which upstream + * already sets further up (gl124_init_optical_regs_scan, "enable shading"): + * on when shading is in use, off when it is not. The same code is in all + * seven ASIC backends. + * + * Session 10's re-measurement from the old logs: runs 57 and 58 differ at + * the point of the image scan in EXACTLY this one bit (229/229 of the other + * registers and the AFE registers identical), and the difference is the + * whole "width fault": + * run 57 0x01=0x22 dense area 2352 = the full line, data 78.4 % + * run 58 0x01=0x02 dense area 331, data 11.4 % + * The same repeats in the pair 65/66. The width fault therefore occurs ONLY + * when A320E_NO_SHADING is on, because upstream then clears DVDSET -- on + * the production path (shading on) there is no fault. Run 66's + * "improvement" from 11.5 % to 46 % was noise: DVDSET on without a loaded + * shading RAM divides by garbage. A separate A320E_DVDSET switch was + * therefore removed; it also forced the bit in calibration runs, where + * upstream keeps it off. + * + * CORRECTION 2026-08-27e, measured burst by burst from capture 07 against + * run 312: the vendor does NOT keep it off throughout calibration. It is + * off for the offset probes, as here, but ON for the shading scan + * (0x01 = 0x23), where the vendor has loaded the unity table first + * (battery K2) so the division is a pass-through. We run every + * calibration scan with it off. Followed in notes/SEURAAMATTOMAT.md, + * group C2; not changed here, because what it would buy is unmeasured. + */ + /* EXPDMY = "Exposure time of dummy lines" (upstream's own comment, + * gl843.cpp:197 and gl842.cpp:86), i.e. the exposure time of dummy LINES + * -- line time, not pixels. The value 0x2a = 42 is the whole Genesys + * family's SENSOR_DEF default (GL843 initialises it to the same), not a + * number specific to this device. It therefore CANNOT produce a 42 px + * addition of bytes within the line, and the experiment below rested on a + * purely numeric coincidence. + * + * Run 72's experiment (the value 20) rested on a purely numeric coincidence + * (EXPDMY = 42 = the period of the banding), crashed the scan (rc=4, 0 + * bytes) and led to the motor being driven into the wall -- which is what + * finally exposed upstream's unguarded homing (see a320e_home_timeout_ms, + * low.cpp). The root cause of the 42 px period was later found in the + * paging of the shading RAM (see send_shading_data). The A320E_EXPDMY + * switch was removed (hardcoding round 2, 2026-08-20). + */ + + reg->set24(REG_MAXWD, maxwd); reg->set24(REG_LPERIOD, exposure_time); reg->set16(REG_DUMMY, sensor.dummy_pixel); + +} + +/* The image buffer addresses (0xe0..0xf7) per mode. + * + * Registers 0xe0..0xf7 hold the addresses of the ASIC's image buffers: 12 + * 16-bit values = six (begin, end) pairs. The vendor uses TWO different + * layouts and switches between them just before the image scan (measured from + * nine USB captures, see HAVAINNOT.md item 4): + * + * colour six separate buffers of 2714 words (each channel gets + * its own) + * gray/lineart two buffers of 8142 words, repeated across the three + * channel slots (only one channel is digitised) + * + * The rule: [0x0064, 0x3fff] = 16284 words are available, divided into + * 2 x channels parts. In addition 0xf8 = 0x23 in gray, 0x05 in colour. + * + * The A320E entry in tables_memory_layout.cpp was read from the + * INITIALISATION phase of the capture, so it is the colour layout -- without + * this function it would be used in gray too, whereupon the only channel gets + * a third of the buffer it needs. + */ +static void gl124_a320e_set_buffer_layout(Genesys_Device* dev, unsigned channels) +{ + /* Written STRAIGHT to the device: 0xe0..0xf8 are not members of any + * Genesys_Register_Set (gl124_init_memory_layout sends them with + * apply_reg_settings_to_device_write_only), so reg->set16 does not work -- + * the two lessons of run 42, see HAVAINNOT.md. The vendor does the same: + * one register at a time after the calibrations, just before the image scan + * (captures 10 and 11, frames 2725-2761). + */ + constexpr unsigned FIRST = 0x0064; + constexpr unsigned LAST = 0x3fff; + + unsigned chunks = 2 * channels; // two buffers per channel + unsigned size = (LAST - FIRST + 1) / chunks; + + for (unsigned ch = 0; ch < 3; ch++) { + // In gray (channels == 1) every channel slot gets the same pair. + unsigned slot = (channels == 1) ? 0 : ch; + for (unsigned half = 0; half < 2; half++) { + unsigned idx = 2 * slot + half; + unsigned begin = FIRST + idx * size; + unsigned end = (idx == chunks - 1) ? LAST : begin + size - 1; + std::uint16_t addr = 0xe0 + 8 * ch + 4 * half; + dev->interface->write_register(addr, (begin >> 8) & 0xff); + dev->interface->write_register(addr + 1, begin & 0xff); + dev->interface->write_register(addr + 2, (end >> 8) & 0xff); + dev->interface->write_register(addr + 3, end & 0xff); + } + } + dev->interface->write_register(0xf8, (channels == 1) ? 0x23 : 0x05); + DBG(DBG_info, "%s: A320E puskuriasettelu %u kanavalle\n", __func__, channels); } void CommandSetGl124::init_regs_for_scan_session(Genesys_Device* dev, const Genesys_Sensor& sensor, @@ -719,6 +1394,39 @@ void CommandSetGl124::init_regs_for_scan_session(Genesys_Device* dev, const Gene exposure_time = 2304; } else { exposure_time = session.params.exposure_lperiod; + + /* FIX 2026-08-20: LPERIOD from the sensor row, not from a frozen + * option. + * + * genesys.cpp:4736 initialises the OPT_EXPOSURE_TIME option ONCE in + * sane_open, from the default resolution's sensor, and does not update + * it when the resolution changes. dev.settings.exposure_lperiod + * therefore stays at 200 dpi's value of 6256. Calibration escapes this + * only because our A320E branch forces calib_sensor's value on it + * (genesys.cpp:1572, 1903) -- in run 108 all 12 calibration scans used + * the correct 12000 and only the image scan used 6256. + * + * The consequence at 800 dpi: the line period is about half of the + * correct one, and the device only manages to clock every other pixel + * -- in run 108 the even columns were 100 % zeros. + * + * Run 113 (verified on the bus): this was NOT the cause of the 800 dpi + * dropout (that was REG_0x31's speed bit), but the frozen option is a + * genuine fault all the same: the image scan uses the wrong line period + * whenever the resolution differs from the default. ON by default + * (hardcoding round 2, 2026-08-20); A320E_LPERIOD_FIX=0 turns it off + * for A/B control. NOTE: 800 dpi with the fix on has not been run (runs + * 114-121 were run without it) -- verify in a device session. + */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E && + sensor.exposure_lperiod > 0) + { + if (exposure_time != sensor.exposure_lperiod) { + DBG(DBG_info, "%s: A320E LPERIOD %d -> %d (sensoririvilta)\n", + __func__, exposure_time, sensor.exposure_lperiod); + exposure_time = sensor.exposure_lperiod; + } + } } const auto& motor_profile = get_motor_profile(dev->motor.profiles, exposure_time, session); @@ -737,6 +1445,51 @@ void CommandSetGl124::init_regs_for_scan_session(Genesys_Device* dev, const Gene dummy, session.params.starty, session.params.scan_mode, session.params.flags); + /* A320E: register 0x3b is resolution-dependent, and it was the only one of + * the vendor's per-resolution overrides we still got wrong. + * + * The vendor's device definition (docs/ScanApi_07b3_1826.ini, the text + * form of Apple's plist) writes it in every mode section: + * + * Reflection\{Color,Gray}\200 "3b"=hex:ff + * Reflection\{Color,Gray}\{300,400,600,800} "3b"=hex:fc + * + * identically for colour and gray, and the split crosses the vendor's own + * speed blocks -- 300 and 400 dpi include Speed2 exactly as 200 dpi does, + * yet they take 0xfc. The rule is therefore the resolution, not the speed + * block (which is REG_0x31 bit 0x80, see gl124_setup_scan_gpio). There is + * no 100 or 150 dpi section: below 300 dpi the base table's 0xff stands, + * and that is what our initialisation table holds. + * + * The register is named nowhere in the genesys sources and upstream never + * writes it on GL124 -- the 0x3a/0x3b pair that carries frontend data on + * the older ASICs is 0x5d/0x5e here (scanner_interface_usb.cpp), so there + * is no conflict. It reaches the motor all the same. Runs 331/332 forced + * 0xfc at 800 dpi: every motor and timing register stayed byte-identical, + * the duration matched to 0.02 s and the image to 0.1 DN, but the sound of + * the motor changed. Run 346 confirmed that blind -- the spectral centroid + * moved 72 Hz where two controls fell 1 Hz apart (ROADMAP 2026-09-01b). + * Forcing the vendor's VALUE into our context proved the register acts; + * this is the vendor's RULE, which is the change that was actually due. + * + * Keyed on the job's resolution, like the speed block in + * gl124_setup_scan_gpio, so every register set built inside one job -- + * calibration, shading and the image scan alike -- carries the value the + * vendor's section would have held. dev->settings.yres is 0 before the + * first job, which falls on the 0xff side, i.e. the initialisation + * table's own value. + */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + std::uint8_t r3b = (dev->settings.yres >= 300) ? 0xfc : 0xff; + auto& reg_3b = reg->find_reg(0x3b); + if (reg_3b.value != r3b) { + DBG(DBG_info, "%s: A320E 0x3b 0x%02x -> 0x%02x (%u dpi, valmistajan taulu)\n", + __func__, reg_3b.value, r3b, dev->settings.yres); + } + reg_3b.value = r3b; + } + + /*** prepares data reordering ***/ dev->read_active = true; @@ -758,19 +1511,148 @@ ScanSession CommandSetGl124::calculate_scan_session(const Genesys_Device* dev, debug_dump(DBG_info, settings); unsigned move_dpi = dev->motor.base_ydpi / 4; + + /* A320E: the feed moved a quarter of what was commanded (runs 159-164). + * + * `feedl = feed_steps << step_type`, so the driver assumes + * `move_dpi << step_type` = 200 << 1 = 400 pulses per inch. The hardware + * gives 1600 (the motor's full step is 800/inch, half stepping). The factor + * of 4 is measured: the -t series gave the ratios 4.19/4.16/4.16, and a + * null test with a long lever (-t 14 and 28 mm) hit the prediction to + * within 0.4-0.5 lines, whereas with a factor of 4.17 the prediction would + * have been off by 1.1 and 3.9 lines. + * + * The `/4` above is upstream's shared code (the LiDE models rely on it), so + * the fix is restricted by ModelId. + * + * A/B without recompiling: A320E_FEED_FIX=0 restores the old (4x short). + */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + move_dpi = dev->motor.base_ydpi; + DBG(DBG_info, "%s: A320E syottokerroin korjattu -> move_dpi = %d\n", + __func__, move_dpi); + } + float move = dev->model->y_offset; + /* A320E_YOFF: a diagnostic switch (2026-08-21, blocker 1). + * + * y_offset = 14.7 mm is the distance between the home position and the + * image origin. Inside it lies an area that is UNDER THE FRAME and shows in + * no ordinary scan -- and that is exactly where a flatbed CCD scanner's + * calibration strip would be, if there is one. The switch zeroes the + * margin, so imaging starts from the home position and brings that area + * into view. + * + * See HAVAINNOT.md, chapter "Blokkaaja 1:n esityo": the documents + * contradict each other over whether the strip exists, and visual + * inspection is no help (the carriage rests right on top of it). + * + * A320E_YOFF=0 imaging from the home position (the experiment) + * A320E_YOFF=14.7 the model's default (the control) + */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + if (const char* env = std::getenv("A320E_YOFF")) { + move = static_cast(std::strtod(env, nullptr)); + DBG(DBG_info, "%s: A320E_YOFF -> y_offset = %.2f mm\n", __func__, move); + } + } move += dev->settings.tl_y; + + /* The user's origin correction (OPT_Y_ORIGIN_OFFSET, default 0). The + * carriage cannot travel behind the home position, so a negative sum is + * clipped to zero -- without the clipping the static_cast below + * would wrap around. + */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + move += dev->a320e_origin_offset_y; + if (move < 0.f) { + move = 0.f; + } + } + + /* A320E: FORWARD TRAVEL GUARD. + * + * There is exactly ONE position sensor on this device and it is at the + * home end: the macOS driver's `HomeSwitch = 0x32,0x02,0x02` names the + * register, the mask and the value, and nothing in the plist, the INI or + * the captures names a second one. Travel away from home is therefore an + * OPEN LOOP -- no hardware stops the carriage at the far end, and + * `LINCNT` and `FEEDL` are 24-bit, so the ASIC will happily accept a + * command that drives the mechanism into its end stop. Run 72 + * (2026-08-19) showed what that costs at the home end: the carriage + * ground against the stop until the user cut the power. + * + * The largest travel the vendor is known to command is 449 mm: capture + * 05 (300 dpi, full area) has LINCNT = 5209 at 300 dpi = 441.0 mm of + * imaging, started from `AreaOrigin` y = 255 @800 = 8.1 mm. Our own full + * length is y_offset 14.7 + y_size 431.8 = 446.5 mm, i.e. inside it -- + * but only by 2.6 mm, and `y_offset` itself carries +-1.7 mm of + * uncertainty. A model-table edit or a unit-conversion slip would put + * the carriage past the vendor's proven envelope without anything + * noticing, so the limit is checked here rather than trusted. + * + * This is a guard, not a feature: with the shipped table it can never + * fire. If it does, the scan is refused instead of the mechanism being + * driven blind. + */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + const float max_travel_mm = 449.f; + float travel_mm = move + (static_cast(settings.lines) * MM_PER_INCH) / + static_cast(settings.yres); + if (travel_mm > max_travel_mm) { + throw SaneException("A320E: scan would move the carriage %.1f mm, past the %.1f mm " + "the mechanism is known to have (no end sensor exists)", + travel_mm, max_travel_mm); + } + DBG(DBG_info, "%s: A320E travel %.1f mm of %.1f mm allowed\n", __func__, + travel_mm, max_travel_mm); + } + move = static_cast((move * move_dpi) / MM_PER_INCH); float start = dev->model->x_offset; start += settings.tl_x; + + // The user's origin correction (OPT_X_ORIGIN_OFFSET, default 0); see y above. + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + start += dev->a320e_origin_offset_x; + if (start < 0.f) { + start = 0.f; + } + } + + /* A320E: the same x scale correction that widens the scan + * (a320e_x_scale, low.cpp) belongs here too. `start` is still in + * millimetres, and one millimetre of glass is 0.75 % more pixels than + * this 400 dpi grid says. Without the factor the image would start + * 0.14 mm too far to the left (18.35 mm x 0.0075) even though its width + * was right -- the whole picture would be shifted, which is the very + * thing the origin was measured to avoid. A no-op on other models. + */ + start *= a320e_x_scale(*dev); + start /= sensor.full_resolution / sensor.get_optical_resolution(); start = static_cast((start * settings.xres) / MM_PER_INCH); + /* A320E: round the origin instead of truncating it. Upstream's cast + * always rounds DOWN, and the bias is systematic: the x scale correction + * moves the origin by only 0.14 mm (18.35 mm x 0.0075), so losing up to + * a whole pixel of it -- 291.2 measured as 290 at 400 dpi -- eats a + * fifth of the very correction being applied. Half a pixel is 0.03 mm + * and cannot be aimed better than that; a whole one is avoidable. + * Left alone on other models: they have no such correction to protect, + * and a one-pixel origin shift is not something to hand upstream + * unmeasured. + */ + unsigned startx = static_cast(start); + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + startx = static_cast(std::lround(start)); + } + ScanSession session; session.params.xres = settings.xres; session.params.yres = settings.yres; - session.params.startx = static_cast(start); + session.params.startx = startx; session.params.starty = static_cast(move); session.params.pixels = settings.pixels; session.params.requested_pixels = settings.requested_pixels; @@ -797,8 +1679,32 @@ ScanSession CommandSetGl124::calculate_scan_session(const Genesys_Device* dev, * */ void CommandSetGl124::save_power(Genesys_Device* dev, bool enable) const { - (void) dev; DBG_HELPER_ARGS(dbg, "enable = %d", enable); + + /* On upstream this is an empty function for GL124: nothing is switched + * off after a scan. On the A320E it belongs there -- the motor's windings + * stay energised (REG_0x02 bit MTRPWR) and the device is left humming in + * place. The user noticed this on 2026-08-20 after run 151: "the motor + * runs but the carriage doesn't move", a steady sound. + * + * The vendor too leaves MTRPWR on (capture 07, the last writes: 0x01 = + * 0x22, 0x02 = 0x30, 0x03 = 0x00), but there is no reason for holding + * torque once the carriage is home and the scan is over. The lamp bit + * LAMPPWR is cleared at the same time as a precaution, even though + * end_scan already writes REG_0x03 = 0x00. + */ + if (dev->model->model_id != ModelId::PLUSTEK_OPTICPRO_A320E || !enable) { + return; + } + + std::uint8_t r02 = dev->interface->read_register(REG_0x02); + dev->interface->write_register(REG_0x02, r02 & ~REG_0x02_MTRPWR); + + std::uint8_t r03 = dev->interface->read_register(REG_0x03); + dev->interface->write_register(REG_0x03, r03 & ~REG_0x03_LAMPPWR); + + DBG(DBG_info, "%s: A320E lepotila: 0x02 0x%02x -> 0x%02x, 0x03 0x%02x -> 0x%02x\n", + __func__, r02, r02 & ~REG_0x02_MTRPWR, r03, r03 & ~REG_0x03_LAMPPWR); } void CommandSetGl124::set_powersaving(Genesys_Device* dev, int delay /* in minutes */) const @@ -828,6 +1734,55 @@ void gl124_setup_scan_gpio(Genesys_Device* dev, int resolution) std::uint8_t val = dev->interface->read_register(REG_0x32); + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // A320E: GPIO15 (0x40) is the lamp control. The Windows driver writes + // 0x32 = 0x4e before every scan (capture 05b, frames 103, 1079, 2709) + // and 0x0e when the lamp is switched off. The LiDE models' + // resolution-dependent bits 0x08/0x10 do not apply to this device. + val = (val & ~0x10) | 0x40; + dev->interface->write_register(REG_0x32, val); + + /* REG_0x31 bit 0x80 is the SPEED BLOCK select. + * + * The vendor's definition has two speed blocks, Speed1 ("60M+12CLK", + * 600/800 dpi) and Speed2 ("48M+12CLK", <=400 dpi), and the INI sets + * "31.80"=00 and "31.80"=80 in them respectively. The value 0x80 in our + * gpios[] table was read from capture 05b, which is 200 dpi, i.e. + * Speed2 -- so it is right at <=400 dpi and WRONG at 600/800 dpi, and + * nothing in the driver adjusted it by resolution. + * + * Measured (runs 114/115, both verified on the bus with regseq.py): + * 800 dpi, 0x31=0x80 (the old): data 50.0 % -- every other + * pixel exactly zero + * 800 dpi, 0x31=0x00 (this) : data 100.0 % + * 200 dpi, 0x31=0x00 : data 26.2 % (control 30.8 %) + * i.e. the bit is right the other way round in the two speed ranges. + * + * NOTE on runs 109-112: earlier attempts to set this elsewhere were + * overwritten and never reached the device. This place is in + * begin_scan, so it holds. Verify on the bus all the same: + * tools/regseq.py --at-scan --expect 0x031=0x00 + */ + /* Bit 0x80 = the speed block select, see a320e_r31_shadow (device.h). + * ONLY that bit is changed, on top of the last value written -- the + * register must not be read back, because a read returns the states of + * the pins. + */ + std::uint8_t r31 = dev->a320e_r31_valid ? dev->a320e_r31_shadow + : gpios[3].r31; // gpios[3] = A320E + std::uint8_t want = (resolution >= 600) ? (r31 & ~0x80) // Speed1 60 MHz + : (r31 | 0x80); // Speed2 48 MHz + if (want != r31) { + DBG(DBG_info, "%s: A320E REG_0x31 0x%02x -> 0x%02x (%d dpi, %s)\n", + __func__, r31, want, resolution, + (resolution >= 600) ? "Speed1" : "Speed2"); + } + dev->a320e_r31_shadow = want; + dev->a320e_r31_valid = true; + dev->interface->write_register(REG_0x31, want); + return; + } + /* LiDE 110, 210 and 220 cases */ if(dev->model->gpio_id != GpioId::CANON_LIDE_120) { if(resolution>=dev->motor.base_ydpi/2) @@ -887,6 +1842,52 @@ void CommandSetGl124::begin_scan(Genesys_Device* dev, const Genesys_Sensor& sens val |= REG_0x01_SCAN; dev->interface->write_register(REG_0x01, val); + // dev->session is always set before this call + // (init_regs_for_scan_session), so the channel count is the session's own. + // Confirmed in runs 42-43: gray works only with this, colour is unchanged. + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + gl124_a320e_set_buffer_layout(dev, dev->session.params.channels); + } + + /* A320E_FORCE -- a register override at the LAST moment. + * + * Background (runs 109-112): earlier overrides were done in + * gl124_init_optical_regs_scan, and the motor phase, the lamp control and + * gl124_setup_scan_gpio rewrote them before the scan. A capture comparison + * (regdump.py --diff run108 run112) showed that of 15 intended registers + * only three reached the device -- i.e. two "eliminations" were void. Debug + * output at the point of the change proves nothing. + * + * This block runs in begin_scan BEFORE scanner_start_action and writes + * STRAIGHT to the device, so no phase of the driver gets in between. + * Verification is still always done from a capture: tools/regat.py. + * + * Usage: A320E_FORCE="0x31=0x00,0x7e=0x2e,0x7f=0xe0" + */ + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + if (const char* env = std::getenv("A320E_FORCE")) { + std::string spec(env); + std::size_t pos = 0; + while (pos < spec.size()) { + std::size_t comma = spec.find(',', pos); + if (comma == std::string::npos) { + comma = spec.size(); + } + std::string item = spec.substr(pos, comma - pos); + std::size_t eq = item.find('='); + if (eq != std::string::npos) { + unsigned r = std::strtoul(item.substr(0, eq).c_str(), nullptr, 0); + unsigned v = std::strtoul(item.substr(eq + 1).c_str(), nullptr, 0); + DBG(DBG_info, "%s: A320E_FORCE 0x%03x = 0x%02x\n", + __func__, r, v); + dev->interface->write_register(static_cast(r), + static_cast(v)); + } + pos = comma + 1; + } + } + } + scanner_start_action(*dev, start_motor); dev->advance_head_pos_by_session(ScanHeadId::PRIMARY); @@ -944,7 +1945,11 @@ void CommandSetGl124::init_regs_for_shading(Genesys_Device* dev, const Genesys_S session.params.yres = resolution; session.params.startx = 0; session.params.starty = move; - session.params.pixels = dev->model->x_size_calib_mm * resolution / MM_PER_INCH; + /* a320e_shading_width_mm is `x_size_calib_mm` on every other model. On the + * A320E it is wider, because the shading window starts at the sensor's + * pixel 0 while the image window starts at x_offset -- see low.cpp. + */ + session.params.pixels = a320e_shading_width_mm(*dev) * resolution / MM_PER_INCH; session.params.lines = calib_lines; session.params.depth = 16; session.params.channels = channels; @@ -954,6 +1959,66 @@ void CommandSetGl124::init_regs_for_shading(Genesys_Device* dev, const Genesys_S session.params.contrast_adjustment = dev->settings.contrast; session.params.brightness_adjustment = dev->settings.brightness; session.params.exposure_lperiod = dev->settings.exposure_lperiod; + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // The same double fix as in the offset and gain calibrations + // (genesys.cpp, a320e_align_calib_pixels): + // * x_size_calib_mm is a float, so the pixel count truncates to an odd + // number and the hardware sends more than was asked for -> Overflow; + // * the exposure has to come from calib_sensor, otherwise the scan's + // lperiod matches no motor profile at this resolution and + // get_motor_profile throws "Motor slope is not configured". + unsigned group = (resolution > 0) ? calib_sensor.full_resolution / resolution : 1; + if (group == 0) { + group = 1; + } + session.params.pixels = ((session.params.pixels + group - 1) / group) * group; + + /* MEASURED (runs 243 and 249, tools/rivinpituus.py): the ASIC emits an + * EVEN number of pixels per line. Asked for ENDPIXEL - STRPIXEL = 9599 + * it sends 9598, so a buffer sliced at 9599 places every row one pixel + * further left than the one before it -- fitted over 95 rows the drift + * is lag = -1.0000 * y, exactly. The group rounding above does not + * catch this at 800 dpi, where group = full_resolution / resolution = 1 + * (ROADMAP 3.3). + * + * What it costs: the strip's odd/even pixel difference (+4.9 % R, + * +4.7 % G, +2.1 % B) changes sign on every row of the buffer and + * cancels in the 95-row average, leaving the coefficients with no + * parity correction at all (+0.05 %). At 800 dpi -- the only mode whose + * output is 1:1 with the sensor -- the sensor's own parity then passes + * into the image uncorrected. Below 800 dpi the ASIC averages pixel + * pairs and hides it. + * + * The vendor keeps this window even: STRPIXEL 23, ENDPIXEL 10155 = + * 10132 px (capture 07). The gain calibration already forces evenness + * for the same reason (genesys.cpp, scanner_coarse_gain_calibration); + * this path was missed. + */ + /* CONFIRMED on the device (runs 253/254, an A/B on the same binary and + * the same target): the ripple at 800 dpi drops from 13.31/8.28/10.95 + * to 0.13/1.91/0.66 DN, and the strip's parity stops flipping sign + * from row to row (7/7 sign changes -> 0/7), so it survives into the + * coefficients. The A320E_EVEN_SHADING switch was removed once + * confirmed, per the working practice. + */ + session.params.pixels &= ~1u; + DBG(DBG_info, "%s: A320E shading window %u px (even)\n", __func__, + session.params.pixels); + + session.params.exposure_lperiod = calib_sensor.exposure_lperiod; + + /* REFUTED (run 107, 2026-08-20): the vendor's 512-pixel shading + * window does NOT fit this path. Windows calibrates STRPIXEL 23 / + * ENDPIXEL 535 = 512 px, but send_shading_data indexes the + * coefficients by absolute pixel, so a 512 px calibration left the + * rest of a 100 mm window at zero (data 76.0 % -> 8.6 %). The vendor + * therefore does not index its coefficients 1:1; the window cannot be + * narrowed without changing the mapping. Full width is the right thing + * for this path. Offset and gain still use 512 (genesys.cpp, + * A320E_CALIB_PIXELS). The switch was removed (hardcoding round 2, + * 2026-08-20). + */ + } session.params.flags = ScanFlag::DISABLE_SHADING | ScanFlag::DISABLE_GAMMA | ScanFlag::DISABLE_BUFFER_FULL_MOVE; @@ -1000,6 +2065,413 @@ void CommandSetGl124::send_shading_data(Genesys_Device* dev, const Genesys_Senso std::uint32_t addr, length, segcnt, pixels, i; std::uint8_t *ptr, *src; + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + /* The data is interleaved (compute_coefficients): 4 bytes per channel + * per pixel, the channels consecutive within a pixel. The scan window's + * share is uploaded as a SINGLE write, the way Windows does it. The + * generic code's three separate writes to the same address + * (0xd0..0xd2 = 0x0a) overwrote each other -- that was the cause of the + * one-third effect (HAVAINNOT.md, run 48). + */ + /* Two different channel counts (run 51, proved by + * gl_white_shading.tiff): CALIBRATION is always run in colour, so the + * data is interleaved across 3 channels. The ASIC, on the other hand, + * consumes 1 element per pixel in a gray image scan (the upload in the + * Windows gray capture = the window's pixels, not x3). For gray the + * GREEN channel's elements are picked out; without the picking, the + * slice lands on the interleaved data wrongly and the coefficients come + * out as garbage (the saturated coefficients of run 49). + */ + unsigned calib_channels = dev->calib_session.params.channels; + unsigned scan_channels = dev->session.params.channels; + std::uint32_t strpx = dev->session.pixel_startx; + std::uint32_t endpx = dev->session.pixel_endx; + std::uint32_t px_count = endpx - strpx; + + /* shading_factor: the window (strpx..endpx) is in units of the SHADING + * RESOLUTION -- gl843.cpp:1733 computes it as + * output_pixels * shading_resolution / xres. At 200 dpi that is + * 312 * 400/200 = 624, when there are 312 output pixels. The ASIC + * consumes ONE coefficient per output pixel, so the source has to be + * decimated -- the generic code does that with the step + * x += 4 * sensor.shading_factor (gl124.cpp:1697, the same in + * gl846/gl847). + * + * In our table shading_factor is 4 (100 dpi), 2 (150/200) and + * 1 (300-800). Without the decimation, consecutive output pixels get + * the coefficient of an even and an odd SENSOR pixel in turn. At the + * edge of the origin the difference between them is 90-97 DN at a level + * of 177 (run 217, the white calibration burst) -- and that is what + * produces the period-2 ripple in output space. See ROADMAP, "Vareilyn + * jahti 2026-08-22". + */ + unsigned sf = sensor.shading_factor ? sensor.shading_factor : 1; + std::uint32_t out_count = px_count / sf; + if (out_count == 0) { + out_count = 1; + } + /* The ASIC steps through the shading RAM THREE elements per pixel in + * every mode -- the stride does not follow the scan's channel count. + * So a gray scan needs a table of the same size as a colour one, with + * the single channel's coefficient repeated into all three slots. + * + * MEASURED 2026-08-28 (runs K027/K032/K033/K034). Writing one element + * per pixel in gray, as this code did, made the table run out after + * exactly a third of the line: + * + * pixels 0..N/3 the ASIC reads elements 0, 3, 6, ... and so gets + * the coefficient of a pixel three times further + * along. The vignetting gain falls with x, so the + * gain applied is too small and the image comes out + * dark -- 11 % at 600 dpi. + * pixels N/3.. the table is exhausted and the ASIC reads whatever + * is still in RAM, which is the PREVIOUS colour + * calibration's interleaved table. There element + * 3x+1 happens to be exactly pixel x's green + * coefficient, so this part came out correct -- and + * that is why the defect looked like a threshold. + * + * The boundary predicted from 5984/3 = 1994.7 elements is 63.33 mm and + * the measured one is 63.37 mm. An A320E_UNITY_SHADING control moved + * the step to the other side of the same boundary, which ruled out the + * coefficient values as the cause. + * + * This is the one-third effect of run 48 returning by another route: + * it was fixed for colour (the three separate writes above), and the + * gray path recreated it. + */ + constexpr unsigned SHADING_STRIDE = 3; + std::uint32_t byte_count = out_count * 4 * SHADING_STRIDE; + + std::vector out(byte_count, 0); + std::uint32_t first_missing = out_count; + for (std::uint32_t x = 0; x < out_count; x++) { + for (unsigned c = 0; c < SHADING_STRIDE; c++) { + // gray (scan 1ch, calib 3ch): channel 1 = green, into every slot + unsigned src_c = (scan_channels == calib_channels) ? c : 1; + std::uint32_t src = ((strpx + x * sf) * calib_channels + src_c) * 4; + std::uint32_t dst_off = (x * SHADING_STRIDE + c) * 4; + if (src + 4 <= (std::uint32_t)size) { + memcpy(out.data() + dst_off, data + src, 4); + } else if (x < first_missing) { + first_missing = x; + } + } + } + + /* Say it out loud when the calibration data does not reach as far as + * the scan window. `out` is zero-initialised, so the shortfall is + * uploaded as a zero gain and the ASIC returns those pixels BLACK -- + * silently, and only at the right-hand edge, which is exactly where + * nobody looks. That was the defect measured on 2026-08-29 (runs + * K066-K073): the image went black at 284.3 mm because the shading + * window began at pixel 0 while the image began at x_offset. The fix + * is a320e_shading_width_mm (low.cpp); this warning is here so the + * next occurrence is one grep away instead of one measurement away. + */ + if (first_missing < out_count) { + DBG(DBG_warn, "%s: calibration data ends at output pixel %u of %u" + " (sensor pixel %u); the rest is uploaded as zero gain and will" + " scan BLACK\n", __func__, first_missing, out_count, + strpx + first_missing * sf); + } + + /* ...and the other half of the same failure: the white REFERENCE may + * end before the shading window does. + * + * This device has no internal white strip -- its lid backing is + * nearly black (64.8 DN against a white sheet's 252.6, captures + * 05b/05c) -- so the white reference is whatever the user laid on the + * glass. Since the shading window was widened to the full width of + * the bed (a320e_shading_width_mm), the target has to span the bed + * too: no standard paper size does, A3 being 297 mm against the bed's + * 304.8. Where the target stops, the white average collapses, the + * gain coefficient computed from it explodes, and those columns scan + * as bright noise -- which looks like a device fault and is not one. + * + * The check is a ratio, not a level: the lamp's own vignetting makes + * the ends of the strip darker than the middle by design, and only a + * collapse to a small fraction of the middle means "nothing is there". + * The measurement it is calibrated against: with a 310 mm card the + * edge of the window reads a normal level; where paper ends the level + * falls by more than 4x within a millimetre. + */ + const auto& white = dev->white_average_data; + if (!white.empty() && out_count >= 16) { + std::uint32_t mid = out_count / 2; + std::size_t mid_idx = static_cast(mid) * calib_channels; + if (mid_idx < white.size()) { + unsigned reference = white[mid_idx]; + unsigned floor_level = reference / 4; + std::uint32_t ends_at = out_count; + for (std::uint32_t x = mid; x < out_count; x++) { + std::size_t idx = static_cast(x) * calib_channels; + if (idx >= white.size()) { + break; + } + if (white[idx] < floor_level) { + ends_at = x; + break; + } + } + if (ends_at < out_count) { + DBG(DBG_warn, "%s: white reference ends at output pixel %u of %u" + " (level %u against %u at mid-window). The calibration target" + " does not span the bed; scans wider than that will be noisy" + " at the right-hand edge\n", __func__, ends_at, out_count, + white[static_cast(ends_at) * calib_channels], reference); + } + } + } + /* The A320E_COEFF_CLAMP diagnostic was removed (2026-08-20, runs + * 128/129/132): the ceiling of 0x4000 is not the hardware's limit (the + * vendor uses coefficients of 19301-65535, capture 03 frame 2605) but + * masked the same overflow that the target margin fixes properly -- + * see genesys.cpp, the A320E's target_code. + */ + + /* PARITY FILTERING (2026-08-22b, a coefficient comparison of run 237 + * against the vendor's capture 06): our coefficients have an odd/even + * difference of ~9 %, the vendor's uploaded ones ~0.1 %, even though + * the parity of the raw data is the same ~2 % in both. The parity + * arises ONLY in the calibration conditions on the strip (the staggered + * rows see different light under the frame, cf. StaggeredLine); on the + * glass during a scan it is absent, so an unfiltered coefficient + * presses it into the image (the period-2 ripple at 300-800 dpi, where + * sf = 1). + * The fix: remove the coefficient's local parity difference window by + * window (cf. the vendor's PixelGainFilter): g'(x) = g(x) - mean of its + * own parity + mean of all, window +-16 px. This preserves the + * vignetting profile and the per-pixel PRNU relative to its own parity + * group. + * MEASURED ON HARDWARE 2026-08-27e (runs 301-311), after the offset + * parity sign normalisation below made the measurement possible at + * all: run 255 had tried the same A/B and measured the draw instead + * of the filter. Five filtered runs against four unfiltered ones, + * both sets containing both draws: + * + * filter ripple, period 2 parity of the loaded gains + * on 0.08 .. 1.26 DN 0.26 .. 0.37 % + * off 2.55 .. 6.78 DN 8.79 .. 12.15 % + * + * The image level is the same either way (97.0 / 125.5 / 86.3 in both + * sets), so the filter removes the ripple without moving the picture. + * The vendor's own uploaded coefficients sit at ~0.1 %, i.e. on the + * filtered side. The A320E_PARITY_FILTER switch was removed with this + * measurement, per the working practice: confirmed becomes default. */ + if (out_count >= 8) { + const int W = 16; + for (unsigned c = 0; c < scan_channels; c++) { + std::vector g(out_count); + for (std::uint32_t x = 0; x < out_count; x++) { + std::size_t o = (x * scan_channels + c) * 4; + g[x] = out[o + 2] | (out[o + 3] << 8); + } + for (std::uint32_t x = 0; x < out_count; x++) { + int lo = static_cast(x) - W; + int hi = static_cast(x) + W; + if (lo < 0) lo = 0; + if (hi >= static_cast(out_count)) hi = out_count - 1; + float sum_all = 0, sum_own = 0; + int n_all = 0, n_own = 0; + for (int i = lo; i <= hi; i++) { + sum_all += g[i]; + n_all++; + if (((i ^ static_cast(x)) & 1) == 0) { + sum_own += g[i]; + n_own++; + } + } + float corrected = g[x] - sum_own / n_own + sum_all / n_all; + int v = static_cast(corrected + 0.5f); + if (v < 0) v = 0; + if (v > 65535) v = 65535; + std::size_t o = (x * scan_channels + c) * 4; + out[o + 2] = v & 0xff; + out[o + 3] = (v >> 8) & 0xff; + } + } + } + + /* OFFSET PARITY SIGN NORMALISATION (2026-08-27c; measured from the + * archive of runs 253-287, 0 device runs of its own). + * + * The sensor clocks even and odd pixels through two pins whose black + * pedestals differ by ~1400 DN16 at 600/800 dpi (a device constant, + * and absent at 200/400 dpi -- it arrives with Speed1). The image + * data carries that split deterministically, but the CALIBRATION + * session's reading of it draws its sign at random: the shading + * burst's parity is stable within a run and varies between runs with + * the bus traffic byte for byte identical (ROADMAP, "Vaihe mitattu + * arkistosta"). compute_coefficients writes dark_average_data into + * the offset field as it stands, so the drawn sign reaches the ASIC's + * out = (in - dark) * coeff / 0x4000 -- and there it either cancels + * the sensor's own split or doubles it. + * + * Measured over the 14 captures at 600/800 dpi that have loaded + * coefficients: a NEGATIVE parity in this frame (even indices below + * odd) gives 0.13-1.05 DN of period-2 ripple, a positive one gives + * 15-25 DN, without exception. The ripple does not depend on the + * parity's AMPLITUDE (29-100 % of the level, ripple 24.2-25.4 DN + * throughout) -- only on its sign, which is what the sign is worth + * normalising rather than removing. + * + * The sign is measured with the local form (-1)^x (v[x] - (v[x-1] + + * v[x+1])/2), not as a difference of group means: the window is 628 + * px of vignetting profile, and a difference of means picks the tilt + * up as parity (the same measurement error as in a320e_tap_split). + * The turn keeps the amplitude and the per-pixel PRNU relative to the + * pixel's own parity group -- v'(x) = v(x) - 2 d(x), where d(x) is + * the local parity deviation over the same +-16 px window the gain + * filter above uses. + * + * The 5 % threshold leaves the low resolutions alone: their parity is + * 0.0-0.5 % of the level (runs 272-285, five captures), i.e. noise + * with no sign worth normalising, and they are the control for this + * change not breaking anything outside 600/800 dpi. + * + * CONFIRMED ON THE DEVICE (runs 288-298, 2026-08-27c). Eight runs + * with the fix: four drew the wrong sign and were turned, two drew + * the right one and were left alone, and the ripple is 0.36-1.11 DN + * in every one of them -- where before the fix the same wrong draw + * gave 24-25 DN and only one run in three came out clean. The A/B is + * direct: run 298 with the fix switched off drew the wrong sign and + * measured 24.52 / 15.43 / 21.31 DN, the same binary and target as + * runs 288-291. The controls at 200 and 400 dpi stayed at 0.02 and + * 0.09-0.14 DN with no turn, as the threshold requires. The A/B + * switch was removed after this, per the working practice. */ + { + if (out_count >= 8) { + const int W = 16; + const float PARITY_MIN = 0.05f; // of the level + for (unsigned c = 0; c < scan_channels; c++) { + std::vector v(out_count); + double level = 0; + for (std::uint32_t x = 0; x < out_count; x++) { + std::size_t o = (x * scan_channels + c) * 4; + v[x] = out[o + 0] | (out[o + 1] << 8); + level += v[x]; + } + level /= out_count; + + double parity = 0; + for (std::uint32_t x = 1; x + 1 < out_count; x++) { + double local = v[x] - (v[x - 1] + v[x + 1]) / 2; + parity += (x & 1) ? -local / 2 : local / 2; + } + parity /= out_count - 2; + + if (level <= 0 || parity <= PARITY_MIN * level) { + DBG(DBG_info, "%s: A320E offset-pariteetti ch %u:" + " %+.2f %% tasosta %.0f -- ei kaanneta\n", __func__, + c, 100 * parity / (level > 0 ? level : 1), level); + continue; + } + + for (std::uint32_t x = 0; x < out_count; x++) { + int lo = static_cast(x) - W; + int hi = static_cast(x) + W; + if (lo < 0) lo = 0; + if (hi >= static_cast(out_count)) hi = out_count - 1; + float sum_all = 0, sum_own = 0; + int n_all = 0, n_own = 0; + for (int i = lo; i <= hi; i++) { + sum_all += v[i]; + n_all++; + if (((i ^ static_cast(x)) & 1) == 0) { + sum_own += v[i]; + n_own++; + } + } + float turned = v[x] - 2 * (sum_own / n_own - sum_all / n_all); + int d = static_cast(turned + 0.5f); + if (d < 0) d = 0; + if (d > 65535) d = 65535; + std::size_t o = (x * scan_channels + c) * 4; + out[o + 0] = d & 0xff; + out[o + 1] = (d >> 8) & 0xff; + } + DBG(DBG_info, "%s: A320E offset-pariteetti ch %u:" + " %+.2f %% tasosta %.0f -- KAANNETTY\n", __func__, + c, 100 * parity / level, level); + } + } + } + + /* A320E_UNITY_SHADING=1: a neutral coefficient table, gain 16384 + * (= 1.0) and offset 0 for every pixel. + * + * This is not an invention of ours but the vendor's own device: in the + * captures it uploads exactly this table first and only then the real + * coefficients (capture 07, write_ahb 0x10014000; tools/ahbdump.py + * "lataus 1 on yksikkotaulu"). The device therefore certainly accepts + * it. + * + * The purpose is to separate two alternatives that no measurement so + * far distinguishes: does the period-2 ripple arise in the ASIC's read + * path (in which case the correction neither produces it nor can remove + * it), or in the shading correction (in which case our coefficients are + * wrong in a way that creates it). See notes/linux/README.md, "Ajot + * 249-252" and "Kerroinvirtojen vertailu". + * + * A diagnostic switch, not for upstream -- the same class as + * A320E_FORCE. */ + if (const char* env = std::getenv("A320E_UNITY_SHADING")) { + if (env[0] != '0') { + for (std::uint32_t x = 0; x < out_count; x++) { + for (unsigned c = 0; c < SHADING_STRIDE; c++) { + std::size_t o = (x * SHADING_STRIDE + c) * 4; + out[o + 0] = 0x00; // offset lo + out[o + 1] = 0x00; // offset hi + out[o + 2] = 0x00; // gain lo ) 0x4000 = 1.0 + out[o + 3] = 0x40; // gain hi ) + } + } + DBG(DBG_info, "%s: A320E_UNITY_SHADING: %u px neutraalia" + " kerrointa\n", __func__, out_count); + } + } + + std::uint8_t val = dev->interface->read_register(0xd0); + std::uint32_t dst = val * 8192 + 0x10000000; + DBG(DBG_info, "%s: A320E lomitettu varjostus: %u tavua @ 0x%08x" + " (ikkuna %u..%u, sf %u -> %u px, calib %u ch -> scan %u ch)\n", __func__, + byte_count, dst, strpx, endpx, sf, out_count, calib_channels, + scan_channels); + + /* CONFIRMED (runs 103/106, a clean A/B on the same binary): the + * shading RAM is PAGED. gl843.cpp:1741-1776: 252 words of payload, + * then a gap of 4 words, page 256 words. The interleaving is 12 bytes + * per pixel, so 504/12 = 42 pixels -- exactly the measured period of + * the phase slip. Without the gap the ASIC reads pixel p's coefficients + * from word address 6p + 4*floor(p/42). + * + * Measurement at 200 dpi / 20 mm: paging on, data 100.0 % and an intact + * image; off, 30.8 % and 42 px stripes. ON by default (hardcoding round + * 2, 2026-08-20); A320E_SHADING_GAP=0 turns it off for A/B control. + * OPEN: in a wide window (100 mm) paging alone is not enough (runs + * 104/105), and runs 114-121 (800 dpi) were run with paging OFF -- + * 800 dpi has to be verified on squared paper. + */ + { + std::uint32_t final_size = ((byte_count + 503) / 504) * 512; + std::vector paged(final_size, 0); + std::uint32_t count = 0; + for (std::uint32_t i = 0; i < byte_count; i++) { + paged[count] = out[i]; + count++; + if ((count % 512) == 504) { + count += 8; + } + } + DBG(DBG_info, "%s: A320E sivutus paalla: %u -> %u tavua" + " (%u sivua)\n", __func__, byte_count, count, + (byte_count + 503) / 504); + dev->interface->write_ahb(dst, count, paged.data()); + return; + } + } + /* logical size of a color as seen by generic code of the frontend */ length = size / 3; std::uint32_t strpixel = dev->session.pixel_startx; @@ -1202,13 +2674,30 @@ static void gl124_init_gpio(Genesys_Device* dev) idx = 0; } else if (dev->model->model_id == ModelId::CANON_LIDE_120) { idx = 2; + } else if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + idx = 3; } else { /* canon LiDE 210 and 220 case */ idx = 1; } - dev->interface->write_register(REG_0x31, gpios[idx].r31); + /* SOLVED 2026-08-20 (runs 114/115, verified on the bus): bit 0x80 of + * REG_0x31 is the speed block select -- Speed1 (60 MHz, 600/800 dpi) + * "31.80"=00, Speed2 (48 MHz, <=400 dpi) "31.80"=80. The table's value 0x80 + * is Speed2's; gl124_setup_scan_gpio adjusts the bit by resolution before + * every scan. The A320E_GPIO31 switch was removed (hardcoding round 2): + * forcing the lower bits drives the mechanics (on one occasion a power + * cycle was needed), and bit 0x80 is now adjusted in code. + */ + { + std::uint8_t r31 = gpios[idx].r31; + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + dev->a320e_r31_shadow = r31; // keep the shadow value (see device.h) + dev->a320e_r31_valid = true; + } + dev->interface->write_register(REG_0x31, r31); + } dev->interface->write_register(REG_0x32, gpios[idx].r32); dev->interface->write_register(REG_0x33, gpios[idx].r33); dev->interface->write_register(REG_0x34, gpios[idx].r34); @@ -1217,6 +2706,7 @@ static void gl124_init_gpio(Genesys_Device* dev) dev->interface->write_register(REG_0x38, gpios[idx].r38); } + /** * set memory layout by filling values in dedicated registers */ @@ -1275,7 +2765,16 @@ void CommandSetGl124::asic_boot(Genesys_Device* dev, bool cold) const dev->interface->write_registers(dev->reg); // tune reg 0B - dev->interface->write_register(REG_0x0B, REG_0x0B_30MHZ | REG_0x0B_ENBDRAM | REG_0x0B_64M); + if (dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // The A320E's own initialisation table sets 0x0b = 0x6c, i.e. 48 MHz + // and 256 Mbit DRAM. The LiDE models' hard value of 0x2a (30 MHz, + // 64 Mbit) would overwrite it, whereupon the memory map's address space + // is the wrong size and image data is read from an area the scan has not + // filled. + dev->interface->write_register(REG_0x0B, dev->reg.find_reg(0x0b).value); + } else { + dev->interface->write_register(REG_0x0B, REG_0x0B_30MHZ | REG_0x0B_ENBDRAM | REG_0x0B_64M); + } dev->reg.remove_reg(0x0b); //set up end access @@ -1283,7 +2782,11 @@ void CommandSetGl124::asic_boot(Genesys_Device* dev, bool cold) const dev->interface->write_0x8c(0x13, 0x0e); /* CIS_LINE */ - dev->reg.init_reg(0x08, REG_0x08_CIS_LINE); + if (dev->model->model_id != ModelId::PLUSTEK_OPTICPRO_A320E) { + dev->reg.init_reg(0x08, REG_0x08_CIS_LINE); + } + // The A320E is a CCD, not a CIS: the device's own initialisation table + // sets 0x08 = 0x20 (MPENB), not CIS_LINE = 0x10. dev->interface->write_register(0x08, dev->reg.find_reg(0x08).value); // setup gpio @@ -1329,7 +2832,23 @@ void CommandSetGl124::update_home_sensor_gpio(Genesys_Device& dev) const DBG_HELPER(dbg); std::uint8_t val = dev.interface->read_register(REG_0x32); - val &= ~REG_0x32_GPIO10; + + if (dev.model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + // A320E: bit 0x02 of REG_0x32 (upstream's GPIO10) is not an output but + // the home switch's input. Apple's plist says so outright: + // HomeSwitch = '0x32,0x02,0x02' (register, mask, home value). Windows + // always writes a value to 0x32 in which bit 0x02 is SET (0x0e; the + // 0x4e writes in the capture are echoes of the value read). + // + // Upstream's clearing breaks the home detection twice over: bit 0x02 + // reads 0 afterwards, and HOMESNR in status register 0x101 sticks at + // one -- whereupon scanner_move_back_home concludes "already at home" + // every time and the read head is never returned home. + val |= REG_0x32_GPIO10; + } else { + val &= ~REG_0x32_GPIO10; + } + dev.interface->write_register(REG_0x32, val); } diff --git a/backend/genesys/low.cpp b/backend/genesys/low.cpp index 3ffc24a1..3e092756 100644 --- a/backend/genesys/low.cpp +++ b/backend/genesys/low.cpp @@ -1643,6 +1643,257 @@ bool get_registers_gain4_bit(AsicType asic_type, const Genesys_Register_Set& reg /** * Wait for the scanning head to park */ +/* A320E: homing timeout. + * + * Upstream's defaults are dangerously long for this device: + * `sanei_genesys_wait_for_home` waits 200 s and does NOT stop the motor on + * timeout, and `scanner_move_back_home` waits 30 s. If a scan fails in such + * a way that HOMESNR never rises, the motor drives the carriage against the + * home end stop for that entire time. That happened in runs 66 and 72; both + * times the user had to cut the power. + * + * In a successful run, home is found on a SINGLE poll (runs 57 and 67-71), + * so 5 s is a very generous limit. On timeout the motor is stopped before + * the exception is thrown. + * + * A320E_HOME_TIMEOUT_MS= changes the limit without a rebuild. + */ +unsigned a320e_home_timeout_ms(const Genesys_Device& dev) +{ + if (dev.model->model_id != ModelId::PLUSTEK_OPTICPRO_A320E) { + return 0; + } + unsigned ms = 5000; + if (const char* env = std::getenv("A320E_HOME_TIMEOUT_MS")) { + ms = std::strtoul(env, nullptr, 0); + } + return ms; +} + +/* A320E: stopping the motor at home. + * + * Homing is an open loop. At EOF the driver commands about 1100 steps + * backwards (`move_back_home(dev, false)`, genesys.cpp) and nothing stops + * the motor when HOMESNR rises: upstream does that for GL846/GL847 only. + * The remaining steps are ground against the end stop -- in run 137 + * (2026-08-20) the user heard a bad noise from it for several seconds after + * the carriage had already returned. The same sequence occurs in every run + * (135, 136: steps=1096); at 800 dpi it is merely more audible, the return + * profile being slower. + * + * `scanner_stop_action` is idempotent: it returns at once if the motor has + * already stopped, so the call is safe even when the return did finish. + * Exceptions are swallowed because the call sites are on cleanup paths + * (sane_close). + */ +void a320e_stop_motor_at_home(Genesys_Device& dev) +{ + if (dev.model->model_id != ModelId::PLUSTEK_OPTICPRO_A320E) { + return; + } + catch_all_exceptions(__func__, [&]() { scanner_stop_action(dev); }); +} + +/* A320E: the lamp goes dark BEFORE the carriage returns, not after. + * + * MEASURED 2026-09-02 from the bus. The vendor's whole tail after the image + * burst is six register writes, identical in captures 07, 03 and 09: + * + * 0x03=0x10 0x03=0x00 0x01=0x22 0x02=0x30 0x03=0x10 0x03=0x00 + * + * REG_0x03 bit 0x10 is LAMPPWR, so the lamp is switched off 29 ms after the + * image has been read. The status register 0x101 confirms what follows: + * +0.028 s 0xd5 (LAMPSTS 1, HOMESNR 0, MOTORENB 1) -> +0.204 s 0xd1 + * (LAMPSTS 0, HOMESNR 0, MOTORENB 1). The carriage drives home with the lamp + * already dark. + * + * We did the opposite: in run 353 the status 1.734 s after the image still + * read 0xcc (LAMPSTS 1) with the carriage already home, and only the last + * write of the whole job cleared LAMPPWR. The lamp burned through the entire + * 1.74 s return. + * + * A single register write would not have held: every move session rewrites + * REG_0x03 through sanei_genesys_set_lamp_power (run 353, frames 9777 and + * 10341 write 0x1f, LAMPPWR back on). The lamp is therefore switched off the + * way the driver already knows how -- ScanFlag::DISABLE_LAMP on the sessions + * that make up the return -- so every write during the return carries + * LAMPPWR = 0. + * + * This does NOT touch REG_0x02: MTRPWR has to stay on, the carriage still + * has to drive home. save_power, which clears both, runs later at close. + * + * A320E_LAMP_LATE=1 restores the old behaviour for an A/B comparison. + * See notes/ENNAKKOANALYYSIT.md, chapter `0x03` LAMPPWR. + */ +bool a320e_lamp_off_on_return(const Genesys_Device& dev) +{ + if (dev.model->model_id != ModelId::PLUSTEK_OPTICPRO_A320E) { + return false; + } + // Only after the image has been read. A backward move on its own is not + // a return -- see device.h, a320e_image_read_done. + if (!dev.a320e_image_read_done) { + return false; + } + return std::getenv("A320E_LAMP_LATE") == nullptr; +} + +/* A320E: let the ASIC drive the carriage home by itself (REG_0x02 AGOHOME). + * + * MEASURED 2026-09-02: the vendor's REG_0x02 is 0x30 at the image scan on + * every resolution and in every mode -- AGOHOME | MTRPWR, with NOTHOME clear. + * AGOHOME is armed during the image scan, not after it, which is why the + * vendor's job ends in six register writes while ours needs 455 and three + * separate reverse moves. + * + * OFF BY DEFAULT, and deliberately so. scanner_move_back_home issues + * `scanner_move(head_pos - 500, BACKWARD)` BEFORE it reads the status, so if + * the ASIC has already brought the carriage home those steps are ground + * against the end stop -- the failure that a320e_stop_motor_at_home below + * already exists to prevent. Enabling this therefore also has to zero the + * driver's idea of the head position, which a320e_agohome_after_read does. + * + * A320E_AGOHOME=1 enables it. + * See notes/ENNAKKOANALYYSIT.md, chapter `0x02` AGOHOME. + */ +bool a320e_agohome_enabled(const Genesys_Device& dev) +{ + if (dev.model->model_id != ModelId::PLUSTEK_OPTICPRO_A320E) { + return false; + } + return std::getenv("A320E_AGOHOME") != nullptr; +} + +/* A320E: after the image has been read, reconcile the driver's head position + * with what the ASIC did on its own. + * + * Only meaningful with A320E_AGOHOME. The ASIC's own return takes about + * 1.7 s and produces no bus traffic, so the driver would otherwise walk into + * move_back_home believing the head is still deep in the bed. + */ +void a320e_agohome_after_read(Genesys_Device& dev) +{ + if (!a320e_agohome_enabled(dev)) { + return; + } + catch_all_exceptions(__func__, [&]() { sanei_genesys_wait_for_home(&dev); }); + + // The ASIC may not have finished, or even started, the return: + // wait_for_home's timeout is swallowed above. Zeroing head_pos then would + // tell the driver the carriage is home while it stands on the glass, and + // the normal return would be skipped. So claim home only when the sensor + // says so; otherwise leave the position alone and let + // scanner_move_back_home do its ordinary work. + // Added before run 374 (2026-09-02), which needed it: see gl124.cpp, + // REG_0x02 -- the carriage did not reach home and the driver had to not + // believe otherwise. + bool at_home = false; + catch_all_exceptions(__func__, [&]() { + at_home = scanner_read_reliable_status(dev).is_at_home; + }); + if (!at_home) { + DBG(DBG_error, "%s: A320E AGOHOME: carriage is NOT home, leaving head " + "position alone so the normal return still runs\n", __func__); + return; + } + + dev.set_head_pos_zero(ScanHeadId::PRIMARY); + DBG(DBG_info, "%s: A320E AGOHOME: head position reset to home\n", __func__); +} + +/* A320E: the x axis scale correction. + * + * MEASURED 2026-08-28 (runs K058-K064): the image is 0.75 % too WIDE in x, + * while y is right to 0.03 % (runs K053-K057). The cause is the sensor's own + * pitch: it is about 403 dpi and the driver models the grid as 400, so + * `mm * xres / 25.4` asks for 0.75 % too few pixels and the millimetres that + * do arrive are printed too wide. The factor is the same at 200 and 400 dpi + * and does not depend on the scanned length -- see ROADMAP.md, chapters + * "X-MITTAKAAVA ON 0,6-0,8 % PITKÄ" and "X-MITTAKAAVA RATKAISTU ILMAN UUTTA + * AJOA". + * + * The correction does NOT go into `sensor.optical_resolution`. That value is + * the ASIC's pixel grid (dpihw * DPISET / 1200), it is what the decimation, + * the shading data and the calibration widths are all counted on, and 403 + * would break their integer arithmetic (ROADMAP.md, "`optical_resolution` + * LUETTU LÄPI"). It goes into the two millimetre -> pixel conversions + * instead, where the device's own grid does not appear: the scan width in + * genesys.cpp (calculate_scan_settings) and the x origin in gl124.cpp + * (calculate_scan_session). + * + * The calibration path keeps its own widths: it measures the sensor itself, + * not a target lying on the glass. + * + * What remains after this is the POSITION dependence of the scale, +-0.3 % + * across the bed (ROADMAP.md, H4e). The largest error drops from ~1.1 % to + * ~0.35 %, not to zero. + * + * A320E_XSCALE= changes the value without a rebuild. It is here for + * the acceptance measurement (ruler across the bed, 200 and 400 dpi, + * requirement 1.000 +- 0.002) and is removed once that has been run. + * + * Returns 1 on every other model, where nothing changes. + */ +float a320e_x_scale(const Genesys_Device& dev) +{ + if (dev.model->model_id != ModelId::PLUSTEK_OPTICPRO_A320E) { + return 1.f; + } + float scale = 1.0075f; + if (const char* env = std::getenv("A320E_XSCALE")) { + // A zero (which is also what an unparseable value gives) would divide + // the requested width by zero in calculate_scan_settings. + float value = static_cast(std::strtod(env, nullptr)); + if (value > 0.f) { + scale = value; + DBG(DBG_info, "%s: A320E_XSCALE -> x scale = %.5f\n", __func__, scale); + } else { + DBG(DBG_warn, "%s: A320E_XSCALE = '%s' is not positive, ignored\n", __func__, env); + } + } + return scale; +} + +/* A320E: the width of the shading field. + * + * THE DEFECT THIS FIXES (measured 2026-08-29, runs K066-K073). The shading + * window starts at the sensor's pixel 0, but the image window starts at + * `x_offset` = 18.35 mm = pixel 289. `x_size_calib_mm` = 304.8 mm therefore + * yields coefficients for pixels 0..4800 while the image needs 289..5125, and + * the last 289 pixels of every full-width scan get NO coefficient. In + * `gl124_send_shading_data` the output buffer is zero-initialised and the + * copy is guarded, so those pixels are uploaded as a zero gain and the ASIC + * returns them black. Predicted edge (4800 - 289) px = 286.45 mm, measured + * 286.51 mm: the driver has been throwing away exactly one x_offset's worth + * of the bed at the right-hand end. + * + * THE VALUE. 330.2 mm is the vendor's own shading field: the macOS driver's + * `AreaShading = 0,0,10400,768,0` in 800 dpi units (docs/ScanApi_07b3_1826.ini, + * notes/scaninfo_1826.txt), i.e. 10400 px @800 = 5200 @400. It covers the + * whole bed plus the origin (18.35 + 304.8 = 323.15 mm) with a margin, and + * the Windows driver is seen writing ENDPIXEL 10220 @800 in capture 05, so + * the sensor is addressed that far by the vendor as well. + * + * It also sidesteps the float trap that `x_size_calib_mm` has: 304.8f is + * stored as 304.79998779 and `* 400 / 25.4` truncates 4800 to 4799 + * (a320e_align_calib_pixels, genesys.cpp). 330.2f gives 5200.0002, which + * truncates to 5200 -- an exact multiple of the hardware's pixel group at + * every resolution. + * + * WHAT THIS DELIBERATELY DOES NOT WIDEN: the coarse gain window + * (scanner_coarse_gain_calibration) stays at `x_size_calib_mm`. Gain is an + * average over the whole window, and the region beyond the bed is dark; a + * wider average would raise the gain and move the white level that was + * measured and locked in test A14 (2026-08-28). One number, one purpose. + */ +float a320e_shading_width_mm(const Genesys_Device& dev) +{ + if (dev.model->model_id != ModelId::PLUSTEK_OPTICPRO_A320E) { + return dev.model->x_size_calib_mm; + } + return 330.2f; +} + void sanei_genesys_wait_for_home(Genesys_Device* dev) { DBG_HELPER(dbg); @@ -1663,10 +1914,15 @@ void sanei_genesys_wait_for_home(Genesys_Device* dev) if (status.is_at_home) { DBG (DBG_info, "%s: already at home\n", __func__); + // A320E: the motor may still be running out the open loop's leftover steps. + a320e_stop_motor_at_home(*dev); return; } unsigned timeout_ms = 200000; + if (unsigned a320e_ms = a320e_home_timeout_ms(*dev)) { + timeout_ms = a320e_ms; + } unsigned elapsed_ms = 0; do { @@ -1680,8 +1936,15 @@ void sanei_genesys_wait_for_home(Genesys_Device* dev) if (elapsed_ms >= timeout_ms && !status.is_at_home) { DBG (DBG_error, "%s: failed to reach park position in %dseconds\n", __func__, timeout_ms / 1000); + // A320E: otherwise the motor keeps driving the carriage into the home + // end stop. Upstream does not stop it in this branch at all. + catch_all_exceptions(__func__, [&]() { scanner_stop_action(*dev); }); throw SaneException(SANE_STATUS_IO_ERROR, "failed to reach park position"); } + + // A320E: home found -- stop the motor before the leftover steps drive + // the carriage into the end stop. + a320e_stop_motor_at_home(*dev); } const MotorProfile* get_motor_profile_ptr(const std::vector& profiles, @@ -1844,6 +2107,28 @@ bool sanei_genesys_is_compatible_calibration(Genesys_Device* dev, compatible = false; } + /* A320E: the checks above compare the SCAN, not the calibration. The + * shading window is a separate width (a320e_shading_width_mm), and when + * it grows, a stored calibration made with the old narrow one still + * matches every test above -- so it would be restored, and the defect it + * was widened to fix (a black band at the right-hand edge, measured + * 2026-08-29) would come back silently on every machine that has a + * cache file. A driver upgrade must not depend on the user knowing to + * delete the .cal files in ~/.sane. + * + * A cached calibration that is WIDER than needed is still usable, so the + * test is one-sided. + */ + if (compatible && dev->model->model_id == ModelId::PLUSTEK_OPTICPRO_A320E) { + unsigned needed = static_cast(a320e_shading_width_mm(*dev) * + cache->session.params.xres / MM_PER_INCH); + if (cache->session.params.pixels < needed) { + dbg.vlog(DBG_io, "incompatible: shading window %d px vs. %d px needed\n", + cache->session.params.pixels, needed); + compatible = false; + } + } + if (!compatible) { DBG (DBG_proc, "%s: completed, non compatible cache\n", __func__); diff --git a/backend/genesys/low.h b/backend/genesys/low.h index 1b96cc56..b6f63aaf 100644 --- a/backend/genesys/low.h +++ b/backend/genesys/low.h @@ -299,8 +299,12 @@ void scanner_search_strip(Genesys_Device& dev, bool forward, bool black); bool should_calibrate_only_active_area(const Genesys_Device& dev, const Genesys_Settings& settings); +// `second_round` is for the Plustek OpticPro A320E: the AD9822's offset DAC +// sits before the PGA, so the offset has to be searched again once the coarse +// gain calibration has set the operating gain. Every other device calibrates +// the offset once and ignores the parameter. void scanner_offset_calibration(Genesys_Device& dev, const Genesys_Sensor& sensor, - Genesys_Register_Set& regs); + Genesys_Register_Set& regs, bool second_round = false); void scanner_coarse_gain_calibration(Genesys_Device& dev, const Genesys_Sensor& sensor, Genesys_Register_Set& regs, unsigned dpi); @@ -346,6 +350,42 @@ extern void sanei_genesys_asic_init(Genesys_Device* dev); void scanner_start_action(Genesys_Device& dev, bool start_motor); void scanner_stop_action(Genesys_Device& dev); + +/* A320E: homing watchdog. Returns the timeout in milliseconds, or 0 if the + * model is not the A320E, in which case upstream's own value stands. + * See HAVAINNOT.md, chapter "Istunto 10", section "Kotiinpaluun vahti". + */ +unsigned a320e_home_timeout_ms(const Genesys_Device& dev); + +/* A320E: stop the motor once the carriage is home. No-op on other models. + * See HAVAINNOT.md, run 137. + */ +void a320e_stop_motor_at_home(Genesys_Device& dev); + +/* A320E: switch the lamp off for the return, the way the vendor does. + * A320E_LAMP_LATE=1 restores the old behaviour. */ +bool a320e_lamp_off_on_return(const Genesys_Device& dev); + +/* A320E: let the ASIC drive the carriage home (REG_0x02 AGOHOME). + * Off unless A320E_AGOHOME is set. */ +bool a320e_agohome_enabled(const Genesys_Device& dev); +void a320e_agohome_after_read(Genesys_Device& dev); + +/* A320E: the x axis scale correction, 1.0075 (measured 2026-08-28, runs + * K058-K064). The sensor's pitch is ~403 dpi while the driver models the + * grid as 400; the millimetre -> pixel conversions multiply by this and the + * image pipeline scales the row back to the width the frontend asked for. + * Returns 1 on other models. See low.cpp for the full rationale. + */ +float a320e_x_scale(const Genesys_Device& dev); + +/* A320E: the width of the SHADING field in millimetres, measured from the + * sensor's own pixel 0. Wider than `x_size_calib_mm`, because the image + * window starts at `x_offset` while the calibration starts at pixel 0. + * Returns `dev.model->x_size_calib_mm` on other models, i.e. no change. + * See low.cpp for the measurement this comes from. + */ +float a320e_shading_width_mm(const Genesys_Device& dev); void scanner_stop_action_no_move(Genesys_Device& dev, Genesys_Register_Set& regs); bool scanner_is_motor_stopped(Genesys_Device& dev); diff --git a/backend/genesys/motor.cpp b/backend/genesys/motor.cpp index 1dfe64f2..ebb69221 100644 --- a/backend/genesys/motor.cpp +++ b/backend/genesys/motor.cpp @@ -30,6 +30,28 @@ namespace genesys { unsigned MotorSlope::get_table_step_shifted(unsigned step, StepType step_type) const { + // A320E: measured curve, see MotorSlope::measured_curve. + // + // There is no duplicate at index 1 here, and that is deliberate: the + // duplicate below is where our STEPNO used to gain the +2 that the bus + // comparison found (ROADMAP.md, "RAMPPITAULU LUETTU KOODISTA + // 2026-09-03"). With the curve in place STEPNO comes out as the vendor's + // own 192 / 87 / 47 / 6 / 4 without being written anywhere. + // + // Past the end of the curve the vendor is already at its plateau at every + // resolution, so 0 is the right answer: it is below any final_speed and + // lets create_slope_table_for_speed() break out and append the plateau + // entry itself. + if (measured_curve != nullptr) { + if (step == 0) { + return initial_speed_w >> static_cast(step_type); + } + if (step >= measured_curve_size) { + return 0; + } + return measured_curve[step] >> static_cast(step_type); + } + // first two steps are always equal to the initial speed if (step < 2) { return initial_speed_w >> static_cast(step_type); diff --git a/backend/genesys/motor.h b/backend/genesys/motor.h index 1981e80e..5a586601 100644 --- a/backend/genesys/motor.h +++ b/backend/genesys/motor.h @@ -94,6 +94,17 @@ struct MotorSlope // acceleration in steps per pixeltime squared. float acceleration = 0; + // A320E: the vendor's acceleration curve, measured instead of modelled. + // Entries are undivided pixeltimes; get_table_step_shifted() returns them + // in place of the uniform-acceleration formula whenever the pointer is + // set, so every other scanner keeps the formula untouched. Index 0 is + // never read -- the table starts from initial_speed_w, which the vendor + // varies with the step size (85560 at quarter step, 86020 otherwise). + // One curve serves every resolution: the plateau decides where it is cut. + // See ROADMAP.md, chapter "VALMISTAJAN RAMPPIKAYRA MITATTU 2026-09-03d". + const unsigned* measured_curve = nullptr; + unsigned measured_curve_size = 0; + unsigned get_table_step_shifted(unsigned step, StepType step_type) const; static MotorSlope create_from_steps(unsigned initial_w, unsigned max_w, diff --git a/backend/genesys/tables_frontend.cpp b/backend/genesys/tables_frontend.cpp index f91a5cd2..7ec31aa6 100644 --- a/backend/genesys/tables_frontend.cpp +++ b/backend/genesys/tables_frontend.cpp @@ -438,6 +438,40 @@ void genesys_init_frontend_tables() s_frontends->push_back(fe); + fe = Genesys_Frontend(); + fe.id = AdcId::PLUSTEK_OPTICPRO_A320E; + // Plustek OpticPro A320E: the analog front end is an Analog Devices + // AD9822 (docs/AD9822.pdf), identified from a USB capture + // (tools/afedump.py) against the datasheet; see notes/usb/HAVAINNOT.md, + // chapter "AFE-sirun tunnistus". The register map is the same as the + // AD9826's, i.e. `analog_devices`: 6-bit gains at 0x02..0x04, 9-bit + // sign-plus-magnitude offsets at 0x05..0x07. + // + // The AD9822 differs from the AD9826 only in scale (gain 1-5.7x rather + // than 1-6x, offset +-350 mV rather than +-300 mV), so the AD9826 formula + // in `compute_frontend_gain` is good enough -- calibration iterates the + // difference away. + // + // NOTE: 0x00 here is the Speed2 (100-400 dpi) value. The vendor driver + // changes it along with the speed block and uses 0xf8 at 600/800 dpi + // (ADC full scale 4 V, clamp bias 4 V). That value comes from the sensor + // table's custom_fe_regs field; see gl124_set_a320e_fe in gl124.cpp. + fe.layout = analog_devices; + fe.regs = { + { 0x00, 0x70 }, + { 0x01, 0x80 }, + { 0x02, 0x00 }, + { 0x03, 0x00 }, + { 0x04, 0x00 }, + { 0x05, 0x00 }, + { 0x06, 0x00 }, + { 0x07, 0x00 }, + { 0x26, 0x00 }, + }; + fe.reg2 = {0x00, 0x00, 0x00}; + s_frontends->push_back(fe); + + fe = Genesys_Frontend(); fe.id = AdcId::PLUSTEK_OPTICPRO_3600; fe.layout = wolfson_layout; diff --git a/backend/genesys/tables_gpo.cpp b/backend/genesys/tables_gpo.cpp index f129262e..d74d137f 100644 --- a/backend/genesys/tables_gpo.cpp +++ b/backend/genesys/tables_gpo.cpp @@ -295,6 +295,21 @@ void genesys_init_gpo_tables() s_gpo->push_back(gpo); + gpo = Genesys_Gpo(); + gpo.id = GpioId::PLUSTEK_OPTICPRO_A320E; + // GL124 takes the actual GPIO values from the `gpios[]` table in + // gl124.cpp (registers 0x31..0x38); this entry exists only because the + // model requires one. The captured 0x6c..0x6f values are what the + // device would hold: it never writes them at all. + gpo.regs = { + { 0x6c, 0x00 }, + { 0x6d, 0x00 }, + { 0x6e, 0x00 }, + { 0x6f, 0x00 }, + }; + s_gpo->push_back(gpo); + + gpo = Genesys_Gpo(); gpo.id = GpioId::PLUSTEK_OPTICPRO_3600; gpo.regs = { diff --git a/backend/genesys/tables_memory_layout.cpp b/backend/genesys/tables_memory_layout.cpp index 77c7ce72..15292e35 100644 --- a/backend/genesys/tables_memory_layout.cpp +++ b/backend/genesys/tables_memory_layout.cpp @@ -145,6 +145,31 @@ void genesys_init_memory_layout_tables() s_memory_layout->push_back(ml); + ml = MemoryLayout(); + ml.models = { ModelId::PLUSTEK_OPTICPRO_A320E }; + // Read straight off a USB capture of the Windows driver (05b). The + // shading RAM address registers 0xd0..0xd2 are ALL 0x0a: the channels + // share ONE pixel-interleaved area (see send_shading_data in gl124.cpp) + // rather than having one area each. The image buffers at 0xe0..0xf7, by + // contrast, form six separate blocks of 0x0a99 words each, covering + // 0x0064..0x3fff contiguously. + ml.regs = { + { 0xd0, 0x0a }, { 0xd1, 0x0a }, { 0xd2, 0x0a }, + { 0xe0, 0x00 }, { 0xe1, 0x64 }, { 0xe2, 0x0a }, { 0xe3, 0xfd }, + { 0xe4, 0x0a }, { 0xe5, 0xfe }, { 0xe6, 0x15 }, { 0xe7, 0x97 }, + { 0xe8, 0x15 }, { 0xe9, 0x98 }, { 0xea, 0x20 }, { 0xeb, 0x31 }, + { 0xec, 0x20 }, { 0xed, 0x32 }, { 0xee, 0x2a }, { 0xef, 0xcb }, + { 0xf0, 0x2a }, { 0xf1, 0xcc }, { 0xf2, 0x35 }, { 0xf3, 0x65 }, + { 0xf4, 0x35 }, { 0xf5, 0x66 }, { 0xf6, 0x3f }, { 0xf7, 0xff }, + // Windows writes 0xf8 = 0x05; gl124_init_registers leaves it at + // 0x01 ("other value is 0x05"). The only other device in this list + // carrying this line is the CanoScan 5600F -- the only other CCD + // device in the list. + { 0xf8, 0x05 }, + }; + s_memory_layout->push_back(ml); + + ml = MemoryLayout(); ml.models = { ModelId::CANON_LIDE_210, ModelId::CANON_LIDE_220 }; ml.regs = { diff --git a/backend/genesys/tables_model.cpp b/backend/genesys/tables_model.cpp index 321d1791..66060148 100644 --- a/backend/genesys/tables_model.cpp +++ b/backend/genesys/tables_model.cpp @@ -33,6 +33,8 @@ #include "low.h" +#include + namespace genesys { StaticInit> s_usb_devices; @@ -2258,6 +2260,172 @@ void genesys_init_usb_device_tables() s_usb_devices->emplace_back(0x04a7, 0x04ac, model); + model = Genesys_Model(); + model.name = "plustek-opticpro-a320e"; + model.vendor = "PLUSTEK"; + model.model = "OpticPro A320E"; + model.model_id = ModelId::PLUSTEK_OPTICPRO_A320E; + model.asic_type = AsicType::GL124; + + /* The native series differ between the axes -- the vendor's model, read + * off the captures and Apple's plist (notes/usb/HAVAINNOT.md, "Oikea + * malli"; notes/linux/README.md, "Valmistajavertailu 150 dpi:lle"). + * + * HORIZONTAL: the ASIC samples only at dpihw x DPISET / 1200, and dpihw + * is 400 (<= 400 dpi) or 800 (>= 600 dpi). The native values are thus + * 100 / 200 / 400 / 800. 300 and 600 dpi are read on the 400 and 800 + * hardware and downscaled in the image pipeline + * (ImagePipelineNodeScaleRows does a box average) -- exactly as the + * vendor driver does. + * + * VERTICAL: the motor is free, and the native values are + * 200 / 300 / 400 / 600 / 800. 100 dpi is not native there either: it is + * run at the 200 dpi step interval and the ASIC drops every other line + * (LINESEL, see gl124_init_motor_regs_scan). + * + * The frontend's menu is the UNION of these two lists + * (MethodResolutions::get_resolutions), i.e. + * 100 / 200 / 300 / 400 / 600 / 800. + * + * 150 dpi was DROPPED on 2026-08-21. It was the only mode non-native on + * both axes, and it could not be made to work: the ASIC produced no + * lines at all when line skipping was attempted on the 300 dpi hardware + * (runs 190, 198, 199). The vendor driver does not run it on the device + * either, but scales it from 200. Dropping it is harmless under SANE: + * sanei_constrain_value picks the nearest listed value and returns + * SANE_STATUS_INEXACT, so an application asking for 150 gets 200 dpi + * rather than an error. + */ + model.resolutions = { + { + { ScanMethod::FLATBED }, + { 800, 400, 200, 100 }, + { 800, 600, 400, 300, 200, 100 }, + } + }; + + model.bpp_gray_values = { 8 }; + model.bpp_color_values = { 8 }; + + // Horizontal zero point: STRPIXEL 289 @ 400 dpi = 18.35 mm. + // (The vendor's AreaOrigin x differs by source: win 532, apple 550 -- + // the captures are from Windows, so x follows them.) + // + // y_offset: MEASURED (runs 168-170, 2026-08-21). Two landmarks in one + // image, 123 mm apart: the trailing edge of a white A4 sheet (physically + // 297 mm) landed at 305.99 mm in the image, and the trailing edge of a + // second sheet at the A3 bottom mark (420 mm) at 428.86 mm. From the two + // equations: + // scale k = 0.99895 (y scale correct to -0.10 %) + // start S = -9.31 mm (the scan began BEFORE the origin corner) + // The home position is therefore 14.71 mm before the origin corner + // (5.40 + 9.31), and y_offset = home position to the FIRST DELIVERED LINE + // = 14.7 mm. Uncertainty +-1.7 mm, set by the ~1 mm width of the A3 mark + // through the lever arm. + // + // That 9.31 mm is the vendor's RUN-IN DISTANCE: the INI's + // `DiscardLine = 300` in 800 dpi units = 9.525 mm, and the vendor driver + // throws those lines away. Our carriage stood in the right place, then; + // it was the run-in that ended up in the image. + // + // The earlier 21.59 was off by a factor of two: it read AreaOrigin + // y = 255 in 300 dpi units (the second figure in MotorDPI), even though + // the INI's geometry is 800 dpi (proof: at 800, AreaPage gives exactly + // the glass size, 304.80 x 431.80 mm), and it was never verified as an + // origin -- the criterion of runs 55-56 (no dark leading lines) is + // one-sided. + /* MEASURED GEOMETRY OF THE SENSOR (2026-08-29, runs K058-K076). Keep + * these four numbers together with what was measured about the pixels + * they are converted into, because two of the three defects found in + * this area came from converting them with the wrong pitch: + * + * - the sensor's real pitch is ~403 dpi, not the 400 of the ASIC's + * pixel grid. The correction lives in the millimetre -> pixel + * conversions (a320e_x_scale, low.cpp) and NOT in + * `optical_resolution`, which has to stay 400; + * - the glass origin therefore sits at sensor pixel 291 (@400), not + * 289, and the sensor reaches at least pixel 5122 -- the vendor + * addresses 5110 (10220 @800) in capture 05 and declares 5200 + * (`AreaShading` 10400 @800); + * - all 304.8 mm of x_size scans clean, verified at 200, 400 and + * 800 dpi. It did not before 2026-08-29, but the limit was the + * driver's own shading window, not the device. + */ + model.x_offset = 18.35; + model.y_offset = 14.7; + model.x_size = 304.8; // 12000 mils, the bed width WIA reports + model.y_size = 431.8; // 17000 mils + /* The glass itself is larger than either. MEASURED on the unit + * (2026-08-29): the transparent area runs 311 mm from the origin in x + * and 436 mm in y, and in y it is followed by a ~10 mm black band before + * the frame. Both model values are therefore inside the glass with + * 6.2 mm and 4.2 mm to spare, and a full-size scan never leaves the + * transparent area. The limit on x is the sensor's array, not the bed. + */ + /* y_offset + y_size = 446.5 mm of travel from the home position. The + * vendor's own maximum is 449.1 mm (capture 05: LINCNT 5209 @300 dpi + * = 441.0 mm of imaging from an origin 8.1 mm out), so this is inside + * it -- but only by 2.6 mm, and y_offset itself is +-1.7 mm. There is no + * sensor at the far end; the guard is in gl124.cpp, calculate_scan_session. + */ + + model.y_offset_calib_white = 0.0; + model.y_size_calib_mm = 3.0; // UNFINISHED: not measured + model.x_offset_calib_black = 0.0; + model.x_size_calib_mm = 304.8; + + model.post_scan = 0.0; + model.eject_feed = 0.0; + + // The CCD's line spacing between channels, MEASURED 2026-08-20 + // (runs 139-142). + // + // Measured off the image (cross-correlation of the channels' line + // profiles, using a sharp horizontal line from the grid): uncorrected, R + // leads G by 6 lines and B trails it by 6 lines at 200 dpi. In run 136 + // the same figure was 3 lines, back when y sampling was still halved (the + // step size fault) -- so the shift follows physical distance, not a line + // count. + // + // 6 lines / 200 dpi = 24 base step units (base_ydpi = 800), which lands + // exactly on the vendor's own values: INI "LineSpace"="24" and scaninfo + // LINESPACE = 24. The increasing R-G-B order was confirmed in run 141: + // with 0/24/48 the residual went to zero on both channels. + model.ld_shift_r = 0; + model.ld_shift_g = 24; + model.ld_shift_b = 48; + + model.line_mode_color_order = ColorOrder::RGB; // UNFINISHED: unverified + + model.is_cis = false; // CCD, not CIS + model.is_sheetfed = false; + model.sensor_id = SensorId::CCD_PLUSTEK_OPTICPRO_A320E; + model.adc_id = AdcId::PLUSTEK_OPTICPRO_A320E; + model.gpio_id = GpioId::PLUSTEK_OPTICPRO_A320E; + model.motor_id = MotorId::PLUSTEK_OPTICPRO_A320E; + /* The WARMUP flag is absent ON PURPOSE (ruled out in runs 81-100, + * commit e9e9a20): + * 1. the vendor's definition says WarmUpTime=0, FastWarmUpTime=0, + * LampType=1 in both sources -- an LED needs no settling time, and + * the genesys warmup loop exists for CCFLs; + * 2. the flag also BREAKS the driver: gl124_init_regs_for_warmup dies + * in compute_session (runs 98-99, rc=4). Enabling it would take an + * A320E branch there, not just the flag. + * The intermittent breakage in grey mode (runs 73-96) was left without a + * root cause; it tracked the clock, not the width or the lamp's warmth. + */ + model.flags = ModelFlag::DARK_CALIBRATION | + ModelFlag::CUSTOM_GAMMA; + // The A320E_NO_SHADING diagnostic switch was removed (hardcoding round + // 2, 2026-08-20): the measurement has been made, and the switch caused + // session 9's spurious "width fault" by zeroing DVDSET (see + // HAVAINNOT.md, session 10). + model.buttons = 0; + model.search_lines = 400; + + s_usb_devices->emplace_back(0x07b3, 0x1826, model); + + model = Genesys_Model(); model.name = "plustek-opticbook-3600"; model.vendor = "PLUSTEK"; diff --git a/backend/genesys/tables_motor.cpp b/backend/genesys/tables_motor.cpp index 3cc576ff..1e3b2b57 100644 --- a/backend/genesys/tables_motor.cpp +++ b/backend/genesys/tables_motor.cpp @@ -476,6 +476,187 @@ void genesys_init_motor_tables() s_motors->push_back(std::move(motor)); + motor = Genesys_Motor(); + motor.id = MotorId::PLUSTEK_OPTICPRO_A320E; + // Every value here was extracted from the device's own acceleration + // tables, which the Windows driver uploads to AHB addresses + // 0x10000000 + 0x4000 * table (tools/slopedump.py). + // + // STEP SIZE, corrected on 2026-08-20 (runs 136-139). The earlier reading + // was base_ydpi = 1600 + full step; it fitted the tables but was off by + // 2x: 7 mm squares came out 56 x 27.3 px in the image, i.e. rectangles of + // 2.05:1 (the user's observation from run 136). The vendor driver writes + // REG_0xA0 at scan time (regseq.py --at-scan): + // 150/200/400/800 dpi: 0x09 -> STEPSEL 1 = HALF STEP + // 600 dpi: 0x0a -> STEPSEL 2 = quarter + // ...while our driver wrote 0x00 = full step. LPERIOD and the + // acceleration tables already matched bit for bit, so the step size was + // the only difference: every pulse moved the carriage twice as far as it + // should have. + // + // The base stepping is therefore 800 (vendor INI: "MotorDPI"="800,300"), + // and the table plateau follows from w = LPERIOD * ydpi / base_ydpi >> step: + // 200 dpi: 6256 * 200 / 800 = 1564 >> 1 = 782 (measured 782) + // 400 dpi: 6000 * 400 / 800 = 3000 >> 1 = 1500 (measured 1500) + // 600 dpi: 12000 * 600 / 800 = 9000 >> 2 = 2250 (measured 2250) + // 800 dpi: 12000 * 800 / 800 = 12000 >> 1 = 6000 (measured 6000) + // + // When base_ydpi is halved and the slope values doubled, the table + // UPLOADED to the device stays byte for byte the same + // (get_table_step_shifted scales the whole curve), and every formula + // using the PRODUCT of the two is unchanged (sanei_genesys_exposure_time2 + // among them). Only REG_0xA0 changes (now 0x09 / 0x0a, as in the vendor + // driver) and the distances, which halve -- that is, become correct. + motor.base_ydpi = 800; + + // STEPNO AND FASTNO ARE NOT SET DIRECTLY. gl124_init_motor_regs_scan + // writes scan_table.table.size() into both, so the third argument of + // create_from_steps below is what decides them. The table always comes + // out `steps` + 2 entries long: + // +1 get_table_step_shifted returns the initial speed for BOTH index + // 0 and index 1 and evaluates the curve at s = k - 1, so the curve + // is shifted one index right and the table starts with a duplicate. + // +1 create_slope_table_for_speed appends the plateau value after the + // loop breaks (push_back(final_speed)). The vendor counts its own + // first plateau entry in STEPNO as well, so this one is right. + // Measured on the bus (tools/ramppitaulu.py, 2026-09-03): the vendor's + // STEPNO is 192, 192, 192, 87, 47, 6, 4 for 100..800 dpi, and four of + // the five profiles below pass exactly that number as `steps`. Ours is + // therefore +2 at every resolution except 300 dpi, whose 85 was fitted + // to the bus and lands on 87 exactly. + // + // DO NOT "FIX" THIS BY SUBTRACTING 2 EITHER. `steps` is the denominator + // of the acceleration, so lowering it steepens the curve -- and the shape + // is already the difference that matters. The vendor's ramp is ONE table + // clamped at each resolution's plateau (element for element identical at + // 100/200/300/400/800 dpi, only the last entry differs) and it follows + // W(k) = 17043 * k^-0.455 to within 0.08 %, not the constant + // acceleration w = 1/sqrt(v0^2 + 2*a*s) that this generator can express. + // Ours is slower throughout: the ramp takes 118 992 pixeltimes against + // the vendor's 63 758 at 800 dpi and 369 998 against 306 195 at 200 dpi, + // and a third of that is the duplicated first entry alone. Matching + // STEPNO by itself would only move where the wrong curve is cut. + // See ROADMAP.md, chapter "RAMPPITAULU LUETTU KOODISTA 2026-09-03". + // + // THE CURVE IS NOW MEASURED, NOT MODELLED (2026-09-03d). The vendor's + // ramp is in the captures as exact numbers, so it is used as it is: one + // 191-entry table, cut at each resolution's plateau, reproduces the + // vendor's SCAN table entry for entry at 100/200/300/400/800 dpi and to + // within 0.12 percent in one entry at 600 dpi (quarter step). `steps` + // below is then dead -- the curve replaces the formula it feeds -- but + // the calls are left as they are so that A320E_SLOPE_CURVE=0 falls back + // to exactly the old behaviour for the A/B. The plateaus were already + // right at all six resolutions, and STEPNO now comes out as the vendor's. + // See ROADMAP.md, chapter "VALMISTAJAN RAMPPIKAYRA MITATTU 2026-09-03d". + + // Generated by tools/ramppitaulu.py --master --koodi from + // notes/usb/03-scan-200dpi-a4-kansi.pcapng. Do not edit by hand. + static const unsigned s_a320e_slope_curve[] = { + 86020, 17056, 12440, 10334, 9070, 8196, 7546, 7036, + 6616, 6274, 5978, 5726, 5504, 5308, 5130, 4972, + 4830, 4696, 4576, 4466, 4364, 4268, 4176, 4094, + 4016, 3942, 3872, 3806, 3744, 3684, 3628, 3574, + 3524, 3474, 3426, 3382, 3340, 3298, 3258, 3220, + 3182, 3148, 3114, 3080, 3048, 3016, 2988, 2958, + 2930, 2902, 2876, 2850, 2824, 2802, 2776, 2754, + 2732, 2710, 2688, 2668, 2648, 2628, 2608, 2588, + 2570, 2552, 2534, 2518, 2500, 2484, 2468, 2452, + 2436, 2422, 2406, 2392, 2378, 2364, 2350, 2336, + 2322, 2310, 2296, 2284, 2272, 2260, 2248, 2236, + 2224, 2212, 2202, 2190, 2180, 2168, 2158, 2148, + 2138, 2128, 2118, 2108, 2098, 2088, 2080, 2070, + 2062, 2052, 2044, 2034, 2026, 2018, 2010, 2002, + 1994, 1984, 1976, 1968, 1962, 1954, 1946, 1938, + 1932, 1924, 1918, 1910, 1902, 1896, 1888, 1882, + 1876, 1868, 1862, 1856, 1850, 1844, 1836, 1830, + 1824, 1818, 1812, 1806, 1800, 1794, 1790, 1784, + 1778, 1772, 1766, 1762, 1756, 1750, 1744, 1740, + 1734, 1730, 1724, 1720, 1714, 1710, 1704, 1700, + 1694, 1690, 1684, 1680, 1676, 1670, 1666, 1662, + 1658, 1652, 1648, 1644, 1640, 1636, 1630, 1626, + 1622, 1618, 1614, 1610, 1606, 1602, 1598, 1594, + 1590, 1586, 1582, 1578, 1574, 1570, 1568, + }; + + // A/B without recompiling: A320E_SLOPE_CURVE=0 restores the modelled + // curve for all four tables. STOP and FAST were held back on the old + // curve until runs 430-435 came out clean and were moved over in runs + // 436-441 (failure modes 1 and 3 of the pre-analysis); + // A320E_SLOPE_CURVE_FAST=0 puts just those two back. + bool a320e_use_curve = true; + if (const char* env = std::getenv("A320E_SLOPE_CURVE")) { + a320e_use_curve = std::strtoul(env, nullptr, 0) != 0; + } + + auto a320e_slope = [&](unsigned initial_w, unsigned max_w, unsigned steps) + { + auto slope = MotorSlope::create_from_steps(initial_w, max_w, steps); + if (a320e_use_curve) { + slope.measured_curve = s_a320e_slope_curve; + slope.measured_curve_size = + sizeof(s_a320e_slope_curve) / sizeof(s_a320e_slope_curve[0]); + } + return slope; + }; + + // The 782 plateau (= 1564 >> 1) is the motor's top speed: the vendor + // driver never runs faster than this. 100 and 150 dpi would need 391 and + // 521, so they can NOT be run natively here -- clamping gives them the + // 200 dpi step interval (measured: 100 dpi stretches by exactly 2.023x). + // The right answer is to run them on 200/300 dpi hardware and drop every + // other line (LINESEL); see gl124_init_motor_regs_scan, A320E_LINESEL. + // The profile still covers yres 100/150 because the move and homing + // sessions use yres 100. + profile = MotorProfile{a320e_slope(86020, 1564, 192), + StepType::HALF, 6256}; + profile.resolutions = { 100, 150, 200 }; + motor.profiles.push_back(profile); + + // 300 dpi: A TABLE OF ITS OWN (2026-08-21b). A shared {300,400} profile + // gave a plateau of 1500, i.e. the 400 dpi step interval, because + // create_slope_table_for_speed clamps the target to the profile's own + // top speed (final_speed = max(target, max_speed)). 300 dpi therefore + // stretched by exactly 1.333x. At 300 dpi the vendor driver uploads a + // table of its own, 43010..1125 / STEPNO 87 (tools/slopedump.py + // notes/usb/04); 2250 >> 1 = 1125 = LPERIOD 6000 * 300 / 800 >> 1. + // + // max_exposure is 6256 rather than 6000 so that the same profile also + // serves when 150 dpi is run on this hardware with LINESEL (yres is then + // 300, but LPERIOD comes from the 150 dpi sensor row = 6256, and the + // target 1173 > 1125, so no clamping happens). + profile = MotorProfile{a320e_slope(86020, 2250, 85), + StepType::HALF, 6256}; + profile.resolutions = { 300 }; + motor.profiles.push_back(profile); + + profile = MotorProfile{a320e_slope(86020, 3000, 47), + StepType::HALF, 6000}; + profile.resolutions = { 400 }; + motor.profiles.push_back(profile); + + // 600 dpi: the vendor driver uses FSTPSEL 1 (0x0a) even though STEPSEL + // is 2; our code writes the same value into both fields -> 0x12. The + // difference affects fast feed only, and 600 dpi is interpolated in + // software besides, so it is waiting for a run of its own. + profile = MotorProfile{a320e_slope(85560, 9000, 6), + StepType::QUARTER, 12000}; + profile.resolutions = { 600 }; + motor.profiles.push_back(profile); + + profile = MotorProfile{a320e_slope(86020, 12000, 4), + StepType::HALF, 12000}; + profile.resolutions = { 800 }; + motor.profiles.push_back(profile); + + // Fast feed / homing: the device loads the FAST and HOME tables with a + // start of 55555 and a plateau of 625 (41666 -> 666 during init). + profile = MotorProfile{MotorSlope::create_from_steps(55555, 625, 511), + StepType::FULL, 0}; + motor.fast_profiles.push_back(profile); + + s_motors->push_back(std::move(motor)); + + motor = Genesys_Motor(); motor.id = MotorId::PLUSTEK_OPTICPRO_3600; motor.base_ydpi = 1200; diff --git a/backend/genesys/tables_sensor.cpp b/backend/genesys/tables_sensor.cpp index 09ca861f..49a56bad 100644 --- a/backend/genesys/tables_sensor.cpp +++ b/backend/genesys/tables_sensor.cpp @@ -20,6 +20,7 @@ #define DEBUG_DECLARE_ONLY +#include #include "low.h" #include @@ -3161,6 +3162,309 @@ void genesys_init_sensor_tables() } } + sensor = Genesys_Sensor(); + sensor.sensor_id = SensorId::CCD_PLUSTEK_OPTICPRO_A320E; // gl124 + /* DO NOT "FIX" THE RESOLUTIONS BELOW TO MATCH THE MEASURED PITCH. + * + * The sensor's real pitch is about 403 dpi (measured 2026-08-28/29: the + * image came out 0.75 % too wide in x while y was right to 0.03 %), but + * `optical_resolution` here is the ASIC's pixel grid -- dpihw x DPISET / + * 1200 -- and the decimation, the shading data and the calibration + * widths are all counted on it being 400 or 800 exactly. 403 breaks + * their integer arithmetic. The scale correction belongs in the + * millimetre -> pixel conversions instead; see a320e_x_scale in low.cpp + * and ROADMAP.md, chapter "`optical_resolution` LUETTU LÄPI". + */ + // Plustek OpticPro A320E. Every value here was read off USB captures of + // the Windows driver (tools/sensorregs.py); see notes/usb/HAVAINNOT.md, + // chapter "Sensoritaulut purettu". The sensor is NOT segmented + // (SEGCNT = TG0CNT = 0), so segment_order stays empty. + sensor.full_resolution = 800; + /* black_pixels = 87 is the common genesys default (nearly every sensor + * in this table uses it), and it has been MEASURED TO BE ADEQUATE rather + * than guessed -- see the measurement below. + * + * The vendor equivalent is OpticalBlack, which is per speed block and is + * a PAIR (start, end) in the units of that block's own optical + * resolution. Two independent sources point at the same physical spot on + * the sensor: + * the macOS driver's definition (docs/ScanApi_07b3_1826.ini, and the + * same thing as a plist in notes/scaninfo_1826.txt): + * Speed2 (100-400 dpi, optical 400) "8,20" + * Speed1 (600/800 dpi, optical 800) "16,40" = 2 x Speed2 + * the Windows driver's bus traffic (notes/usb, 4 captures at different + * resolutions, tools/winscan.py): ALL calibration phases give + * STRPIX = 23 @ DPISET 1200 = 7.67 @ 400 + * and that holds regardless of the resolution requested -- the vendor + * driver always calibrates at full sensor resolution from a fixed spot. + * The device-level "100,1100" does not scale like the speed-block pairs + * and matches no recognised resolution; its meaning is still open. + * + * MEASURED 2026-08-22 (runs 232/233, A/B on the same binary, + * scanner_offset_calibration): + * start_pixel 0, black_pixels 43 @400 -> bottom 0/0/0, offset (11,11,11) + * start_pixel 8, black_pixels 12 @400 -> bottom 1/1/1, offset (11,11,11) + * The same result. The whole 0..42 range (@400) is optically black and + * the vendor's window is a subset of it, so a wider average leaks no + * light and merely suppresses noise better. We therefore use 87 + * deliberately. See HAVAINNOT.md, item 8. The development switches + * A320E_BLACK_PIXELS (2026-08-21) and A320E_OPTBLACK (2026-08-22) have + * been removed. + */ + sensor.black_pixels = 87; + /* On the gl124, dummy_pixel goes into REG_DUMMY = 0x80/0x81 + * (`gl124.cpp`, set16(REG_DUMMY, sensor.dummy_pixel)) -- NOT into + * EXPDMY. The value 42 had been read from the wrong register: 42 is + * EXPDMY (0x88/0x89), which this table already sets separately in + * custom_regs ({ 0x88, 0x00 }, { 0x89, 0x2a }). + * + * The vendor's REG_DUMMY is 23 in ALL seven captures (100/150/200/300/ + * 400 dpi, colour/grey/lineart -- tools/winscan.py). Our 42 showed up in + * run 57 as an RGB phase slip within the line, with a period of exactly + * 42 px: vertical magenta/cyan/yellow stripes, the missing channel + * cycling R->G->B every 42 pixels. See HAVAINNOT.md, chapter + * "Istunto 10". + * + * RULED OUT as the hypothesis (run 67): 42 -> 23 did not move the + * stripes (the root cause was the shading RAM's paging, see + * send_shading_data in gl124.cpp). The value 23 stays because it matches + * the vendor driver in 7 captures out of 7. The A320E_DUMMY switch was + * removed (hardcoding round 2, 2026-08-20). + */ + sensor.dummy_pixel = 23; // REG_DUMMY (0x80/0x81) = 23 + sensor.fau_gain_white_ref = 210; + sensor.gain_white_ref = 200; + sensor.exposure = { 2590, 2590, 2590 }; // EXPR/EXPG/EXPB, constant in every run + sensor.gamma = { 2.2f, 2.2f, 2.2f }; + + /* EXPERIMENT (agent round 2026-08-20): shading correction ON THE HOST. + * + * Hypothesis for the 42 px phase slip: the ASIC's shading RAM is paged + * 252 words + a 4-word gap (gl843.cpp:1741-1776), interleaving 12 bytes + * per pixel -> 504/12 = 42 px. Our own send_shading_data branch uploads + * the buffer WITHOUT the gap, so the ASIC reads pixel p's coefficients + * from address 6p + 4*floor(p/42) while we wrote them to 6p. + * + * This switch bypassed shading in the ASIC entirely + * (genesys_send_shading_coefficient returns immediately) and did it on + * the host instead (ImagePipelineNodeCalibrate). If the stripes went + * away, the cause was confirmed. Both of the other CCDs with this same + * 0xd0=0xd1=0xd2 layout (OpticFilm 7400, CanoScan 5600F) are solved in + * exactly this way. + * + * NOTE: it needs the DVDSET condition in gl124.cpp as its pair -- + * otherwise the ASIC still divides by an unloaded shading RAM. + * + * THE EXPERIMENT WAS NOT NEEDED: the shading RAM's paging (42 px) solved + * the stripes in the ASIC, see send_shading_data. The A320E_HOST_CALIB + * switch was removed on 2026-08-21; host-side calibration stays at + * upstream's default. + */ + + { + struct CustomSensorSettings { + ValueFilterAny resolutions; + unsigned optical_resolution; + unsigned register_dpihw; + unsigned register_dpiset; + unsigned shading_resolution; + int exposure_lperiod; + SensorExposure exposure; + Ratio pixel_count_ratio; + unsigned shading_factor; + std::vector segment_order; + GenesysRegisterSettingSet custom_regs; + /* Per-speed-block AFE registers. The vendor driver changes the + * AD9822's configuration register 0x00 along with the speed + * block: Speed2 (100-400 dpi) 0x70, Speed1 (600/800 dpi) 0xf8. + * See gl124_set_a320e_fe in gl124.cpp and + * docs/ScanApi_07b3_1826.ini. Empty = the base value from + * tables_frontend.cpp is used as such. */ + GenesysRegisterSettingSet custom_fe_regs; + }; + + CustomSensorSettings custom_settings[] = { + { { 100 }, 400, 1200, 300, 400, 6256, + { 2590, 2590, 2590 }, Ratio{1, 1}, 4, + std::vector{}, { + { 0x16, 0x23 }, { 0x17, 0x00 }, { 0x18, 0x00 }, { 0x19, 0x00 }, + { 0x1a, 0x10 }, { 0x1b, 0x00 }, { 0x1c, 0x00 }, { 0x1d, 0x80 }, { 0x20, 0x11 }, + { 0x52, 0x08 }, { 0x53, 0x0a }, { 0x54, 0x00 }, { 0x55, 0x02 }, + { 0x56, 0x04 }, { 0x57, 0x06 }, + { 0x5a, 0x29 }, { 0x5b, 0x51 }, { 0x5c, 0x40 }, + { 0x70, 0x07 }, { 0x71, 0x08 }, { 0x72, 0x08 }, { 0x73, 0x09 }, + { 0x74, 0x00 }, { 0x75, 0x0f }, { 0x76, 0xc0 }, + { 0x77, 0x00 }, { 0x78, 0x00 }, { 0x79, 0x00 }, + { 0x7a, 0x00 }, { 0x7b, 0x00 }, { 0x7c, 0x00 }, + { 0x88, 0x00 }, { 0x89, 0x2a }, + { 0x93, 0x00 }, { 0x94, 0x00 }, { 0x95, 0x00 }, + { 0x96, 0x00 }, { 0x97, 0x00 }, + { 0x98, 0x00 }, + }, + }, + { { 150 }, 400, 1200, 600, 400, 6256, + { 2590, 2590, 2590 }, Ratio{1, 1}, 2, + std::vector{}, { + { 0x16, 0x23 }, { 0x17, 0x00 }, { 0x18, 0x00 }, { 0x19, 0x00 }, + { 0x1a, 0x10 }, { 0x1b, 0x00 }, { 0x1c, 0x00 }, { 0x1d, 0x80 }, { 0x20, 0x11 }, + { 0x52, 0x08 }, { 0x53, 0x0a }, { 0x54, 0x00 }, { 0x55, 0x02 }, + { 0x56, 0x04 }, { 0x57, 0x06 }, + { 0x5a, 0x29 }, { 0x5b, 0x51 }, { 0x5c, 0x40 }, + { 0x70, 0x07 }, { 0x71, 0x08 }, { 0x72, 0x08 }, { 0x73, 0x09 }, + { 0x74, 0x00 }, { 0x75, 0x0f }, { 0x76, 0xc0 }, + { 0x77, 0x00 }, { 0x78, 0x00 }, { 0x79, 0x00 }, + { 0x7a, 0x00 }, { 0x7b, 0x00 }, { 0x7c, 0x00 }, + { 0x88, 0x00 }, { 0x89, 0x2a }, + { 0x93, 0x00 }, { 0x94, 0x00 }, { 0x95, 0x00 }, + { 0x96, 0x00 }, { 0x97, 0x00 }, + { 0x98, 0x00 }, + }, + }, + { { 200 }, 400, 1200, 600, 400, 6256, + { 2590, 2590, 2590 }, Ratio{1, 1}, 2, + std::vector{}, { + { 0x16, 0x23 }, { 0x17, 0x00 }, { 0x18, 0x00 }, { 0x19, 0x00 }, + { 0x1a, 0x10 }, { 0x1b, 0x00 }, { 0x1c, 0x00 }, { 0x1d, 0x80 }, { 0x20, 0x11 }, + { 0x52, 0x08 }, { 0x53, 0x0a }, { 0x54, 0x00 }, { 0x55, 0x02 }, + { 0x56, 0x04 }, { 0x57, 0x06 }, + { 0x5a, 0x29 }, { 0x5b, 0x51 }, { 0x5c, 0x40 }, + { 0x70, 0x07 }, { 0x71, 0x08 }, { 0x72, 0x08 }, { 0x73, 0x09 }, + { 0x74, 0x00 }, { 0x75, 0x0f }, { 0x76, 0xc0 }, + { 0x77, 0x00 }, { 0x78, 0x00 }, { 0x79, 0x00 }, + { 0x7a, 0x00 }, { 0x7b, 0x00 }, { 0x7c, 0x00 }, + { 0x88, 0x00 }, { 0x89, 0x2a }, + { 0x93, 0x00 }, { 0x94, 0x00 }, { 0x95, 0x00 }, + { 0x96, 0x00 }, { 0x97, 0x00 }, + { 0x98, 0x00 }, + }, + }, + { { 300 }, 400, 1200, 1200, 400, 6000, + { 2590, 2590, 2590 }, Ratio{1, 1}, 1, + std::vector{}, { + { 0x16, 0x23 }, { 0x17, 0x00 }, { 0x18, 0x00 }, { 0x19, 0x00 }, + { 0x1a, 0x10 }, { 0x1b, 0x00 }, { 0x1c, 0x00 }, { 0x1d, 0x80 }, { 0x20, 0x11 }, + { 0x52, 0x08 }, { 0x53, 0x0a }, { 0x54, 0x00 }, { 0x55, 0x02 }, + { 0x56, 0x04 }, { 0x57, 0x06 }, + { 0x5a, 0x29 }, { 0x5b, 0x51 }, { 0x5c, 0x40 }, + { 0x70, 0x07 }, { 0x71, 0x08 }, { 0x72, 0x08 }, { 0x73, 0x09 }, + { 0x74, 0x00 }, { 0x75, 0x0f }, { 0x76, 0xc0 }, + { 0x77, 0x00 }, { 0x78, 0x00 }, { 0x79, 0x00 }, + { 0x7a, 0x00 }, { 0x7b, 0x00 }, { 0x7c, 0x00 }, + { 0x88, 0x00 }, { 0x89, 0x2a }, + { 0x93, 0x00 }, { 0x94, 0x00 }, { 0x95, 0x00 }, + { 0x96, 0x00 }, { 0x97, 0x00 }, + { 0x98, 0x00 }, + }, + }, + { { 400 }, 400, 1200, 1200, 400, 6000, + { 2590, 2590, 2590 }, Ratio{1, 1}, 1, + std::vector{}, { + { 0x16, 0x23 }, { 0x17, 0x00 }, { 0x18, 0x00 }, { 0x19, 0x00 }, + { 0x1a, 0x10 }, { 0x1b, 0x00 }, { 0x1c, 0x00 }, { 0x1d, 0x80 }, { 0x20, 0x11 }, + { 0x52, 0x08 }, { 0x53, 0x0a }, { 0x54, 0x00 }, { 0x55, 0x02 }, + { 0x56, 0x04 }, { 0x57, 0x06 }, + { 0x5a, 0x29 }, { 0x5b, 0x51 }, { 0x5c, 0x40 }, + { 0x70, 0x07 }, { 0x71, 0x08 }, { 0x72, 0x08 }, { 0x73, 0x09 }, + { 0x74, 0x00 }, { 0x75, 0x0f }, { 0x76, 0xc0 }, + { 0x77, 0x00 }, { 0x78, 0x00 }, { 0x79, 0x00 }, + { 0x7a, 0x00 }, { 0x7b, 0x00 }, { 0x7c, 0x00 }, + { 0x88, 0x00 }, { 0x89, 0x2a }, + { 0x93, 0x00 }, { 0x94, 0x00 }, { 0x95, 0x00 }, + { 0x96, 0x00 }, { 0x97, 0x00 }, + { 0x98, 0x00 }, + }, + }, + { { 600 }, 800, 1200, 1200, 800, 12000, + { 2590, 2590, 2590 }, Ratio{1, 1}, 1, + std::vector{}, { + { 0x16, 0x23 }, { 0x17, 0x00 }, { 0x18, 0x10 }, { 0x19, 0x00 }, + { 0x1a, 0x00 }, { 0x1b, 0x00 }, { 0x1c, 0x00 }, { 0x1d, 0x80 }, { 0x20, 0x11 }, + { 0x52, 0x08 }, { 0x53, 0x0a }, { 0x54, 0x00 }, { 0x55, 0x02 }, + { 0x56, 0x04 }, { 0x57, 0x06 }, + { 0x5a, 0x2a }, { 0x5b, 0x5a }, { 0x5c, 0x40 }, + { 0x70, 0x06 }, { 0x71, 0x07 }, { 0x72, 0x07 }, { 0x73, 0x08 }, + { 0x74, 0x00 }, { 0x75, 0x00 }, { 0x76, 0x00 }, + { 0x77, 0x00 }, { 0x78, 0x00 }, { 0x79, 0x00 }, + { 0x7a, 0x00 }, { 0x7b, 0x00 }, { 0x7c, 0x00 }, + { 0x88, 0x00 }, { 0x89, 0x2a }, + { 0x93, 0x00 }, { 0x94, 0x00 }, { 0x95, 0x00 }, + { 0x96, 0x00 }, { 0x97, 0x00 }, + { 0x98, 0x00 }, + }, + /* Speed1: ADC 4 V + clamp bias 4 V (captures 07/08). */ + { { 0x00, 0xf8 } }, + }, + { { 800 }, 800, 1200, 1200, 800, 12000, + { 2590, 2590, 2590 }, Ratio{1, 1}, 1, + std::vector{}, { + { 0x16, 0x23 }, { 0x17, 0x00 }, { 0x18, 0x10 }, { 0x19, 0x00 }, + { 0x1a, 0x00 }, { 0x1b, 0x00 }, { 0x1c, 0x00 }, { 0x1d, 0x80 }, { 0x20, 0x11 }, + { 0x52, 0x08 }, { 0x53, 0x0a }, { 0x54, 0x00 }, { 0x55, 0x02 }, + { 0x56, 0x04 }, { 0x57, 0x06 }, + { 0x5a, 0x2a }, { 0x5b, 0x5a }, { 0x5c, 0x40 }, + { 0x70, 0x06 }, { 0x71, 0x07 }, { 0x72, 0x07 }, { 0x73, 0x08 }, + { 0x74, 0x00 }, { 0x75, 0x00 }, { 0x76, 0x00 }, + { 0x77, 0x00 }, { 0x78, 0x00 }, { 0x79, 0x00 }, + { 0x7a, 0x00 }, { 0x7b, 0x00 }, { 0x7c, 0x00 }, + { 0x88, 0x00 }, { 0x89, 0x2a }, + { 0x93, 0x00 }, { 0x94, 0x00 }, { 0x95, 0x00 }, + { 0x96, 0x00 }, { 0x97, 0x00 }, + { 0x98, 0x00 }, + }, + /* Speed1: ADC 4 V + clamp bias 4 V (captures 07/08). */ + { { 0x00, 0xf8 } }, + }, + }; + + /* INTEGRALITY OF THE X DECIMATION. + * + * The line clock runs exactly 4/3 too fast when the decimation + * factor d = optical_resolution / xres is not an integer. That -- + * not the motor -- was half of the stretch at 150/300/600 dpi. + * Confirmed on the device by control run 196: A320E_OPTICAL_FIX=0 + * left 300 dpi with a factor of exactly 4/3 (measured 1.340). + * + * SOLVED STRUCTURALLY on 2026-08-21, not by tuning this table: xres + * can now only be 100/200/400/800, because resolutions_x in + * tables_model.cpp lists the native horizontal modes only. d is then + * always 4, 2, 1 or 1. 300 and 600 dpi arise from running the + * VERTICAL axis natively (resolutions_y) and downscaling the + * HORIZONTAL one in the image pipeline from 400 and 800 -- exactly + * as the vendor driver does. + * + * The 150/300/600 dpi rows below therefore go unused in image + * scanning; they have been restored to 400/400/800 so that they are + * correct should some path still look them up. Removing the rows is + * cleanup, not a fix -- first check that sanei_genesys_find_sensor + * is not called with them. + * + * THE EARLIER ATTEMPT and why it was abandoned: optical_resolution + * 150->300, 300->300, 600->600 fixed the line clock but broke the + * horizontal window. register_dpihw stayed at 400/800, so + * STRPIXEL/ENDPIXEL were written in dpihw units while the width was + * computed in optical_resolution units: the window came out 3/4 of + * what was asked (300 dpi 236 where it should be 312, 600 dpi 472 + * where it should be 630), i.e. the image would have been stretched + * 4/3 horizontally. + */ + for (const CustomSensorSettings& setting : custom_settings) { + sensor.resolutions = setting.resolutions; + sensor.optical_resolution = setting.optical_resolution; + sensor.register_dpihw = setting.register_dpihw; + sensor.register_dpiset = setting.register_dpiset; + sensor.shading_resolution = setting.shading_resolution; + sensor.exposure_lperiod = setting.exposure_lperiod; + sensor.exposure = setting.exposure; + sensor.pixel_count_ratio = setting.pixel_count_ratio; + sensor.shading_factor = setting.shading_factor; + sensor.segment_order = setting.segment_order; + sensor.custom_regs = setting.custom_regs; + sensor.custom_fe_regs = setting.custom_fe_regs; + s_sensors->push_back(sensor); + } + } + + sensor = Genesys_Sensor(); sensor.sensor_id = SensorId::CCD_PLUSTEK_OPTICPRO_3600; // gl841 sensor.full_resolution = 1200;