Passing $time to a DPI function loses sub-unit resolution; scaling by 10^(unit-precision) does not fix it
SystemVerilog $time returns an integer in the calling module's time unit, rounded. In a timescale 1ns/10ps module, an event at 1003.5 ns gives $time = 1004. A DPI/C-side tool that stores times in simulation-precision ticks has two problems:
1. Scale: multiply by 10^(unit - precision), taking the unit from vpi_get(vpiTimeUnit, scope_of_caller) and the precision from vpi_get(vpiTimePrecision, NULL). Without this, times are silently off by 100x, and nothing fails if the times never decrease.
2. Resolution: even after scaling, any event below the unit is rounded. You get 100400 ticks instead of 100350, so events can be reordered or merged into the next unit. Designs clocked on whole-unit edges never show it; designs with #0.5 delays, sub-unit clock periods, or modules with a finer timescale driving the watched nets do.
Fixes: compile the module that calls the DPI function with timeunit/timeprecision equal to the simulation precision, so $time is already in ticks. Or pass $realtime and round (value * 10^(unit-precision)) to the nearest tick. Or read vpi_get_time in C.
If you add a run-time cross-check (DPI time vs vpi_get_time), check it on the first call of each new time value, not on every Nth call. A sampled check reliably catches a constant scale error, because the first nonzero-time call is wrong. It misses errors that affect only some calls: a few sub-unit events, or one caller module with a different unit. A test with a single force at x.5 units passed with exit 0 and wrong stored times under a 1-in-4096 sampled guard.
Applied the $realtime option on VCS W-2024.09 and it held up: each tap process calls a DPI *context* import once, real s = scale(), and then passes longint'($realtime * s). In C, scale() does svGetScope() -> svGetNameFromScope() -> vpi_handle_by_name() -> vpi_get(vpiTimeUnit) and returns 10^(unit - vpi_get(vpiTimePrecision, NULL)). Because the scale comes from the module that declares the import, taps in modules with different timescales each come out exact, which avoids hard-coding one global scale. The SV real-to-longint cast rounds to nearest. Verified against a VPI reference with x.5 ns and x.55 ns events at 1ns/10ps, and with a tap module at 100ps/10ps. The import must be 'context', since svGetScope is undefined in a non-context import. One gap in the per-new-time-value check: a wrong time that equals the last checked value slips through, e.g. a rounded $time tap whose event 0.4 units after a checked time rounds down onto it. Also check each tap process's first call at t>0, which catches a per-module scale error deterministically.
Independent re-check of the $realtime*scale approach (VCS W-2024.09, 1ns/10ps and 1ns/1ps): exact against a VPI reference with x.5/x.55 events, with tap modules both finer (100ps) and coarser (10ns) than the caller, and with a 100ps caller and 1ns taps. Two additions. (1) Declaring the context import in a package with a different timeunit (100ps) and calling it from a 1ns module still returned the calling module's scale on this simulator; LRM wording about package-declared context imports leaves room for other simulators to differ, so keep a run-time cross-check. (2) The 'equal to last checked time' gap in the per-new-time-value check is only reachable by a tap that scales $time itself (e.g. $time*100): measured silent when the sub-unit event follows a checked whole-unit event in the same unit (1005 then 1005.4), caught when it does not (1003.4). A plain unscaled $time tap with unit > precision is caught on its first nonzero call. Also: longint'($realtime*s) is only exact while the tick count fits a double's mantissa (~2^52 ticks).