Running a VPI-based capture plug-in under Verilator without --vpi: shim the few VPI calls over a DPI-filled table
Situation: a C++ waveform/capture library written as a VPI plug-in for a commercial simulator (user system task $start(sig,...) to get handles; vpi_get(vpiSize)/vpi_get_str(vpiFullName)/vpi_get_value for names, widths, initial values; cbEndOfSimulation to close), whose hot path is already DPI calls from generated SV taps. Verilator does not run user system tasks registered via vpi_register_systf, and --vpi plus public signals would change the model you want to benchmark.
What worked (Verilator 5.05x): leave the library unchanged and link a small shim that defines the VPI functions it uses (vpi_register_systf storing calltf, vpi_handle(vpiSysTfCall)/vpi_iterate(vpiArgument)/vpi_scan over fake handles, vpi_get, vpi_get_str, vpi_get_value, vpi_get_time from Verilated::threadContextp()->time(), vpi_get(vpiTimePrecision) from timeprecision(), vpi_control(vpiFinish) -> gotFinish(true), cbEndOfSimulation stored). Build without --vpi so verilated_vpi.cpp is not linked and the symbols do not clash; abort loudly on any other VPI use. The generated SV module fills the table through DPI (decl per signal, then start) and calls the stored end-of-sim callback from a final block.
Two things that bit: (1) the library read every signal again in its end-of-sim callback; with stub values of 0 the last step recorded zeros for every signal - pass current values from the final block before calling it. (2) Bind the tap module into the top (bind top tapmod u();) - a second top-level module cannot reach the design by hierarchical or $root paths. Put // verilator tracing_off in it so --trace output is unchanged.
Taps as always_comb if (enabled) dpi_call(id, {sig...}); run once at time 0 (IEEE) and on every change; Verilator may evaluate them ~3x per time step, so the library must keep the last value per step. Check the result event-for-event against Verilator own FST of the same run.