Skip to main content

epics_base_rs/server/record/
record_instance.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::sync::Mutex as StdMutex;
4use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5
6use crate::error::{CaError, CaResult};
7use crate::server::event_queue::{EventReader, EventUser};
8use crate::server::pv::{MonitorEvent, Subscriber};
9use crate::server::recgbl::EventMask;
10use crate::server::snapshot::{
11    ControlInfo, DisplayInfo, EnumInfo, EnumStringForm, PropertySupport,
12};
13use crate::types::{DbFieldType, EpicsValue, PvString, c_parse};
14
15use super::alarm::{AlarmSeverity, AnalogAlarmConfig};
16use super::common_fields::CommonFields;
17use super::link::{
18    ParsedLink, out_link_discards_cp, parse_forward_link_v2, parse_link_v2, parse_output_link_v2,
19};
20use super::menu_choices::MenuBound;
21use super::record_trait::{
22    AuxPostMask, CommonFieldPutResult, FieldDeclaration, FieldDesc, ProcessSnapshot, Record,
23    RecordProcessResult, SubroutineFn,
24};
25use super::scan::{ScanType, SimModeScan};
26
27/// Every client-visible `special(SPC_NOMOD)` field of `dbCommon.dbd:13-190`.
28///
29/// These are common fields — no record's `field_list` declares them — so the
30/// declaration names them here. The remaining `SPC_NOMOD` entries in
31/// `dbCommon.dbd` (MLOK, MLIS, BKLNK, ASP, PPN, PPNR, SPVT, RSET, DSET, DPVT,
32/// RDES, LSET, BKPT) are `DBF_NOACCESS`: they have no field API in this port at
33/// all, and C refuses them one level earlier, at `dbNameToAddr`.
34///
35/// TIME is `DBF_NOACCESS` in C too (`dbpf REC.TIME` → "failed"), but this port
36/// resolves `.TIME`, so the declaration must cover it.
37///
38/// Read only through [`RecordInstance::is_no_mod`].
39const DBCOMMON_NOMOD: &[&str] = &[
40    "NAME", "STAT", "SEVR", "AMSG", "NSTA", "NSEV", "NAMSG", "ACKS", "ACKT", "LCNT", "PACT",
41    "PUTF", "RPRO", "TIME", "UTAG",
42];
43
44thread_local! {
45    /// The origin tag applied to every event posted from the current
46    /// thread's synchronous put+process cascade when the poster itself
47    /// passes origin 0. Set only by [`AmbientWriteOriginScope`], read only
48    /// by [`RecordInstance::notify_field_with_origin`]. An in-process
49    /// writer (a ported SNL state machine) uses this so the whole
50    /// synchronous consequence of its put — the direct field post AND the
51    /// process-cycle posts, FLNK cascade included — carries its origin and
52    /// is filtered from its own subscriptions, while posts from work the
53    /// cascade merely *spawned* (a motor poller on another task) stay
54    /// untagged and visible to it.
55    static AMBIENT_WRITE_ORIGIN: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
56}
57
58/// RAII scope for [`AMBIENT_WRITE_ORIGIN`]. Sound only around code with no
59/// `.await` inside: the tag is thread-local, so crossing an await point
60/// would both leak it to interleaved tasks and lose it on work-stealing.
61/// The put paths that use it (`put_record_field_from_ca_no_notify_with_origin`)
62/// wrap a fully synchronous body.
63pub struct AmbientWriteOriginScope {
64    prev: u64,
65}
66
67/// Enter an ambient-origin scope; the previous value is restored on drop
68/// (scopes nest).
69pub fn ambient_write_origin_scope(origin: u64) -> AmbientWriteOriginScope {
70    let prev = AMBIENT_WRITE_ORIGIN.with(|c| c.replace(origin));
71    AmbientWriteOriginScope { prev }
72}
73
74impl Drop for AmbientWriteOriginScope {
75    fn drop(&mut self) {
76        AMBIENT_WRITE_ORIGIN.with(|c| c.set(self.prev));
77    }
78}
79
80/// The current thread's ambient write origin (0 outside any scope).
81/// `pub(crate)` so the simple-PV posting funnel
82/// (`ProcessVariable::deliver`) applies the same inheritance rule as the
83/// two record funnels in this file.
84pub(crate) fn ambient_write_origin() -> u64 {
85    AMBIENT_WRITE_ORIGIN.with(|c| c.get())
86}
87
88/// Put-notify completion wait-set — the C `dbNotify.c` `processNotify`
89/// waitList analogue (`dbNotifyAdd` / `dbNotifyCompletion`).
90///
91/// A `ca_put_callback` / WRITE_NOTIFY completion must fire only after the
92/// originating (put-target) record AND every record reached through its
93/// FLNK / OUT / process-action dispatch chain (synchronous *or* async)
94/// has finished processing. A single wait-set owns the completion
95/// oneshot; only it fires, and only when the last chain member leaves.
96///
97/// Counting convention: [`Self::new`] arms `pending = 1` for the
98/// originating record (which always joins). Every additional PP target
99/// that will process under the active notify [`Self::enter`]s on join
100/// (C `dbNotifyAdd`), and every record [`Self::leave`]s when its
101/// processing completes (C `dbNotifyCompletion`). The oneshot fires on
102/// the `leave` that drops `pending` to zero.
103pub struct NotifyWaitSet {
104    pending: AtomicUsize,
105    tx: StdMutex<Option<crate::runtime::sync::oneshot::Sender<()>>>,
106}
107
108impl NotifyWaitSet {
109    /// Arm a wait-set whose `tx` fires when the chain settles. `pending`
110    /// starts at 1 for the originating record — its completion `leave`s
111    /// that implicit slot, so a put with no chain targets fires
112    /// immediately on the originating record's own completion.
113    pub fn new(tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
114        Arc::new(Self {
115            pending: AtomicUsize::new(1),
116            tx: StdMutex::new(Some(tx)),
117        })
118    }
119
120    /// A PP target joined the chain (C `dbNotifyAdd`). Balanced by exactly
121    /// one [`Self::leave`].
122    pub fn enter(&self) {
123        self.pending.fetch_add(1, Ordering::AcqRel);
124    }
125
126    /// A record finished its contribution (C `dbNotifyCompletion`). Fires
127    /// the completion oneshot on the `leave` that empties the set.
128    pub fn leave(&self) {
129        let prev = self.pending.fetch_sub(1, Ordering::AcqRel);
130        debug_assert!(prev >= 1, "NotifyWaitSet::leave underflow");
131        if prev == 1 {
132            if let Some(tx) = self.tx.lock().unwrap().take() {
133                let _ = tx.send(());
134            }
135        }
136    }
137
138    /// True once every chain member has left (the completion has fired).
139    /// Used by the put entry to decide synchronous ([`ProcessCompletion::Sync`])
140    /// vs async-pending ([`ProcessCompletion::Async`]) completion.
141    pub fn completed(&self) -> bool {
142        self.pending.load(Ordering::Acquire) == 0
143    }
144}
145
146/// The completion outcome of an externally-initiated record process cycle —
147/// the value a caller learns after driving the synchronous head of a
148/// `dbPutNotify` / CA `WRITE_NOTIFY`.
149///
150/// This is the contract the **RTEMS CA driver** consumes: the CA thread drives
151/// the synchronous head of a put (C `dbProcessNotify`, `rsrv/camessage.c`
152/// `write_notify_action`) to completion — on RTEMS via `park_on` — then
153/// `match`es this value to decide whether to reply inline or return now and let
154/// background infrastructure deliver the completion later. The caller learns
155/// sync-vs-async as a typed value, not by inferring it from `Option::is_some`.
156///
157/// # C parity (`dbNotify.c`)
158///
159/// The C `processNotify` state machine forks a put-notify exactly here:
160///
161/// * **[`Self::Sync`]** — the record was neither active (`pact`) nor selected
162///   for processing, so `processNotifyCommon` runs `callDone`
163///   (`dbNotify.c:270`), which fires `doneCallback` INLINE on the calling
164///   thread (`dbNotify.c:182`). Our fully-synchronous chain drains the
165///   [`NotifyWaitSet`] before the put entry returns.
166/// * **[`Self::Async`]** — the record was `pact` (`notifyRestartInProgress`,
167///   `dbNotify.c:225-231`) or processed into an async device
168///   (`notifyProcessInProgress`, `dbNotify.c:252-263`). Completion is deferred
169///   to `dbNotifyCompletion` (`dbNotify.c:445-475`), which fires the user
170///   callback via `callbackRequest` (`:466`/`:470`) when the tracked waitList
171///   empties. Our [`NotifyWaitSet::leave`]-to-zero fires the `handle` oneshot
172///   at that same moment.
173///
174/// # Invariant (by construction)
175///
176/// Exactly one of {`Sync` returned, the `Async` handle fires exactly once} per
177/// initiated cycle. The single owner of the fire is [`NotifyWaitSet`]: its
178/// `leave`-to-zero `take`s the oneshot sender and sends once, so the handle can
179/// never fire twice; and `Sync` is returned only when the wait-set already
180/// drained, so no handle is outstanding to fire. There is no parallel
181/// signalling path — the oneshot is the sole completion channel.
182#[derive(Debug)]
183pub enum ProcessCompletion {
184    /// The cycle settled within the calling thread — the caller replies inline.
185    Sync,
186    /// The cycle went async; `handle` fires exactly once when the tracked
187    /// FLNK/OUT chain settles (C `dbNotifyCompletion`).
188    Async(crate::runtime::sync::oneshot::Receiver<()>),
189}
190
191impl ProcessCompletion {
192    /// Build the outcome from the wait-set's internal signal. `None` — the
193    /// wait-set drained synchronously, or the completion receiver lives
194    /// elsewhere (a deferred-restart replay carries only the sender) — is
195    /// [`Self::Sync`]; `Some(rx)` is [`Self::Async`].
196    pub(crate) fn from_signal(rx: Option<crate::runtime::sync::oneshot::Receiver<()>>) -> Self {
197        match rx {
198            Some(rx) => Self::Async(rx),
199            None => Self::Sync,
200        }
201    }
202
203    /// The completion handle if this cycle went async, else `None`. The CA
204    /// `WRITE_NOTIFY` dispatch uses this to choose inline reply (`None`) vs a
205    /// spawned completion task (`Some(rx)`).
206    pub fn into_handle(self) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
207        match self {
208            Self::Sync => None,
209            Self::Async(rx) => Some(rx),
210        }
211    }
212
213    /// True if the cycle went async (a completion handle is outstanding).
214    pub fn is_async(&self) -> bool {
215        matches!(self, Self::Async(_))
216    }
217
218    /// True if the cycle completed synchronously (no handle to await).
219    pub fn is_sync(&self) -> bool {
220        matches!(self, Self::Sync)
221    }
222}
223
224/// A put-notify (`dbPutNotify` — CA WRITE_NOTIFY, `caput -c`) that landed on a
225/// PACT record and was therefore deferred WHOLE.
226///
227/// C `processNotifyCommon` (dbNotify.c:225-231) tests `precord->pact` above
228/// `ppn->putCallback`, so nothing is written and nothing is marked: the record
229/// joins the notify's wait list in state `notifyRestartInProgress`, and when the
230/// async cycle completes the put is replayed against a record that is no longer
231/// active — value written, record processed, callback fired only after THAT
232/// process finishes. So a client's "callback returned" still means "the value I
233/// sent has been processed".
234///
235/// softIoc 7.0.10.1-DEV, `ASY` (calcout, `ODLY=4`, `A=5`), `caput -c ASY.A 7`
236/// issued 1 s into the async cycle:
237///
238/// ```text
239/// t=1s  A=5  PACT=1                      <- cycle in flight
240/// t=2s  A=5  PACT=1  RPRO=0              <- put-notify pending: nothing written
241/// t=4s  A=7  PACT=1                      <- cycle done; the put is replayed
242/// callback returns at t=6.9s: A=7 VAL=7  <- after the RESTARTED process
243/// ```
244pub struct DeferredNotifyPut {
245    /// The field the client wrote (already upper-cased).
246    pub field: String,
247    /// The value it wrote — held here, unwritten, until the restart.
248    pub value: crate::types::EpicsValue,
249    /// The client's completion channel. The replayed put builds its wait-set
250    /// around this sender, so the callback fires on the restarted process, not
251    /// on the in-flight cycle.
252    pub completion: crate::runtime::sync::oneshot::Sender<()>,
253}
254
255/// The PACT→idle transition, as a value.
256///
257/// # Invariant
258///
259/// `RecordInstance::deferred_notify_put.is_some()` ⟹ PACT is held. A
260/// put-notify is parked ONLY on the `is_processing()` arm of the put entry
261/// ([`RecordInstance::park_notify_put`]), and PACT can be released ONLY by
262/// [`RecordInstance::leave_pact`], which takes the park with it. So the
263/// deferral cannot outlive the PACT window it was parked in — the strand is
264/// unrepresentable rather than guarded against.
265///
266/// The token is `#[must_use]`: a release site cannot silently drop the parked
267/// put. Its single consumer is `PvDatabase::apply_pact_exit`, called from the
268/// released cycle's `recGblFwdLink` tail — C `recGbl.c:295` (`if (pdbc->ppn)
269/// dbNotifyCompletion(pdbc)`), which is where C queues the restart callback
270/// (`dbNotify.c:466-469`, state `notifyRestartInProgress`). Holding the token
271/// to the tail rather than replaying at the `pact = FALSE` store is what keeps
272/// the replay behind the rest of the cycle, exactly as C's queued callback is.
273#[must_use = "a put-notify parked on this record is stranded unless the PactExit \
274              reaches PvDatabase::apply_pact_exit"]
275pub struct PactExit(Option<DeferredNotifyPut>);
276
277impl Drop for PactExit {
278    /// Last-resort canary. Every release site hands its token to
279    /// `PvDatabase::apply_pact_exit`; a token reaching here still holding a
280    /// parked put means a PACT release path was added without a tail. The
281    /// client is not left hanging (dropping `completion` errors its receiver),
282    /// but the put's value IS lost, so say so.
283    fn drop(&mut self) {
284        if self.0.is_some() {
285            tracing::error!(
286                "PactExit dropped with a put-notify still parked: a PACT release \
287                 path is not routed through PvDatabase::apply_pact_exit"
288            );
289        }
290    }
291}
292
293impl PactExit {
294    /// The put-notify this release freed, if one was parked.
295    pub(crate) fn into_deferred(mut self) -> Option<DeferredNotifyPut> {
296        self.0.take()
297    }
298
299    /// Fold two releases of the same cycle into one token.
300    ///
301    /// A simulated SDLY continuation releases PACT inside `check_simulation_mode`
302    /// and again at the `is_continuation` arm; the second release finds PACT
303    /// already clear and carries nothing, because the first took the park.
304    pub(crate) fn merge(mut self, mut other: PactExit) -> PactExit {
305        debug_assert!(
306            self.0.is_none() || other.0.is_none(),
307            "a parked put-notify can be released only once per cycle"
308        );
309        PactExit(self.0.take().or_else(|| other.0.take()))
310    }
311
312    /// A cycle that released no PACT (and therefore freed no put-notify).
313    pub(crate) fn none() -> PactExit {
314        PactExit(None)
315    }
316}
317
318/// Cached metadata for a record.
319///
320/// Stores the result of `populate_display_info` / `populate_control_info` /
321/// `populate_enum_info` so subsequent `snapshot_for_field` /
322/// `make_monitor_snapshot` calls can skip rebuilding the metadata. The
323/// cache is invalidated whenever a metadata-class field is written
324/// (EGU, PREC, HOPR, LOPR, alarm limits, DRVH/DRVL, state strings).
325///
326/// In a CA-only IOC this is a CPU win; in a hybrid CA + PVA IOC where
327/// every snapshot needs full metadata for NTScalar serialization, the
328/// cache eliminates redundant per-event populate work.
329#[derive(Clone, Default)]
330pub(crate) struct MetadataSnapshot {
331    pub display: Option<DisplayInfo>,
332    pub control: Option<ControlInfo>,
333    pub enums: Option<EnumInfo>,
334}
335
336/// Returns true if this field is property-class — the C `prop(YES)`
337/// dbd attribute: writing a changed value posts `DBE_PROPERTY` to the
338/// record's subscribers AND invalidates the metadata cache. Field name
339/// is expected uppercase.
340///
341/// **Every field read by `populate_display_info`,
342/// `populate_control_info`, or `populate_enum_info` MUST be in this
343/// set** — otherwise the cache serves stale metadata until some other
344/// tracked field is written. The reverse does not hold: a field may be
345/// property-class without being a cache source (e.g. the motor fields
346/// below feed the live-computed `field_metadata_override`, never the
347/// cache — its invalidation on their write is harmless).
348///
349/// Currently uncovered (because it is not yet populated by any
350/// `populate_*` function): `DESC` (would map to `display.description`
351/// — populate hook missing). The `Q:form` info tag is now wired
352/// (`populate_display_info` -> `display.form`), but as an immutable
353/// load-time info tag — not a runtime field — it needs no cache
354/// invalidation and so is intentionally absent from this field set.
355fn is_metadata_field(name: &str) -> bool {
356    matches!(
357        name,
358        // Display info (analog + integer + motor) — `prop(YES)` in
359        // ai/ao/longin/longout DBDs.
360        "EGU" | "PREC" | "HOPR" | "LOPR" | "HLM" | "LLM"
361        // Alarm limits (used by both display and the analog_alarm config) —
362        // ai/ao/longin/longout `prop(YES)`.
363        | "HIHI" | "HIGH" | "LOW" | "LOLO"
364        // Alarm severities for the four limit thresholds —
365        // ai/ao/longin/longout `prop(YES)` per upstream DBDs
366        // (`aiRecord.dbd.pod` lines 357-388).
367        | "HHSV" | "HSV" | "LSV" | "LLSV"
368        // Output ctrl limits — ao/longout `prop(YES)`.
369        | "DRVH" | "DRVL"
370        // motor `prop(YES)` (`motorRecord.dbd` 154/161/289/361/368):
371        // VBAS/VMAX bound VELO's range, MRES the RVAL/RRBV raw range,
372        // DHLM/DLLM the DVAL/DRBV range — all served per field by
373        // `Record::field_metadata_override` (C get_graphic_double /
374        // get_control_double). HLM/LLM/EGU/PREC and the alarm limits
375        // are motor `prop(YES)` too, already listed above.
376        | "VBAS" | "VMAX" | "MRES" | "DHLM" | "DLLM"
377        // bi/bo/busy enum strings — `prop(YES)`.
378        | "ZNAM" | "ONAM"
379        // bi/bo state severities — `biRecord.dbd.pod` / `boRecord.dbd.pod`
380        // `prop(YES)` for ZSV/OSV/COSV (zero / one / change-of-state).
381        | "ZSV" | "OSV" | "COSV"
382        // mbbi/mbbo state strings (16 levels) — `prop(YES)`.
383        | "ZRST" | "ONST" | "TWST" | "THST" | "FRST" | "FVST" | "SXST" | "SVST"
384        | "EIST" | "NIST" | "TEST" | "ELST" | "TVST" | "TTST" | "FTST" | "FFST"
385    )
386}
387
388/// One alarm limit for a DBR_AL_DOUBLE response: the value when its
389/// severity threshold is enabled, `NaN` otherwise. Mirrors C
390/// `get_alarm_double`'s `prec->hhsv ? prec->hihi : epicsNAN` — a NONZERO
391/// test on the raw ordinal, so an out-of-range severity still enables the
392/// limit.
393fn gated(severity: i16, limit: f64) -> f64 {
394    if severity != 0 { limit } else { f64::NAN }
395}
396
397/// Extract the RAW stored ordinal a put lands in a `menu(menuAlarmSevr)`
398/// severity field (`HHSV`/`HSV`/`LSV`/`LLSV`/`UDFS`/`DISS`), WITHOUT clamping
399/// to the 0..=3 valid range.
400///
401/// C's numeric menu put stores whatever `(epicsEnum16)` the value truncates to
402/// (`dbConvert.c::putDoubleEnum` = `*pfield = (epicsEnum16)*psrc`), so
403/// `caput REC.HSV 4` keeps `4` and `caput REC.HSV -1` keeps `65535` — both
404/// wire-visible (served signed as `-1`) and both used verbatim to derive the
405/// alarm. The carrier is `i16` so the 16-bit pattern round-trips; the alarm
406/// meaning is read back with [`AlarmSeverity::from_u16`] and the C nonzero
407/// enable with `!= 0`.
408///
409/// A numeric value has already been wrapped to `epicsEnum16` upstream
410/// (`EpicsValue::convert_to(Enum)`, the one owner of C's double→enum cast); this
411/// only reinterprets its bit pattern. A `String` is a db-load / internal-link
412/// label (a client string put is rejected-or-resolved by `putStringMenu`
413/// upstream), resolved to its ordinal here.
414fn menu_ordinal_raw(value: &EpicsValue) -> i16 {
415    match value {
416        EpicsValue::String(s) => match s.as_str_lossy().as_ref() {
417            "NO_ALARM" => 0,
418            "MINOR" => 1,
419            "MAJOR" => 2,
420            "INVALID" => 3,
421            other => other
422                .parse::<i64>()
423                .ok()
424                .map(|n| n as u16 as i16)
425                .unwrap_or(0),
426        },
427        other => other.to_f64().unwrap_or(0.0) as i64 as u16 as i16,
428    }
429}
430
431/// Coerce a db-loaded `String` for a numeric/menu **common** field to that
432/// field's canonical DBF type before [`RecordInstance::put_common_field`]
433/// dispatches on it.
434///
435/// The db loader applies a record's own fields with the typed
436/// `EpicsValue::parse(desc.dbf_type, value_str)` (`db_loader::apply_fields`),
437/// but a field absent from `field_list` is pushed to the common-field path as
438/// a raw `EpicsValue::String` — it has no `FieldDesc` to parse against. The
439/// numeric common-field arms in `put_common_field` match only their typed
440/// variant, so without this step a `.db` `field(PHAS, "1")`,
441/// `field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`, `field(DISA, "1")`, … is
442/// silently dropped at IOC load. Routing the String through the same
443/// `EpicsValue::parse` the record-field path uses handles the numeric *and*
444/// menu-label forms uniformly, so the arm receives the value it expects.
445///
446/// Only fields whose canonical type is numeric/menu are listed; the
447/// Port of libcom `epicsParseInt32(str, &to, 10, NULL)`
448/// (`libcom/src/misc/epicsStdlib.c:26-53,245-261`), which is how pvxs parses
449/// the `nsec:lsb:` digit count. Returns `None` for every status the C
450/// returns non-zero for:
451///
452/// - `S_stdlib_noConversion` — `strtol` consumed nothing (empty / no digits)
453/// - `S_stdlib_extraneous` — trailing non-space bytes with `units == NULL`
454/// - `S_stdlib_overflow` — outside `epicsInt32`
455///
456/// Leading and trailing ASCII whitespace and a leading `+`/`-` sign are
457/// accepted, matching `epicsParseLong`'s `isspace` skips and `strtol`.
458fn epics_parse_int32_base10(s: &str) -> Option<i32> {
459    // `while ((c = *str) && isspace(c)) ++str;` then `strtol(str, &endp, 10)`.
460    let body = s.trim_start_matches(|c: char| c.is_ascii_whitespace());
461    let (sign, digits) = match body.strip_prefix(['+', '-']) {
462        Some(rest) if body.starts_with('-') => (-1i64, rest),
463        Some(rest) => (1i64, rest),
464        None => (1i64, body),
465    };
466    let end = digits
467        .find(|c: char| !c.is_ascii_digit())
468        .unwrap_or(digits.len());
469    if end == 0 {
470        return None; // endp == str → S_stdlib_noConversion
471    }
472    // `if (c && !units) return S_stdlib_extraneous;` after skipping trailing
473    // whitespace.
474    if !digits[end..]
475        .trim_start_matches(|c: char| c.is_ascii_whitespace())
476        .is_empty()
477    {
478        return None;
479    }
480    // ERANGE from `strtol`, then the explicit `epicsInt32` range check.
481    let magnitude: i64 = digits[..end].parse().ok()?;
482    i32::try_from(sign * magnitude).ok()
483}
484
485/// The STORED type of a `dbCommon` field — the variant its
486/// [`RecordInstance::put_common_field_bounded`] arm binds, which is not always
487/// the type the `.dbd` DECLARES it as (a `menu()` field is declared `DBF_MENU`
488/// and served `DBR_ENUM`, but held here as its bare index).
489///
490/// String-typed common fields (DESC, ASG, OUT, TSEL, …) have no entry: their
491/// arms take the string verbatim.
492fn stored_common_field_type(name: &str) -> Option<DbFieldType> {
493    Some(match name {
494        "SCAN" | "SSCN" | "PINI" => DbFieldType::Enum,
495        "TSE" | "PHAS" | "PRIO" | "DISV" | "DISA" | "DISS" | "LCNT" | "UDFS" | "ACKT" | "ACKS"
496        | "SEVR" | "STAT" | "NSEV" | "NSTA" => DbFieldType::Short,
497        // The analog-alarm limits and the hysteresis margin — `DBF_DOUBLE` in
498        // every dbd that declares them (`aiRecord.dbd.pod:357-388`,
499        // `calcRecord.dbd.pod:716-744`, `sCalcoutRecord.dbd:479-531`). A field
500        // missing from this table reaches its arm as whatever variant the caller
501        // built, and an arm that binds a typed variant then drops it: that is
502        // how `field(HYST,"2")` silently became 0 on every record whose
503        // hysteresis lives in `common.hyst`
504        // (calc/calcout/scalcout/ai/ao/longin/int64in).
505        "HIHI" | "HIGH" | "LOW" | "LOLO" | "HYST" => DbFieldType::Double,
506        // The `DBF_UCHAR` flags. `bool` here, a NUMBER in C.
507        "DISP" | "UDF" | "TPRO" | "RPRO" | "BKPT" | "PROC" => DbFieldType::Char,
508        _ => return None,
509    })
510}
511
512/// **The single owner of "what type does a `dbCommon` field hold"**, run on
513/// EVERY put before the typed arms below see the value — so an arm may bind one
514/// variant and know the put cannot have arrived in another.
515///
516/// This is not a string-parsing convenience. A common field is reached by three
517/// writers with three different ideas of the value's shape: the db loader hands
518/// every field over as a raw `String`; a `dbPut` arrives coerced to the field's
519/// DECLARED type (`DBF_MENU` → `Enum` for PRIO, `DBF_UCHAR` → `Char` for DISP);
520/// an internal link delivers whatever its source stored. Before this ran on the
521/// non-`String` shapes too, each arm's single-variant `if let` was a silent
522/// drop for the other two writers — `caput REC.PRIO HIGH` resolved its label to
523/// `Enum(2)` and then vanished at the arm, leaving PRIO at 0.
524///
525/// An unparseable String is returned as-is so the arm drops it, and a menu
526/// field's bad label FAILS the put (`S_db_badChoice`) rather than landing as
527/// index 0.
528fn coerce_common_field(name: &str, value: EpicsValue, bound: MenuBound) -> CaResult<EpicsValue> {
529    let Some(dbf) = stored_common_field_type(name) else {
530        return Ok(value);
531    };
532    let EpicsValue::String(s) = &value else {
533        // Already typed: project onto the stored type through the one
534        // value-coercion owner. `convert_to` short-circuits a value that is
535        // already `dbf`, so the common case costs nothing.
536        return Ok(value.convert_to(dbf));
537    };
538    let text = s.as_str_lossy();
539    // A `DBF_MENU` common field resolves its label against THAT field's own
540    // menu through the one converter every menu-field string put uses
541    // (C `dbConvert.c::putStringMenu`: exact label, else an index below
542    // `nChoice`, else `S_db_badChoice`) — the same rule the record-specific
543    // menu fields follow in `coerce_write_value`. The failure PROPAGATES: the
544    // field-blind `EpicsValue::parse` fallback below must never see a menu
545    // field, or `caput REC.PRIO Bogus` lands as index 0 instead of failing.
546    //
547    // SCAN/SSCN/PINI are menu fields like any other and go through the same
548    // converter. They used to each carry a hand-written `from_str` that drifted
549    // from C: `ScanType::from_str` case-folded and invented `"0.5 second"`
550    // aliases for menuScan's `".5 second"` (and mapped any out-of-range index
551    // to Passive), `SimModeScan::from_str` took any u16, `PiniMode::from_str`
552    // trimmed. C has ONE converter and it does none of that.
553    if let Some(choices) = super::menu_choices::shared_menu_choices(name) {
554        return super::menu_choices::resolve_menu_field_string_bounded(
555            name, choices, dbf, &text, bound,
556        );
557    }
558    // Numeric (non-menu) common field: C's `dbPut` runs the string through the
559    // SAME `epicsParse*` (`dbConvert.c` `putString*`) the record data fields use,
560    // and a non-zero status REFUSES the whole put (`dbAccess.c:1362`, mapped to
561    // `ECA_PUTFAIL`). Route it through the single owner of that conversion —
562    // [`c_parse::put_string`] — instead of the field-blind `EpicsValue::parse`,
563    // which wrapped (`256 as u8 == 0`) and swallowed the error (`Err(_) =>
564    // Ok(value)`), so `caput REC.PROC 256` and `caput REC.PROC notanumber` were
565    // accepted where C rejects them.
566    //
567    // Key the parse on the field's C-DECLARED width, not its stored variant: the
568    // `DBF_UCHAR` flags (DISP/UDF/TPRO/RPRO/BKPT/PROC, `dbCommon.dbd`) are held in
569    // the signed `Char` variant here but C parses them with `epicsParseUInt8`, so
570    // `caput REC.PROC 255` and `caput REC.PROC -1` (→255) are accepted and only
571    // `256`+/non-numeric refused. `put_string` returns the value in the declared
572    // variant; project it back onto the stored variant through the one
573    // value-coercion owner (byte-identity for `UChar`→`Char`).
574    let declared = match dbf {
575        DbFieldType::Char => DbFieldType::UChar,
576        other => other,
577    };
578    let Some(target) = c_parse::NumericField::of(declared) else {
579        // Unreachable: every numeric `stored_common_field_type` (Char/Short/
580        // Double, all with a numeric row) reaches here; the `Enum` menu types
581        // returned above. Keep the pre-parse value rather than panic.
582        return Ok(value);
583    };
584    c_parse::put_string(name, target, text.trim()).map(|parsed| parsed.convert_to(dbf))
585}
586
587/// The alarm-acknowledge request types C's `dbPut` dispatches on
588/// (`dbAccess.c:1331-1335`): `DBR_PUT_ACKT` and `DBR_PUT_ACKS`.
589///
590/// Acknowledgement is a *request type*, not a field write — the two handlers
591/// run above the `SPC_NOMOD` gate that refuses every ordinary put to ACKT/ACKS.
592#[derive(Debug, Clone, Copy, PartialEq, Eq)]
593pub enum AlarmAck {
594    /// `DBR_PUT_ACKT` → `putAckt`: set transient-alarm acknowledgement.
595    Transient,
596    /// `DBR_PUT_ACKS` → `putAcks`: acknowledge an alarm of this severity.
597    Severity,
598}
599
600/// A type-erased record instance stored in the database.
601pub struct RecordInstance {
602    pub name: String,
603    pub record: Box<dyn Record>,
604    pub common: CommonFields,
605    pub subscribers: HashMap<String, Vec<Subscriber>>,
606    // Link parse cache
607    pub parsed_inp: ParsedLink,
608    pub parsed_out: ParsedLink,
609    pub parsed_flnk: ParsedLink,
610    pub parsed_sdis: ParsedLink,
611    pub parsed_tsel: ParsedLink,
612    // Device support
613    pub device: Option<Box<dyn super::super::device_support::DeviceSupport>>,
614    // Subroutine (for sub records)
615    pub subroutine: Option<Arc<SubroutineFn>>,
616    /// PACT (C `precord->pact`) — the re-entrancy guard, and the record's
617    /// "busy" state for every put that lands on it.
618    ///
619    /// PRIVATE by construction: entered through [`RecordInstance::enter_pact`]
620    /// and released ONLY through [`RecordInstance::leave_pact`], which hands
621    /// back the [`PactExit`] carrying any put-notify parked on this PACT window.
622    /// A `pact.store(false)` open-coded at a release site is what stranded the
623    /// deferral on the ODLY/SDLY paths; it is no longer expressible.
624    pact: AtomicBool,
625    // Put-notify wait-set this record currently belongs to (C
626    // `precord->ppn`). Set when the record joins an active put-notify
627    // (originating put target, or a FLNK/OUT PP target via `dbNotifyAdd`);
628    // taken + `leave`d when the record's processing completes. `None`
629    // outside any put-notify. See [`NotifyWaitSet`].
630    pub notify: Option<Arc<NotifyWaitSet>>,
631    /// A put-notify that arrived while this record was PACT, deferred WHOLE —
632    /// C `processNotifyCommon` (dbNotify.c:225-231) tests `precord->pact`
633    /// ABOVE `putCallback`, so a `dbPutNotify` onto a busy record writes
634    /// nothing: no value, no RPRO. The record is parked on the notify's wait
635    /// list and the entire put — value, process, callback — is restarted once
636    /// the async cycle completes. See [`DeferredNotifyPut`] and
637    /// `PvDatabase::restart_deferred_notify_put`, the single owner that applies
638    /// it. `None` outside that window.
639    ///
640    /// PRIVATE, and paired with [`Self::pact`] by the [`PactExit`] invariant:
641    /// parked only by [`Self::park_notify_put`] (reachable only from the
642    /// PACT arm of the put entry), taken only by [`Self::leave_pact`].
643    deferred_notify_put: Option<DeferredNotifyPut>,
644    /// The value of each subscribed field as ALREADY PUBLISHED to that
645    /// field's `DBE_VALUE`/`DBE_LOG` subscribers. The generic
646    /// change-detection loop in every snapshot builder posts a field only
647    /// when its current value differs from this — so this map is what
648    /// C's per-record `*_lst` / MARK state is to `monitor()`.
649    ///
650    /// # Invariant (CONTRACT)
651    ///
652    /// A field's value MUST NOT be published twice by the framework.
653    /// Concretely: every value-class post (a `db_post_events` carrying
654    /// `DBE_VALUE` and/or `DBE_LOG`) MUST advance this map for the field it
655    /// posts; an alarm-only / property-only post MUST NOT (those classes do
656    /// not deliver the value to a `DBE_VALUE`/`DBE_LOG` subscriber, so the
657    /// change is still owed to them).
658    ///
659    /// In C, `dbPut` (dbAccess.c:1407-1414) is the record's ONLY post for a
660    /// put: `db_post_events(precord, pfieldsave, DBE_VALUE|DBE_LOG)`. No
661    /// record's `monitor()` re-posts that field — it posts a closed set and
662    /// compares against its own `*_lst` fields. A framework that posts on the
663    /// put and then change-detects the same field on the next process cycle
664    /// sends an event C never sends.
665    ///
666    /// # Owner
667    ///
668    /// [`RecordInstance::record_value_post`] is the SINGLE writer. The field
669    /// is private so no path outside this module can advance (or fail to
670    /// advance) it: the snapshot builders read it through
671    /// [`RecordInstance::posted_value`] and every poster —
672    /// [`RecordInstance::notify_field_with_origin`] included — advances it
673    /// through the owner.
674    last_posted: HashMap<String, EpicsValue>,
675    /// The live store for a field the record's `.dbd` DECLARES but the record
676    /// struct has no `put_field` arm / no storage for — the WRITE analog of the
677    /// read-side [`Self::declared_default`] fallback.
678    ///
679    /// C makes every `.dbd` field not just readable but WRITABLE: `dbPutField`
680    /// resolves the field from its `dbFldDes` and `dbPut` writes the incoming
681    /// value into record memory, whether or not any record code ever reads it
682    /// back — a `caput dfanout.HOPR 10` sticks even though `dfanoutRecord.c`
683    /// never touches HOPR. A Rust record models only the fields it has
684    /// behaviour for, so a field it declares but never stores had nowhere for a
685    /// put to land: [`Self::put_common_field`]'s catch-all reported
686    /// `S_dbLib_fieldNotFound` and the client's put was refused, while a READ of
687    /// the same field succeeded through `declared_default`. This map is that
688    /// missing storage — one uniform mechanism for the whole family, not a
689    /// per-field struct member on each record type.
690    ///
691    /// Keyed by upper-case field name, holding the value already coerced to the
692    /// field's C-declared DBF type (the same projection `declared_default` and
693    /// the read path serve). [`Self::resolve_field`] reads it BEFORE
694    /// `declared_default`, so a read reflects a prior write and an untouched
695    /// field still reads its `.dbd` initial. Empty for a record whose declared
696    /// fields are all modeled.
697    declared_overrides: HashMap<String, EpicsValue>,
698    /// Set by `check_deadband_ext` for waveform/aai/aao when their
699    /// content hash changed this cycle (C `monitor()` On Change mode,
700    /// waveformRecord.c:310-319). The snapshot builders read it to post
701    /// `HASH` with a literal `DBE_VALUE` event, independent of the VAL
702    /// post mask. False for every record without the MPST/APST/HASH
703    /// mechanism.
704    pub(crate) array_hash_changed: bool,
705    /// One-shot "skip the registered subroutine this cycle" signal for aSub
706    /// `LFLG=READ`. The async processing path resolves the `SUBL` link before
707    /// taking this lock; when the resolved name is bad (C `fetch_values` ->
708    /// `S_db_BadSub`) or the link read failed, C `process` runs `do_sub` only
709    /// on `!status`, so the subroutine is skipped. Set by the resolution
710    /// apply, consumed (and cleared) by [`Self::run_registered_subroutine`];
711    /// `false` for every record without a pending bad re-resolution.
712    pub(crate) suppress_subroutine_run: bool,
713    /// Generation counter for ReprocessAfter timer cancellation.
714    /// Bumped each process cycle. Spawned timers check this to avoid
715    /// stale re-processes from accumulated timers.
716    pub reprocess_generation: Arc<std::sync::atomic::AtomicU64>,
717    /// Generation counter for the monitor watchdog
718    /// ([`Record::watchdog_interval`] / [`Record::watchdog_fire`]), bumped by
719    /// each `PvDatabase::arm_watchdog` so a re-arm supersedes the tick already
720    /// in flight — C `callbackRequestDelayed` replacing an outstanding delayed
721    /// callback. Deliberately NOT `reprocess_generation`: C's histogram wdog is
722    /// its own `epicsCallback`, independent of the record's SDLY/async
723    /// re-entry, so an SDLY defer must not cancel the watchdog nor vice versa.
724    pub watchdog_generation: Arc<std::sync::atomic::AtomicU64>,
725    /// Per-record info tags from `info("key", "value")` directives in
726    /// the .db file (epics-base info(...) grammar). Consumers include
727    /// asyn (`asyn:READBACK`), record-as-PV bridge tags
728    /// (`Q:group`, `Q:form`), and IOC-specific extensions. Empty for
729    /// records loaded without info(...) clauses.
730    pub info: HashMap<String, String>,
731    /// Cached metadata (display/control/enums) — `None` means stale or
732    /// not yet built. Populated lazily by `snapshot_for_field` /
733    /// `make_monitor_snapshot` and invalidated by `invalidate_metadata_cache`
734    /// whenever a metadata-class field (EGU/PREC/HOPR/LOPR/limit/state)
735    /// is written.
736    ///
737    /// Wrapped in `std::sync::Mutex` for interior mutability — the
738    /// containing `RecordInstance` is shared via `Arc<RwLock<...>>` from
739    /// `PvDatabase`, and snapshot construction holds a read lock; the
740    /// inner Mutex lets us still mutate the cache from a `&self` method.
741    ///
742    /// # Cache invariant (CONTRACT)
743    ///
744    /// The cache is **only correct under the following contract**: every
745    /// code path that mutates a metadata-class field (the set defined in
746    /// the file-private `is_metadata_field` predicate) MUST call
747    /// [`RecordInstance::notify_field_written`] (or
748    /// [`RecordInstance::invalidate_metadata_cache`] directly) afterward.
749    ///
750    /// All current write paths in `field_io.rs` already do this. If you
751    /// add a new code path that:
752    ///
753    /// - calls `instance.record.put_field(...)` directly, OR
754    /// - mutates record fields from inside `Record::process()`,
755    ///   `Record::on_put`, or `Record::special` and that mutation could
756    ///   touch a metadata-class field, OR
757    /// - lets a `Box<dyn Record>` implementation expose its own
758    ///   mutation methods that change metadata fields,
759    ///
760    /// then call `instance.notify_field_written(field_name)` to keep the
761    /// cache consistent. Forgetting will produce a stale snapshot —
762    /// monitors will continue to see the old EGU/PREC/limits until the
763    /// next legitimate metadata-field write triggers invalidation.
764    ///
765    /// # Symmetric note for `populate_*` extensions
766    ///
767    /// If a future change adds a new field to `populate_display_info`,
768    /// `populate_control_info`, or `populate_enum_info` (e.g. populating
769    /// `display.description` from DESC), the new source field name MUST
770    /// also be added to `is_metadata_field` so writes to it invalidate
771    /// the cache. (The `Q:form` -> `display.form` mapping is exempt: it
772    /// reads an immutable load-time info tag, not a runtime field.)
773    pub(crate) metadata_cache: StdMutex<Option<MetadataSnapshot>>,
774}
775
776/// The cycle status [`RecordInstance::run_registered_subroutine`] reports when
777/// `do_sub` was skipped — C's `fetch_values` failure / `S_db_BadSub` path, which
778/// leaves `process`'s `status` non-zero (aSubRecord.c:216-224).
779const SUBROUTINE_STATUS_SKIPPED: i64 = -1;
780/// No subroutine is bound: C `do_sub` returns `S_db_BadSub` (aSubRecord.c:255).
781const SUBROUTINE_STATUS_NO_SUB: i64 = -2;
782/// The bound subroutine returned `Err` — no C counterpart (a C subroutine
783/// returns a `long`), and a failed cycle either way.
784const SUBROUTINE_STATUS_ERROR: i64 = -3;
785
786/// C `monitor()`'s post of the deadband field, as assembled by the single owner
787/// [`RecordInstance::deadband_post`].
788pub(crate) struct DeadbandPost {
789    /// C's `monitor_mask` for this cycle. Also the mask the
790    /// [`Record::fields_posted_with_value_mask`] secondaries ride: C posts them
791    /// from INSIDE the `if (monitor_mask)` guard, with the same mask.
792    pub mask: EventMask,
793    /// The deadband field's own post — `(field, value)`. `None` when no class
794    /// fired (C's `if (monitor_mask)` skips the post) or the field does not
795    /// resolve.
796    pub field: Option<(String, EpicsValue)>,
797}
798
799/// A value's `DBR_STRING` form, for a source whose field metadata is NOT
800/// reachable (an external CA/PVA link, a constant, an lnkCalc result) — the
801/// fallback half of [`RecordInstance::field_as_dbr_string`], which is also the
802/// whole rule once the local choice table has had its say.
803///
804/// A pvalink NTEnum still resolves its label here: the carrier brings its own
805/// `choices` (pvxs `pvalink_lset.cpp:344-356` — a `DBR_STRING` target copies
806/// `choices[index]`). A bare `Enum` index from a link whose labels the port
807/// cannot reach falls back to its decimal form, like the CA `*_STRING` encoder.
808/// **The** declaration lookup: a field's `dbFldDes`, in C's terms.
809///
810/// The record type's declaration is [`FieldDeclaration::field_list`] — the
811/// generated `.dbd` table where one exists and the hand-written table where it
812/// does not, never both. `dbCommon` is asked last, so a record-specific field
813/// shadows the common one.
814///
815/// A free function, not just a [`RecordInstance`] method, because the sites that
816/// need the declaration do not all hold an instance — a constant-link seed, a
817/// link write, the db loader all have `&dyn Record`.
818///
819/// `None` for a field with no declaration at all: a virtual field (`RTYP`,
820/// `TIME`, ...), which C answers from dbStaticLib rather than from a `dbFldDes`.
821pub(crate) fn field_desc_of<R: Record + ?Sized>(
822    record: &R,
823    field: &str,
824) -> Option<&'static FieldDesc> {
825    let named = |t: &'static [FieldDesc]| t.iter().find(|f| f.name.eq_ignore_ascii_case(field));
826    named(record.field_list()).or_else(|| named(super::dbd_generated::DB_COMMON_FIELDS))
827}
828
829/// **The single owner of "which choice list does this field resolve against"**,
830/// asked by BOTH sides of a menu field:
831///
832/// * the READ side ([`RecordInstance::enum_string_form_for`]) — C `getMenuString`,
833///   which renders the stored index as its choice;
834/// * the WRITE side ([`crate::server::record::coerce_put_value`]) — C
835///   `putStringMenu`, which resolves an incoming label to that same index.
836///
837/// They MUST see the same list or the field is not round-trippable. The write
838/// side used to ask only [`Record::menu_field_choices`] (the record's hand
839/// table), so an `aSub`'s `caput FTA LONG` found no menu, fell through to the
840/// numeric parse and landed as index 0 — while the read side, on the `.dbd`
841/// menu, rendered index 0 as `STRING`.
842///
843/// Order is C's: the field's own `menu()` from the declaration, then the
844/// record's hand table where the `.dbd` does not reach (the downstream crates'
845/// record types), then the `dbCommon` menus.
846///
847/// The last step — [`shared_menu_choices`](super::menu_choices::shared_menu_choices)
848/// — is a heuristic keyed on the field NAME (`OSV`, `SIMS`, `HHSV`, …), so it is
849/// consulted ONLY when the field's declaration does not already pin a non-menu
850/// type: a menu is served as `DBR_ENUM`, so a field DECLARED `DBF_STRING` is
851/// never a menu. Without this gate scalcout's string `OSV` ("Output string
852/// value") matched the name-based `menuAlarmSevr` entry a same-named bi/bo
853/// severity field owns, and `caput SCALCOUT.OSV <string>` was rejected with
854/// `S_db_badChoice` where C accepts the string.
855pub(crate) fn menu_choices_of<R: Record + ?Sized>(
856    record: &R,
857    field: &str,
858) -> Option<&'static [&'static str]> {
859    // `DTYP` is `DBF_DEVICE`: its choices are the record type's DEVICE menu
860    // (C `dbDeviceMenu`, built from the `device()` declarations), which is
861    // per-record-type and so cannot live in the shared `dbCommon` FieldDesc.
862    if field.eq_ignore_ascii_case("DTYP") {
863        return super::dbd_generated::device_menu(record.record_type());
864    }
865    let desc = field_desc_of(record, field);
866    desc.and_then(|f| f.menu)
867        .or_else(|| record.menu_field_choices(field))
868        .or_else(|| {
869            // Name-based fallback — but the declared type wins: a `DBF_STRING`
870            // field is not a menu even when a same-named field elsewhere is.
871            if desc.is_some_and(|f| f.dbf_type == DbFieldType::String) {
872                None
873            } else {
874                super::menu_choices::shared_menu_choices(field)
875            }
876        })
877}
878
879/// The `DBF_*` type `field` is SERVED as, for a caller that holds only a
880/// `&dyn Record` — the free-function form of
881/// [`RecordInstance::declared_field_type`], and the ONLY way any site outside
882/// the instance may turn a [`FieldDesc`] into a type.
883///
884/// A [`FieldDesc::runtime_typed`] field (`waveform.VAL` typed by `FTVL`, an
885/// `aSub`'s `A`..`U` typed by `FTA`..`FTU`) has NO type in its declaration: C's
886/// `cvt_dbaddr` overwrites `paddr->field_type` from record state, so the `.dbd`
887/// entry is a placeholder and the value the record stores is the answer. Every
888/// caller falls back to that value, which is why this returns `None` rather than
889/// the placeholder — handing out `DBF_DOUBLE` for a `FTVL=CHAR` waveform is how
890/// a string written down an output link became `0.0`.
891pub(crate) fn declared_field_type_of<R: Record + ?Sized>(
892    record: &R,
893    field: &str,
894) -> Option<DbFieldType> {
895    let desc = field_desc_of(record, field)?;
896    (!desc.runtime_typed).then_some(desc.dbf_type)
897}
898
899pub(crate) fn value_as_dbr_string(value: &EpicsValue) -> Option<PvString> {
900    match value {
901        EpicsValue::String(s) => Some(s.clone()),
902        EpicsValue::Enum(v) => Some(PvString::from(v.to_string())),
903        EpicsValue::EnumWithChoices { index, choices } => Some(
904            choices
905                .get(*index as usize)
906                .cloned()
907                .unwrap_or_else(|| PvString::from(index.to_string())),
908        ),
909        other => match other.clone().convert_to(DbFieldType::String) {
910            EpicsValue::String(s) => Some(s),
911            _ => None,
912        },
913    }
914}
915
916/// The `dbCommon` link fields, each with the C link-field type its text is
917/// parsed under (`dbStaticLib.c:2380-2391`): `INP`/`TSEL`/`SDIS` are
918/// `DBF_INLINK`, `OUT` is `DBF_OUTLINK`, `FLNK` is `DBF_FWDLINK`.
919///
920/// These five — and only these five — have a parse cache on
921/// [`RecordInstance`], which is what lets a one-shot init decision (C
922/// `dbInitLink` setting `DBLINK_FLAG_INITIALIZED`) be committed for them.
923/// The list has one owner because it is read from three places that must not
924/// drift: the per-field parse in `put_common_field`, the database's
925/// `record_link_fields` enumeration, and the `initialize_link_locality`
926/// commit. `FLNK` missing from just one of them is exactly how an external
927/// forward link went un-opened at init.
928pub const COMMON_LINK_FIELDS: [(&str, super::link::LinkFieldType); 5] = [
929    ("INP", super::link::LinkFieldType::In),
930    ("OUT", super::link::LinkFieldType::Out),
931    ("TSEL", super::link::LinkFieldType::In),
932    ("SDIS", super::link::LinkFieldType::In),
933    ("FLNK", super::link::LinkFieldType::Fwd),
934];
935
936impl RecordInstance {
937    pub fn new(name: String, record: impl Record) -> Self {
938        Self::new_boxed(name, Box::new(record))
939    }
940
941    /// The raw text of one [`COMMON_LINK_FIELDS`] entry, or `None` for any
942    /// other field name.
943    pub fn common_link_text(&self, field: &str) -> Option<&str> {
944        Some(match field {
945            "INP" => self.common.inp.as_str(),
946            "OUT" => self.common.out.as_str(),
947            "TSEL" => self.common.tsel.as_str(),
948            "SDIS" => self.common.sdis.as_str(),
949            "FLNK" => self.common.flnk.as_str(),
950            _ => return None,
951        })
952    }
953
954    /// The parse cache of one [`COMMON_LINK_FIELDS`] entry, or `None` for any
955    /// other field name. The only mutable handle on the cache outside
956    /// `put_common_field`, so the iocInit locality commit cannot reach a slot
957    /// that has no matching raw text.
958    pub fn common_link_cache_mut(&mut self, field: &str) -> Option<&mut ParsedLink> {
959        Some(match field {
960            "INP" => &mut self.parsed_inp,
961            "OUT" => &mut self.parsed_out,
962            "TSEL" => &mut self.parsed_tsel,
963            "SDIS" => &mut self.parsed_sdis,
964            "FLNK" => &mut self.parsed_flnk,
965            _ => return None,
966        })
967    }
968
969    pub fn new_boxed(name: String, record: Box<dyn Record>) -> Self {
970        let rtype = record.record_type();
971        let analog_alarm = match rtype {
972            // C parity: every record type whose dbd carries
973            // HIHI/HIGH/LOW/LOLO/HHSV/HSV/LSV/LLSV gets an analog-alarm
974            // config slot. Previously calc / calcout were missing —
975            // their put_field for those fields silently no-op'd
976            // because `self.common.analog_alarm` was None at the
977            // mutation site. Confirmed via
978            // calcRecord.dbd.pod:716-744 (HIHI..LLSV) and
979            // calcoutRecord.dbd.pod:1103+ (same). `sub` carries the same
980            // HIHI/HIGH/LOLO/LOW + HHSV/HSV/LSV/LLSV set
981            // (subRecord.dbd.pod:569-642) and runs the analog `checkAlarms`.
982            // `scalcout` declares the identical set (`sCalcoutRecord.dbd:479-531`
983            // HIHI/LOLO/HIGH/LOW/HHSV/LLSV/HSV/LSV/HYST + `:858` LALM) and its
984            // `checkAlarms` (`sCalcoutRecord.c:699-751`) is the same ladder, run
985            // BEFORE the OOPT switch (`:371`) precisely so a limit excursion can
986            // drive IVOA. Without the slot the record had no alarm surface at
987            // all: `caput scalc.HIHI 5` was a `FieldNotFound` and a scalcout
988            // could never go MINOR/MAJOR on its own result.
989            //
990            // **This match is the single owner of "which records have the analog
991            // ladder"** — `evaluate_alarms` runs it off the slot's presence, so a
992            // record added here gets the ladder and one absent cannot.
993            "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout"
994            | "sub" | "scalcout" => Some(AnalogAlarmConfig::default()),
995            _ => None,
996        };
997        let mut common = CommonFields::default();
998        common.analog_alarm = analog_alarm;
999
1000        Self {
1001            name,
1002            record,
1003            common,
1004            subscribers: HashMap::new(),
1005            parsed_inp: ParsedLink::None,
1006            parsed_out: ParsedLink::None,
1007            parsed_flnk: ParsedLink::None,
1008            parsed_sdis: ParsedLink::None,
1009            parsed_tsel: ParsedLink::None,
1010            device: None,
1011            subroutine: None,
1012            pact: AtomicBool::new(false),
1013            notify: None,
1014            deferred_notify_put: None,
1015            last_posted: HashMap::new(),
1016            declared_overrides: HashMap::new(),
1017            array_hash_changed: false,
1018            suppress_subroutine_run: false,
1019            reprocess_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1020            watchdog_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
1021            info: HashMap::new(),
1022            metadata_cache: StdMutex::new(None),
1023        }
1024    }
1025
1026    /// **The owner of a record's init passes** — C `iocInit.c::doInitRecord0`
1027    /// (`:508-536`) and `doInitRecord1`. Nothing else may call
1028    /// `Record::init_record`.
1029    ///
1030    /// C runs a prologue on EVERY record before pass 0, and it is the reason
1031    /// this is one function instead of two `init_record` calls at each caller:
1032    ///
1033    /// ```c
1034    /// /* Reset the process active field */
1035    /// precord->pact = FALSE;
1036    ///
1037    /// /* Initial UDF severity */
1038    /// if (precord->udf && precord->stat == UDF_ALARM)
1039    ///     precord->sevr = precord->udfs;
1040    /// ```
1041    ///
1042    /// A record is born `udf = 1`, `stat = UDF_ALARM` (dbCommon.dbd
1043    /// `initial("UDF")`), `udfs = INVALID` — so after `iocInit` a record that
1044    /// has NEVER processed advertises `STAT=UDF SEVR=INVALID`, not
1045    /// `NO_ALARM`. That is what makes an `MS` consumer inherit
1046    /// `LINK`/`INVALID` from a not-yet-processed source, the IOC-startup
1047    /// ordering case MS exists for (softIoc-verified). A record whose
1048    /// `init_record` or device support defines the value clears UDF and the
1049    /// severity goes away on its first process.
1050    ///
1051    /// `name` is used only for the init-failure diagnostics C sends to errlog.
1052    ///
1053    /// Crate-private on purpose: the passes must run against the record's FINAL
1054    /// loaded field set (the initial UDF severity is a function of UDF/STAT/
1055    /// UDFS, and a `.db` `field(VAL,…)` clears UDF at load — C
1056    /// `dbStaticLib.c:2653-2661`). The one caller is the creation sink,
1057    /// [`crate::server::database::PvDatabase::add_loaded_record`], which takes
1058    /// the load and the record together so no path can init a half-loaded
1059    /// record.
1060    pub(crate) fn run_init_passes(&mut self, name: &str) {
1061        // C's `precord->pact = FALSE` — a record cannot be mid-process at init,
1062        // so this release provably frees nothing: no client put has run, so the
1063        // `PactExit` invariant (`deferred_notify_put.is_some()` ⟹ PACT) makes a
1064        // parked put-notify here unreachable.
1065        let deferred = self.leave_pact().into_deferred();
1066        debug_assert!(
1067            deferred.is_none(),
1068            "a record cannot hold a parked put-notify at init"
1069        );
1070        drop(deferred);
1071        if self.common.udf != 0
1072            && self.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM
1073        {
1074            self.common.sevr = AlarmSeverity::from_u16(self.common.udfs as u16);
1075        }
1076        if let Err(e) = self.record.init_record(0) {
1077            eprintln!("init_record(0) failed for {name}: {e}");
1078        }
1079        if let Err(e) = self.record.init_record(1) {
1080            eprintln!("init_record(1) failed for {name}: {e}");
1081        }
1082        // The UDF tail of pass 1. `init_record` cannot reach UDF (a common
1083        // field), so the record types whose C `init_record` ends in
1084        // `prec->udf = FALSE` — histogram's `clear_histogram`, aao's constant
1085        // DOL, mbboDirect's B0..B1F fold (epics-base dabcf89) — deliver it
1086        // through this hook instead. It lives HERE, inside the init owner,
1087        // because it is part of the same C pass: a creation path that ran the
1088        // passes but skipped the tail (iocsh `dbLoadRecords` did) left those
1089        // records UDF=1 where C has UDF=0.
1090        // The `post_init_finalize_undef` hook is a cross-crate record-trait API
1091        // over a `bool` (histogram/aao/mbboDirect implement it); bridge the raw
1092        // `u8` carrier through it here at the single init owner.
1093        let mut udf = self.common.udf != 0;
1094        if let Err(e) = self.record.post_init_finalize_undef(&mut udf) {
1095            eprintln!("post_init_finalize_undef failed for {name}: {e}");
1096        }
1097        self.common.udf = udf as u8;
1098        // C `init_record` that ends in `prec->udf = 0; recGblResetAlarms(prec)`
1099        // — the asyn record, defined and no-alarm the moment it loads. The born
1100        // `UDF`/`INVALID` (and the UDF-severity derivation above) are overwritten
1101        // here: at init `nsta`/`nsev` are 0, so `rec_gbl_reset_alarms` transfers
1102        // `STAT`/`SEVR` to `NO_ALARM`. Runs after `post_init_finalize_undef` so
1103        // it is the final word on this record's initial alarm state.
1104        if self.record.init_resets_alarms() {
1105            self.common.udf = 0;
1106            let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut self.common);
1107        }
1108        // C `init_record` can END with `prec->pact = TRUE` to disable a record
1109        // it cannot process (`subRecord.c:119-123`, an empty SNAM). PACT has one
1110        // owner, so the record declares the fact and the owner parks it — after
1111        // the passes, so the `leave_pact()` above cannot undo it. There is
1112        // nothing to release later: `dbProcess` takes the PACT-active branch on
1113        // every scan from here on, which is exactly the point.
1114        if self.record.init_record_parks_pact() {
1115            self.enter_pact();
1116        }
1117    }
1118
1119    /// SINGLE OWNER of the DTYP -> soft-output-dset mapping. The dset table
1120    /// decides what a soft OUT-link write carries; no caller may re-derive it.
1121    ///
1122    /// C ships two soft output dsets per output record type and DTYP picks one:
1123    /// `devXxxSoft.c::write_xxx` puts VAL/OVAL on the OUT link, while
1124    /// `devXxxSoftRaw.c::write_xxx` puts the RAW word — `dbPutLink(&prec->out,
1125    /// DBR_LONG, &prec->rval, 1)` (`devAoSoftRaw.c:44`, `devBoSoftRaw.c:65`) or
1126    /// `data = prec->rval & prec->mask` (`devMbboSoftRaw.c:71-75`,
1127    /// `devMbboDirectSoftRaw.c:71-75`).
1128    ///
1129    /// `Record::raw_soft_output_value` IS the SoftRaw column of that table:
1130    /// `Some` exactly for the record types C ships a SoftRaw dset for. A record
1131    /// type C has no SoftRaw dset for keeps the plain soft-channel value —
1132    /// `DTYP="Raw Soft Channel"` on a `longout` is a `.db` error C rejects at
1133    /// init ("no device support"), and the port's lenient reading of it (the
1134    /// same one [`crate::server::device_support::is_soft_dtyp`] already applies
1135    /// on the input side) must not turn the write into a silent no-op.
1136    ///
1137    /// `None` means DTYP names device support that owns the write — real
1138    /// hardware, or "Async Soft Channel", whose dset is a registered async
1139    /// device.
1140    pub fn soft_output_value(&self) -> Option<Option<EpicsValue>> {
1141        if self.common.dtyp == "Raw Soft Channel" {
1142            return Some(
1143                self.record
1144                    .raw_soft_output_value()
1145                    .or_else(|| self.record.output_link_value()),
1146            );
1147        }
1148        if self.common.dtyp.is_empty() || self.common.dtyp == "Soft Channel" {
1149            return Some(self.record.output_link_value());
1150        }
1151        None
1152    }
1153
1154    /// Set a single `info("key", "value")` tag on this record. Last
1155    /// write wins. Used by the .db loader (`info(...)` directive) and
1156    /// `dbpf`-style tools.
1157    pub fn set_info(&mut self, key: impl Into<String>, value: impl Into<String>) {
1158        self.info.insert(key.into(), value.into());
1159    }
1160
1161    /// Look up a single info tag. Returns `None` when the record has
1162    /// no tag with that key.
1163    pub fn get_info(&self, key: &str) -> Option<&str> {
1164        self.info.get(key).map(|s| s.as_str())
1165    }
1166
1167    /// The value of `field` already published to its `DBE_VALUE`/`DBE_LOG`
1168    /// subscribers, or `None` when the framework has never published one.
1169    /// The read side of the `last_posted` contract — see the field's docs.
1170    pub(crate) fn posted_value(&self, field: &str) -> Option<&EpicsValue> {
1171        self.last_posted.get(field)
1172    }
1173
1174    /// SINGLE OWNER of `last_posted`: record that `value` has been published
1175    /// to `field`'s `DBE_VALUE`/`DBE_LOG` subscribers, so no later cycle
1176    /// change-detects and re-publishes it.
1177    ///
1178    /// Every value-class post — the snapshot builders' change-detected posts,
1179    /// the intermediate async-notify posts, and the put-time
1180    /// [`Self::notify_field_with_origin`] post that C makes from `dbPut`
1181    /// (dbAccess.c:1414) — routes through here. Alarm-only / property-only
1182    /// posts MUST NOT call it: they deliver nothing to a value-class
1183    /// subscriber, so the value is still owed.
1184    pub(crate) fn record_value_post(&mut self, field: &str, value: EpicsValue) {
1185        if let Some(slot) = self.last_posted.get_mut(field) {
1186            *slot = value;
1187        } else {
1188            self.last_posted.insert(field.to_string(), value);
1189        }
1190    }
1191
1192    /// Invalidate the metadata cache. Called after writing any
1193    /// metadata-class field (EGU, PREC, HOPR/LOPR, alarm limits,
1194    /// DRVH/DRVL, enum strings). The next snapshot will rebuild the
1195    /// cache from the new values.
1196    pub fn invalidate_metadata_cache(&self) {
1197        if let Ok(mut guard) = self.metadata_cache.lock() {
1198            *guard = None;
1199        }
1200    }
1201
1202    /// Hook called by the database after a field is written. If the
1203    /// field is in the metadata-class set, the cache is invalidated so
1204    /// the next snapshot picks up the new value.
1205    ///
1206    /// Field name is automatically uppercased.
1207    pub fn notify_field_written(&self, field: &str) {
1208        let upper = field.to_ascii_uppercase();
1209        if is_metadata_field(&upper) {
1210            self.invalidate_metadata_cache();
1211        }
1212    }
1213
1214    /// Like [`Self::notify_field_written`] but skips the invalidation when
1215    /// the put did not actually change the field's value. Mirrors
1216    /// epics-base `faac1df1` — `DBE_PROPERTY` events fire only on
1217    /// real changes, not on idempotent writes (the C path compares
1218    /// `paddr->pfield` against the converted payload before setting
1219    /// the `propertyUpdate` flag).
1220    ///
1221    /// `prev` is the value captured BEFORE the put. Callers that
1222    /// don't need the change-detection (e.g. internal writers that
1223    /// know the field is non-metadata) can keep using
1224    /// [`Self::notify_field_written`].
1225    // must post EventMask::PROPERTY to all field subscribers when metadata changes
1226    pub fn notify_field_written_if_changed(&mut self, field: &str, prev: Option<&EpicsValue>) {
1227        let upper = field.to_ascii_uppercase();
1228        if !is_metadata_field(&upper) {
1229            return;
1230        }
1231        let now = self.record.get_field(&upper);
1232        if prev != now.as_ref() {
1233            self.invalidate_metadata_cache();
1234            // mirror C dbAccess.c:1396-1397 db_post_events(precord, NULL, DBE_PROPERTY).
1235            // Collect keys first to avoid a re-entrant immutable borrow on subscribers.
1236            let fields: Vec<String> = self.subscribers.keys().cloned().collect();
1237            for f in fields {
1238                self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
1239            }
1240        }
1241    }
1242
1243    /// Returns the cached MetadataSnapshot, building and storing it on
1244    /// the first call (or after invalidation). Used by both
1245    /// `snapshot_for_field` and `make_monitor_snapshot` so the populate
1246    /// cost is paid at most once per metadata-stable interval.
1247    fn cached_metadata(&self) -> MetadataSnapshot {
1248        // Fast path: cache hit
1249        if let Ok(guard) = self.metadata_cache.lock()
1250            && let Some(cached) = guard.as_ref()
1251        {
1252            return cached.clone();
1253        }
1254
1255        // Cache miss: build a fresh metadata snapshot
1256        let mut tmp = super::super::snapshot::Snapshot::new(
1257            EpicsValue::Double(0.0),
1258            0,
1259            0,
1260            std::time::SystemTime::UNIX_EPOCH,
1261        );
1262        self.populate_display_info(&mut tmp);
1263        self.populate_control_info(&mut tmp);
1264        self.populate_enum_info(&mut tmp);
1265
1266        let meta = MetadataSnapshot {
1267            display: tmp.display,
1268            control: tmp.control,
1269            enums: tmp.enums,
1270        };
1271
1272        // Store back; ignore poisoning (cache is best-effort).
1273        if let Ok(mut guard) = self.metadata_cache.lock() {
1274            *guard = Some(meta.clone());
1275        }
1276        meta
1277    }
1278
1279    /// C `dbChannelSpecial(chan) == SPC_NOMOD` — **the single owner of the
1280    /// no-modify declaration**, for every consumer that needs to know whether a
1281    /// field can be written.
1282    ///
1283    /// C declares it once, in the `.dbd`, and reads it in two unrelated places:
1284    ///
1285    /// * `dbPut` (`dbAccess.c:123-126`, via `dbPutSpecial(paddr, 0)`) refuses
1286    ///   the write — the port's `check_no_mod` gate;
1287    /// * `rsrvCheckPut` (`rsrv/camessage.c:2540-2551`) — `if
1288    ///   (dbChannelSpecial(pciu->dbch) == SPC_NOMOD) return 0;` — which feeds
1289    ///   the CA `ACCESS_RIGHTS` write bit (`camessage.c:1123-1124`) as well as
1290    ///   both put paths, so a client sees `Access: read, no write` and never
1291    ///   sends the doomed write.
1292    ///
1293    /// Only the first consumer existed in the port, so every dbCommon NOMOD
1294    /// field advertised WRITE on the wire (`caput N1.SEVR 2` was refused
1295    /// server-side, after the client had already sent it, with an async
1296    /// exception instead of C's clean client-side "Write access denied").
1297    ///
1298    /// Three sources, one answer:
1299    ///
1300    /// 1. the dbCommon `SPC_NOMOD` set below — common fields, so no record's
1301    ///    `field_list` declares them;
1302    /// 2. the record type's **declaration**, resolved by `Self::field_desc` —
1303    ///    the vendored `.dbd` whenever one exists, and only for a record type
1304    ///    that has no `.dbd` at all (`motor`, `optics`, `scaler`, `std`) the
1305    ///    record's own hand-written table, which for those Tier 3 types
1306    ///    genuinely *is* their declaration;
1307    /// 3. [`Record::field_no_mod`] — an SPC_NOMOD a record's `cvt_dbaddr`
1308    ///    raises from its own state (compress VAL under BALG=LIFO,
1309    ///    `compressRecord.c:398-407`), which a static `FieldDesc` cannot
1310    ///    express.
1311    ///
1312    /// `field` may be any case.
1313    pub fn is_no_mod(&self, field: &str) -> bool {
1314        if DBCOMMON_NOMOD.iter().any(|f| f.eq_ignore_ascii_case(field)) {
1315            return true;
1316        }
1317        if self.field_desc(field).is_some_and(|f| f.read_only) {
1318            return true;
1319        }
1320        self.record.field_no_mod(field)
1321    }
1322
1323    /// Check if the record is currently processing (PACT equivalent).
1324    pub fn is_processing(&self) -> bool {
1325        self.pact.load(std::sync::atomic::Ordering::Acquire)
1326    }
1327
1328    /// C `prec->pact = TRUE` — the record goes busy for an async device
1329    /// round-trip, an SDLY simulation defer, or an ODLY reprocess window.
1330    pub fn enter_pact(&self) {
1331        self.pact.store(true, std::sync::atomic::Ordering::Release);
1332    }
1333
1334    /// C `prec->pact = FALSE` — the ONLY release of PACT.
1335    ///
1336    /// Every PACT→idle transition takes the put-notify parked on that window
1337    /// with it (C `dbNotifyCompletion`, reached from `recGblFwdLink` on every
1338    /// path that ends a cycle). The returned [`PactExit`] is `#[must_use]`, so
1339    /// a release site cannot strand the deferral the way the open-coded
1340    /// `processing.store(false)` at the ODLY continuation and the three SIM/SDLY
1341    /// releases did.
1342    pub fn leave_pact(&mut self) -> PactExit {
1343        self.pact.store(false, std::sync::atomic::Ordering::Release);
1344        PactExit(self.deferred_notify_put.take())
1345    }
1346
1347    /// True when a put-notify already owns this record — an in-flight wait-set
1348    /// (C `precord->ppn`) or a park from a previous PACT arm. C
1349    /// `processNotifyCommon` (dbNotify.c:213-217) queues a second notify on the
1350    /// record's restart list; the port refuses it (`S_db_Blocked` /
1351    /// `ECA_PUTCBINPROG`) — see [`Self::park_notify_put`].
1352    pub fn put_notify_busy(&self) -> bool {
1353        self.notify.is_some() || self.deferred_notify_put.is_some()
1354    }
1355
1356    /// Park a put-notify that landed on a PACT record (C `processNotifyCommon`,
1357    /// dbNotify.c:225-231). `Err(put)` hands the put back when another
1358    /// put-notify already owns the record.
1359    ///
1360    /// The [`PactExit`] invariant — `deferred_notify_put.is_some()` ⟹ PACT —
1361    /// is established here: this is the only park, and it is only reachable
1362    /// from the caller's `is_processing()` arm.
1363    pub fn park_notify_put(&mut self, put: DeferredNotifyPut) -> Result<(), DeferredNotifyPut> {
1364        debug_assert!(
1365            self.is_processing(),
1366            "a put-notify may be parked only on a PACT record"
1367        );
1368        if self.put_notify_busy() {
1369            return Err(put);
1370        }
1371        self.deferred_notify_put = Some(put);
1372        Ok(())
1373    }
1374
1375    /// Unified field resolution: record fields → common fields → virtual fields.
1376    pub fn resolve_field(&self, name: &str) -> Option<EpicsValue> {
1377        let name = name.to_ascii_uppercase();
1378        self.record
1379            .get_field(&name)
1380            .or_else(|| self.get_common_field(&name))
1381            .or_else(|| self.get_virtual_field(&name))
1382            .or_else(|| self.declared_overrides.get(&name).cloned())
1383            .or_else(|| self.declared_default(&name))
1384    }
1385
1386    /// The value a field that is DECLARED by the `.dbd` but has no live store
1387    /// on this record serves: its `initial(...)`, or a type-zero.
1388    ///
1389    /// C makes *every* `.dbd` field addressable — `dbNameToAddr` resolves the
1390    /// field from its `dbFldDes` and `dbGet` reads it out of record memory,
1391    /// which the dbd loader seeded with `initial()` (or left zero). A Rust
1392    /// record implements only the fields it has behaviour for, so a field it
1393    /// declares but never touches — `aSub.OVAL`, `sub.LA`, `sel.HOPR` — had no
1394    /// channel at all: [`Self::resolve_field`]'s three accessors all returned
1395    /// `None` and CA create-channel answered `S_dbLib_recNotFound`.
1396    ///
1397    /// The declared table is the contract for *which* fields exist; this is the
1398    /// last resort for the *value* of one with no runtime accessor, and it is
1399    /// exactly what an unprocessed C record on an empty `.db` returns —
1400    /// [`apply_dbd_initials`](crate::server::db_loader) seeds the same
1401    /// `initial()` into the fields the record *does* store, from the same
1402    /// generated table, so the two paths agree by construction.
1403    fn declared_default(&self, name: &str) -> Option<EpicsValue> {
1404        let desc = self.field_desc(name)?;
1405        // A `runtime_typed` field (`VAL`/`BG`, re-typed from `FTVL`/`SDEF`) is
1406        // record-owned by definition and its placeholder `dbf_type` is not what
1407        // it serves; never synthesise one here — the record itself answers it.
1408        if desc.runtime_typed {
1409            return None;
1410        }
1411        let initial = desc.initial.unwrap_or("");
1412        if let Some(choices) = desc
1413            .menu
1414            .or_else(|| self.record.menu_field_choices(name))
1415            .or_else(|| super::shared_menu_choices(name))
1416        {
1417            // A menu field with no `initial(...)` is index 0, exactly as an
1418            // empty numeric field is 0 below.
1419            if initial.is_empty() {
1420                return Some(EpicsValue::Enum(0));
1421            }
1422            return super::resolve_menu_field_string_db_load(name, choices, desc.dbf_type, initial)
1423                .ok();
1424        }
1425        // `parse` maps an empty string to the declared type's zero, so this one
1426        // call serves both `initial(...)` and no-initial fields.
1427        EpicsValue::parse_bytes(desc.dbf_type, initial.as_bytes()).ok()
1428    }
1429
1430    /// Resolve a field for EPICS `$` long-string (character-array) access.
1431    ///
1432    /// The `$` channel-name modifier (C `dbChannel.c:486-505`) re-views a
1433    /// field as a `DBR_CHAR` array: a `DBF_STRING` field becomes a char
1434    /// array of `field_size` elements, a link field a char array of
1435    /// `PVLINK_STRINGSZ`, and every other field type is rejected with
1436    /// `S_dbLib_fieldNotFound`. pvxs serves that char view as a
1437    /// `form = "String"` long-string `NTScalar` — it reads the `DBR_CHAR`
1438    /// bytes and NUL-terminates them back into a string
1439    /// (`ioc/iocsource.cpp:133-136`, `ioc/channel.cpp:62-74`).
1440    ///
1441    /// Both `DBF_STRING` fields and link fields resolve to an
1442    /// [`EpicsValue::String`] in this database (a link resolves to its
1443    /// textual form, see [`Self::get_common_field`]), so a field is
1444    /// `$`-eligible exactly when it resolves to a string value. Returns
1445    /// that string value for an eligible field, or `None` for a field the
1446    /// `$` modifier cannot view as a char array (the
1447    /// `S_dbLib_fieldNotFound` case) — the single owner of the
1448    /// dbChannel `$`-eligibility rule for the channel-resolution layer.
1449    pub fn resolve_string_view_field(&self, name: &str) -> Option<EpicsValue> {
1450        match self.resolve_field(name)? {
1451            v @ EpicsValue::String(_) => Some(v),
1452            _ => None,
1453        }
1454    }
1455
1456    /// Choice table for a field served as `DBR_ENUM` from a `DBF_MENU`:
1457    /// the record's own record-specific menu
1458    /// ([`Record::menu_field_choices`](super::record_trait::Record::menu_field_choices)),
1459    /// else a shared menu keyed by field name
1460    /// ([`shared_menu_choices`](super::menu_choices::shared_menu_choices)).
1461    /// The choices a `menu()` field serves as its `DBR_ENUM` labels.
1462    ///
1463    /// The `.dbd` declaration is the first and best answer: a generated
1464    /// [`FieldDesc`] carries the field's own `menu(...)` choices, which is what
1465    /// C's `dbGetFieldIndex` -> `pamapdbfType` -> menu lookup resolves. The two
1466    /// hand-maintained fallbacks below are for record types still on a
1467    /// hand-written table; they go away with the last of them.
1468    ///
1469    /// `shared_menu_choices` in particular keys on the field NAME alone, across
1470    /// every record type — which is only correct while no two record types give
1471    /// the same field name different menus. Asking the field's own descriptor
1472    /// first removes that assumption.
1473    fn menu_choices_for(&self, field: &str) -> Option<&'static [&'static str]> {
1474        menu_choices_of(self.record.as_ref(), field)
1475    }
1476
1477    /// The choices this record's `DTYP` selects among — C's `dbDeviceMenu` for
1478    /// the record type, in `.dbd` declaration order.
1479    ///
1480    /// C's DTYP field IS the index into this list, and an unset DTYP is index 0
1481    /// — which is why a bare `record(ai,"X"){}` serves `Soft Channel` and a
1482    /// `record(calc,"X"){}`, whose record type declares no device support at
1483    /// all, serves the empty string.
1484    ///
1485    /// The port stores the device NAME rather than the index, because the name
1486    /// is what the device-support registry dispatches on, and a name registered
1487    /// at runtime by a downstream crate (`asynInt32`) has no `device()` line in
1488    /// any vendored `.dbd`. Such a name is appended as its own slot, so the
1489    /// index and the string still name the SAME device support: there is no
1490    /// value of DTYP that renders as a device this record is not bound to.
1491    /// `None` when the record type declares NO device support at all — C's
1492    /// `dbDeviceMenu *pdevs = paddr->pfldDes->ftPvt; if (!pdevs) goto nostrs;`
1493    /// (`dbAccess.c:176-179`), which clears `DBR_ENUM_STRS` so the client is
1494    /// sent no choice list at all.
1495    ///
1496    /// C keeps that case DISTINCT from a device menu that exists but is empty,
1497    /// and says so at `dbAccess.c:205`: *"indicate option data not available.
1498    /// distinct from no_str==0"*. An empty-but-present menu is still marked,
1499    /// with `no_str = 0`; a missing menu is not marked. Returning `Vec` here
1500    /// and defaulting the missing menu to `[]` collapsed the two, so a
1501    /// `record(calc,"X"){}` — whose record type has no `device()` line — served
1502    /// `value.choices = {0}[]` where QSRV2 omits the leaf entirely.
1503    pub(crate) fn device_choices(&self) -> Option<Vec<PvString>> {
1504        let record_type = self.record.record_type();
1505        // Base's build-time menu (`epics-base-rs/dbd`), then the menus a
1506        // downstream crate whose device support has no vendored `device()` line
1507        // registered at runtime (asyn's `asynInt32`, `asynFloat64`, ...). C's
1508        // `dbDeviceMenu` is the concatenation of every `device()` the loaded
1509        // `.dbd` set declares, in load order — base first, then asyn — so the
1510        // merge appends the contributed choices AFTER the declared ones.
1511        // The C None-vs-empty distinction (`dbAccess.c:176-179` vs `:205`): the
1512        // menu is present iff the loaded `.dbd` set declares ANY `device()` for
1513        // this type. A type base declares none for but asyn does (structurally
1514        // possible, though none of asyn's are such) is therefore present, not
1515        // None; a type neither declares for (calc) stays None.
1516        if super::dbd_generated::device_menu(record_type).is_none()
1517            && super::contributed_device_menu(record_type).is_empty()
1518        {
1519            return None;
1520        }
1521        // `merged_device_menu` = declared + contributed, the SAME source the
1522        // CA-put validation (`coerce_put_value`'s DTYP branch) resolves against,
1523        // so a client can put exactly the DTYP names it can read here.
1524        let mut names: Vec<PvString> = super::merged_device_menu(record_type)
1525            .into_iter()
1526            .map(PvString::from)
1527            .collect();
1528        let dtyp = self.common.dtyp.as_str();
1529        if !dtyp.is_empty() && !names.iter().any(|n| n.as_str_lossy() == dtyp) {
1530            names.push(PvString::from(dtyp));
1531        }
1532        Some(names)
1533    }
1534
1535    /// C `dbPutFieldLink`'s link-type gate (`dbAccess.c:1125-1137`): a link
1536    /// written at RUNTIME is held to the same `dbCanSetLink` rule as one written
1537    /// by the `.db`, against the device support the record's CURRENT `DTYP`
1538    /// binds. Same rule, same owner — [`super::check_link_assignment`]; only the
1539    /// DTYP it is asked about differs (the record's, not the `.db` text's).
1540    ///
1541    /// [`MenuBound::DbLoad`] is exempt, and that is not a hole: on the db-load
1542    /// path C does not check a link as each field is parsed either. It checks
1543    /// once, at `iocInit`, over the record as loaded (`dbStaticLib.c:2178-2231`)
1544    /// — which is why `field(INP,…)` may precede `field(DTYP,…)` in a `.db` and
1545    /// still bind. `db_loader::check_link_types` is that pass, and it reads the
1546    /// record's DTYP out of the whole field set, so it does not depend on the
1547    /// order the `.db` happened to spell them in. Gating here as well would
1548    /// re-introduce exactly that order dependence.
1549    fn check_link_assignment(
1550        &self,
1551        upper_field: &str,
1552        text: &str,
1553        bound: MenuBound,
1554    ) -> CaResult<()> {
1555        if matches!(bound, MenuBound::DbLoad) {
1556            return Ok(());
1557        }
1558        super::check_link_assignment(
1559            self.record.record_type(),
1560            Some(self.common.dtyp.as_str()),
1561            upper_field,
1562            text,
1563        )
1564    }
1565
1566    /// The value of the `DTYP` field: the index of the bound device support in
1567    /// [`Self::device_choices`]. An unset DTYP is index 0, exactly as in C.
1568    pub(crate) fn dtyp_index(&self) -> u16 {
1569        let dtyp = self.common.dtyp.as_str();
1570        if dtyp.is_empty() {
1571            return 0;
1572        }
1573        // A record type with no device menu has no slot for any DTYP, so the
1574        // index stays 0 — the same answer the old `unwrap_or(&[])` gave.
1575        self.device_choices()
1576            .unwrap_or_default()
1577            .iter()
1578            .position(|c| c.as_str_lossy() == dtyp)
1579            .unwrap_or(0) as u16
1580    }
1581
1582    /// **The** owner of "what string does this enum-valued field render as" —
1583    /// C's `[DBF_*][DBR_STRING]` conversion row, chosen by the field's DBF
1584    /// class. Every path that renders an enum as a string goes through here:
1585    /// the CA/PVA encoders (via [`snapshot::EnumInfo::string_form`] on the
1586    /// snapshot this builds) and the db-link read
1587    /// ([`Self::field_as_dbr_string`]). There is exactly one such table per
1588    /// field, and no path may reconstruct a second one.
1589    ///
1590    /// C's dispatch, and this function's, in the same order:
1591    ///
1592    /// * `DBF_MENU` / `DBF_DEVICE` -> `getMenuString` / `getDeviceString`, the
1593    ///   field's own choice list. Asked FIRST, because a menu field on a record
1594    ///   whose `VAL` is an enum (`bo.OMSL`) must render its menu's choices, not
1595    ///   the record's `ZNAM`/`ONAM`.
1596    /// * `DBF_ENUM` `VAL` -> `getEnumString` -> the record's `get_enum_str`
1597    ///   rset ([`Record::enum_string_form`](super::record_trait::Record::enum_string_form)).
1598    ///
1599    /// `None` when the field has neither — C answers `S_db_noRSET`, an error;
1600    /// the port renders empty.
1601    ///
1602    /// Each class brings its own out-of-range rule with it (see
1603    /// [`EnumOverflow`](crate::server::snapshot::EnumOverflow)); the index is
1604    /// rendered as a number for a `DBF_MENU` and ONLY for a `DBF_MENU`.
1605    pub(crate) fn enum_string_form_for(&self, field: &str) -> Option<EnumStringForm> {
1606        if field.eq_ignore_ascii_case("DTYP") {
1607            // `None` propagates C's `goto nostrs` (`dbAccess.c:178`): a record
1608            // type with no `device()` declaration supplies no choice list, so
1609            // the leaf is omitted rather than marked empty.
1610            return self.device_choices().map(EnumStringForm::device);
1611        }
1612        if let Some(choices) = self.menu_choices_for(field) {
1613            return Some(EnumStringForm::menu(
1614                choices.iter().map(|c| PvString::from(*c)),
1615            ));
1616        }
1617        if field.eq_ignore_ascii_case("VAL") {
1618            return self.record.enum_string_form();
1619        }
1620        None
1621    }
1622
1623    /// Is `field` one of the DBF classes C's soft device support writes as
1624    /// `DBR_STRING`?
1625    ///
1626    /// `devsCalcoutSoft.c:128-130` (and its async twin, :83-85) switches the
1627    /// scalcout OUT put on the TARGET field's DBF type and sends `OSV` — the
1628    /// string result — for seven of them:
1629    ///
1630    /// ```c
1631    /// case DBF_STRING: case DBF_ENUM: case DBF_MENU: case DBF_DEVICE:
1632    /// case DBF_INLINK: case DBF_OUTLINK: case DBF_FWDLINK:
1633    ///     status = dbPutLink(&pscalcout->out, DBR_STRING, &pscalcout->osv, 1);
1634    /// ```
1635    ///
1636    /// [`DbFieldType`] is the port's DBR *wire* type and cannot express
1637    /// `DBF_MENU` / `DBF_DEVICE` — C's DBF class is not the DBR type. The
1638    /// classification therefore lives here, with the record's field metadata,
1639    /// where each class is already known:
1640    ///
1641    /// * `DBF_STRING` and the three link classes — the port stores links and
1642    ///   `DTYP` (C's only `DBF_DEVICE` field) as strings;
1643    /// * `DBF_ENUM` — an enum-typed field;
1644    /// * `DBF_MENU` — a menu-index field, i.e. one this record resolves choice
1645    ///   labels for ([`Self::menu_choices_for`]): `PRIO`, `STAT`, `SEVR`,
1646    ///   `DISS`, `ACKT`, `SCAN`, `IVOA`, `OMSL`, … The index is stored as a
1647    ///   short, so a same-named field that is NOT a menu index (scalcout's
1648    ///   string `OSV` shares a name with the alarm-severity menu) is
1649    ///   classified by its own type, not by the name collision.
1650    ///
1651    /// Everything else (`DBF_DOUBLE`, `DBF_LONG`, `DBF_CHAR`, …) falls to the
1652    /// device support's `default:` arm.
1653    ///
1654    /// The question is about the target field's DECLARED class, so it is asked
1655    /// of the declaration ([`Self::declared_field_type`]) and not of the
1656    /// variant the record stores: C's `switch` is on `dbAddr.field_type`, which
1657    /// `dbNameToAddr` took from the `dbFldDes`. `DBF_MENU` and `DBF_DEVICE` both
1658    /// map to `DbFieldType::Enum` in the generated tables (`mapDBFToDBR`), and
1659    /// the three link classes to `DbFieldType::String`, so the seven C arms are
1660    /// exactly these two.
1661    pub(crate) fn field_puts_as_string(&self, field: &str) -> bool {
1662        let Some(declared) = self.declared_field_type(field) else {
1663            return false;
1664        };
1665        matches!(declared, DbFieldType::String | DbFieldType::Enum)
1666    }
1667
1668    /// The field's value as C `dbGetLink(plink, DBR_STRING, ...)` delivers it —
1669    /// the SOURCE side of an input link read with
1670    /// [`LinkReadAs::String`](super::record_trait::LinkReadAs::String).
1671    ///
1672    /// C converts at the source, through `dbConvert.c`'s
1673    /// `[field_type][DBR_STRING]` table: a `DBF_ENUM` field goes through
1674    /// `getEnumString` → the record's `get_enum_str` (mbbi's `ZRST`.., bi's
1675    /// `ZNAM`/`ONAM`) and a `DBF_MENU` field through `getMenuString` → the
1676    /// menu's choice string, i.e. the state LABEL in both cases, never the
1677    /// index. Only the record holds those tables, so the render lives here with
1678    /// the field metadata — the link-read owner has an index and nothing to
1679    /// resolve it with.
1680    ///
1681    /// The render goes through [`Self::enum_string_form_for`], the same owner
1682    /// the CA/PVA encoders use, so a link read and a `caget -t` of one field can
1683    /// never disagree about its string.
1684    pub(crate) fn field_as_dbr_string(&self, field: &str) -> Option<PvString> {
1685        let value = self.resolve_field(field)?;
1686        // A `DBF_ENUM` index, and a `DBF_MENU` index (stored as a short),
1687        // render through the field's string source. A short field that is
1688        // neither has no source and stays the plain number C converts it to.
1689        let idx = match value {
1690            EpicsValue::Enum(v) => Some(v),
1691            EpicsValue::Short(v) => u16::try_from(v).ok(),
1692            _ => None,
1693        };
1694        if let Some(idx) = idx
1695            && let Some(form) = self.enum_string_form_for(field)
1696        {
1697            return Some(form.render(idx));
1698        }
1699        value_as_dbr_string(&value)
1700    }
1701
1702    /// The field's declaration — its `dbFldDes`, in C's terms.
1703    ///
1704    /// The `.dbd` is the declaration, so the table generated FROM the `.dbd`
1705    /// ([`dbd_generated::record_fields`](super::dbd_generated::record_fields))
1706    /// is asked first, for every record type that has one. A record's own
1707    /// [`Record::field_list`](super::record_trait::Record::field_list) is a
1708    /// hand-written stand-in for that table, and it is consulted only for a
1709    /// record type the `.dbd` does not cover (`subArray`, and the record types
1710    /// the downstream crates add). It cannot be the primary answer: several of
1711    /// those tables are *derived from the record's Rust storage types* — the
1712    /// `#[derive(EpicsRecord)]` records type `longin.ADEL` `DBF_DOUBLE`
1713    /// because the struct member is an `f64`, where the `.dbd` says
1714    /// `DBF_LONG` — and reading the type off the storage is the whole defect
1715    /// this owner exists to close.
1716    ///
1717    /// `dbCommon` last, matching the order [`Self::resolve_field`] reads the
1718    /// value in, so a record-specific field always shadows the common one in
1719    /// both halves.
1720    ///
1721    /// `None` for a field with no declaration at all: a virtual field
1722    /// (`RTYP`, `TIME`, ...), which C answers from dbStaticLib rather than
1723    /// from a `dbFldDes`.
1724    pub(crate) fn field_desc(&self, field: &str) -> Option<&'static FieldDesc> {
1725        field_desc_of(self.record.as_ref(), field)
1726    }
1727
1728    /// The `DBF_*` type `field` is SERVED as — the single source of truth for
1729    /// the type on the wire, on every delivery path.
1730    ///
1731    /// This is the field's DECLARED type ([`FieldDesc::dbf_type`], from the
1732    /// `.dbd`), not the type of whatever variant the record happens to store.
1733    /// C resolves a channel's `field_type` from the `dbFldDes` at
1734    /// name-resolution time (`dbChannelCreate` -> `dbNameToAddr`,
1735    /// `dbAccess.c:184-205`) and every later `dbGet`/`db_post_events` converts
1736    /// the stored bytes to it — the storage is private to the record, the
1737    /// declaration is the contract.
1738    ///
1739    /// Two answers are NOT the declaration:
1740    ///
1741    /// * a [`FieldDesc::runtime_typed`] field — C's `cvt_dbaddr` overwrites
1742    ///   `paddr->field_type` from record state (`FTVL`, `FTA`, `SDEF`), and
1743    ///   this port's `cvt_dbaddr` is the variant the record stores;
1744    /// * a field with no `FieldDesc` at all (a virtual field).
1745    ///
1746    /// In both cases the value's own type is the answer, so this returns
1747    /// `None` and [`Self::project_to_declared_type`] leaves the value alone.
1748    pub fn declared_field_type(&self, field: &str) -> Option<DbFieldType> {
1749        declared_field_type_of(self.record.as_ref(), field)
1750    }
1751
1752    /// Project a field's stored value onto its declared type
1753    /// ([`Self::declared_field_type`]) — the single owner of "what type this
1754    /// field goes on the wire as", run by the CA create-channel path
1755    /// ([`Self::client_field_value`]), the GET path
1756    /// ([`Self::snapshot_for_field`]) and the MONITOR path
1757    /// ([`Self::make_monitor_snapshot`]), so all three announce and serve the
1758    /// same type.
1759    ///
1760    /// The projection is [`EpicsValue::convert_to`], the one value-coercion
1761    /// owner — the same routine `dbGet` converts through. Never re-derive a
1762    /// conversion here: C picks its routine from BOTH the source and the
1763    /// destination type, and only `convert_to` knows that table.
1764    ///
1765    /// Idempotent: a value already of its declared type is short-circuited by
1766    /// `convert_to`, and re-projecting a projected value is a no-op. That is
1767    /// what lets the CA path derive the native type from the value it is about
1768    /// to serve.
1769    pub fn project_to_declared_type(&self, field: &str, value: EpicsValue) -> EpicsValue {
1770        match self.declared_field_type(field) {
1771            Some(declared) => value.convert_to(declared),
1772            None => value,
1773        }
1774    }
1775
1776    /// The client-facing value of `field`: the resolved value projected onto
1777    /// the field's declared type ([`Self::project_to_declared_type`]), so a
1778    /// native type derived from the value — which is what the CA
1779    /// create-channel path does — is the DECLARED type, and matches the
1780    /// GET/MONITOR data byte for byte.
1781    pub fn client_field_value(&self, field: &str) -> Option<EpicsValue> {
1782        let value = self.resolve_field(field)?;
1783        Some(self.project_to_declared_type(field, value))
1784    }
1785
1786    /// Attach a `DBF_MENU` field's `menu()` choice labels to a built snapshot,
1787    /// so the CA/PVA enum encoders present `"NO CONVERSION"` rather than `0`.
1788    ///
1789    /// The VALUE half of the `DBF_MENU` -> `DBR_ENUM` mapping is not here: the
1790    /// `.dbd` declares a menu field `DBF_MENU`, the generator types that
1791    /// `DbFieldType::Enum` (`mapDBFToDBR`), and
1792    /// [`Self::project_to_declared_type`] — which every delivery path runs —
1793    /// makes the served value an [`EpicsValue::Enum`] on that declaration
1794    /// alone. So the label table is all that is left to attach, and it is
1795    /// attached exactly when the served value came out an enum. A same-named
1796    /// field that is NOT a menu index (`scalcout.OSV`, declared `DBF_STRING`,
1797    /// shares a name with the alarm-severity menu) is served as its own
1798    /// declared string and gets no choice table.
1799    fn attach_menu_enum(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
1800        if !matches!(snap.value, EpicsValue::Enum(_)) {
1801            return;
1802        }
1803        // `VAL` is the one field whose two rset slots differ: C's
1804        // `get_enum_strs` (the `DBR_GR_ENUM` labels) is TRIMMED to `no_str`
1805        // while `get_enum_str` (the DBR_STRING form) indexes the untrimmed
1806        // state array. `populate_enum_info` owns that pair; every OTHER
1807        // enum-valued field is a menu or a device, whose one choice list
1808        // answers both (C `getMenuString`/`getDeviceString` index the same
1809        // `papChoiceValue` the GR_ENUM reply carries).
1810        if field.eq_ignore_ascii_case("VAL") {
1811            return;
1812        }
1813        let Some(form) = self.enum_string_form_for(field) else {
1814            return;
1815        };
1816        snap.enums = Some(super::super::snapshot::EnumInfo::with_string_form(
1817            form.slots.clone(),
1818            form,
1819        ));
1820    }
1821
1822    /// Build a Snapshot with full metadata for the given field.
1823    pub fn snapshot_for_field(&self, field: &str) -> Option<super::super::snapshot::Snapshot> {
1824        // The GET path serves the field at its DECLARED type, the same type
1825        // the CA create-channel path announced from `client_field_value` and
1826        // the same one the monitor path posts.
1827        let value = self.client_field_value(field)?;
1828        let mut snap = super::super::snapshot::Snapshot::new(
1829            value,
1830            self.common.stat,
1831            self.common.sevr as u16,
1832            self.common.time,
1833        );
1834        // Default the served `timeStamp.userTag` to the record's `utag`,
1835        // mirroring pvxs `iocsource.cpp:245` (`auto utag = meta.utag;`).
1836        // The 64-bit `epicsUTag` narrows to the int32 NT wire field by
1837        // truncating to the low 32 bits — pvxs assigns the same uint64
1838        // straight into the `Int32` `timeStamp.userTag`. The `Q:time:tag`
1839        // nsec-LSB split below overrides this when configured, matching
1840        // pvxs `if(info.nsecMask) utag = meta.time.nsec & info.nsecMask;`
1841        // (:247).
1842        snap.user_tag = self.common.utag as i32;
1843        // Carry the record's committed alarm message (`common.amsg`) so a
1844        // PVA read serves `alarm.message` from the record's own amsg
1845        // (pvxs `iocsource.cpp:230-236` prefers `meta.amsg`) rather than a
1846        // string re-synthesized from the condition code. Empty for records
1847        // that raise no message (C's plain `recGblSetSevr` clears namsg).
1848        snap.alarm.amsg = self.common.amsg.clone();
1849
1850        // Pull display/control/enums from the metadata cache (build on
1851        // first call, hit thereafter until invalidated by a metadata-class
1852        // field write).
1853        let meta = self.cached_metadata();
1854        snap.display = meta.display;
1855        snap.control = meta.control;
1856        snap.enums = meta.enums;
1857
1858        // The cache above is the record's VAL metadata. C routes PER FIELD, so
1859        // a non-VAL-class field does NOT get VAL's limits — see
1860        // [`Self::route_field_metadata`], which owns that decision.
1861        self.route_field_metadata(field, &mut snap);
1862
1863        // Per-field RSET metadata (C get_units/get_precision/
1864        // get_graphic_double/get_control_double/get_alarm_double key on
1865        // dbGetFieldIndex) patches the record-level cache for this field.
1866        self.apply_field_metadata_override(field, &mut snap);
1867
1868        // DBF_MENU field (a shared menu such as `SCAN`/`OMSL`/`HHSV`/... or
1869        // a record-specific menu such as `sel.SELM`): carry the menu index
1870        // as DBR_ENUM and attach its `menu()` choice labels. See
1871        // `attach_menu_enum`. This overrides any record VAL enum table
1872        // copied from the metadata cache above, because a menu field
1873        // carries its own menu's choices, not the record's VAL state
1874        // strings.
1875        self.attach_menu_enum(field, &mut snap);
1876
1877        // The metadata VALUES and the mask that says which of them this
1878        // channel actually supplies are assigned by the same owner, from the
1879        // settled value, so they cannot disagree.
1880        self.assign_property_support(field, &mut snap);
1881
1882        // apply `info(Q:time:tag, "nsec:lsb:N")` — pvxs
1883        // `iocsource.cpp:239-248` publishes `nanoseconds & ~nsecMask` and
1884        // moves `nanoseconds & nsecMask` into `timeStamp.userTag`. The
1885        // split is applied to both `snap.timestamp` and `snap.user_tag` so
1886        // downstream encoders (NTScalar `timeStamp`, QSRV groups) all see
1887        // the same shape. A zero mask (tag absent or unparseable) is a
1888        // no-op inside the helper, exactly as pvxs's `if(info.nsecMask)`
1889        // gate is.
1890        crate::server::snapshot::apply_nsec_mask(&mut snap, self.qtime_nsec_mask());
1891
1892        Some(snap)
1893    }
1894
1895    /// Resolve `info(Q:time:tag)` to pvxs's `MappingInfo::nsecMask`.
1896    /// Returns 0 (the "no split" mask) when the tag is absent or does not
1897    /// parse — pvxs leaves `nsecMask` at its 0 initialiser in that case.
1898    ///
1899    /// pvxs `ioc/typeutils.cpp:79-88`:
1900    ///
1901    /// ```c
1902    /// if(auto val = ent.info("Q:time:tag")) {
1903    ///     epicsInt32 dig = 0;
1904    ///     if(strncmp(val, "nsec:lsb:", 9)==0 && !epicsParseInt32(&val[9], &dig, 10, nullptr)) {
1905    ///         nsecMask = (uint64_t(1u)<<dig)-1u;
1906    ///     }
1907    /// }
1908    /// ```
1909    ///
1910    /// The prefix test is a byte-exact `strncmp` — no case folding and no
1911    /// whitespace tolerance, so `NSEC:LSB:4` and `nsec: lsb: 4` do NOT
1912    /// match and leave the timestamp alone. There is no bounds clamp
1913    /// either: any `dig` `epicsParseInt32` accepts is shifted verbatim, so
1914    /// `nsec:lsb:31` yields the `0x7FFF_FFFF` mask pvxs actually serves.
1915    fn qtime_nsec_mask(&self) -> u64 {
1916        let Some(rest) = self
1917            .get_info("Q:time:tag")
1918            .and_then(|v| v.strip_prefix("nsec:lsb:"))
1919        else {
1920            return 0;
1921        };
1922        let Some(dig) = epics_parse_int32_base10(rest) else {
1923            return 0;
1924        };
1925        // C shifts `uint64_t(1u)` by an `epicsInt32`. A `dig` outside
1926        // `0..=63` is UB in C++; every ISA EPICS builds on (x86-64 `shlq`,
1927        // aarch64 `lsl`) takes the shift count modulo 64, which is what
1928        // `wrapping_shl` does — so `nsec:lsb:64` disables the split
1929        // (mask 0) and a negative `dig` shifts by `dig & 63`, the same
1930        // masks pvxs produces on those hosts.
1931        1u64.wrapping_shl(dig as u32) - 1
1932    }
1933
1934    /// Populate DisplayInfo from record fields if applicable.
1935    /// Resolve the `Q:form` info-tag value to a `display.form` menu index.
1936    ///
1937    /// pvxs publishes the fixed seven-entry form menu
1938    /// (Default/String/Binary/Decimal/Hex/Exponential/Engineering) for every
1939    /// numeric value and, for the VAL field only, sets `display.form.index`
1940    /// to the slot whose name equals the field's `Q:form` info tag
1941    /// (`iocsource.cpp:42-62`, case-sensitive). Unset or unrecognised ->
1942    /// `None` (form stays 0 = Default), exactly as pvxs leaves the index
1943    /// untouched on no match.
1944    fn q_form_index(&self) -> Option<i16> {
1945        const FORM_NAMES: [&str; 7] = [
1946            "Default",
1947            "String",
1948            "Binary",
1949            "Decimal",
1950            "Hex",
1951            "Exponential",
1952            "Engineering",
1953        ];
1954        let tag = self.info.get("Q:form")?;
1955        FORM_NAMES
1956            .iter()
1957            .position(|name| name == tag)
1958            .map(|i| i as i16)
1959    }
1960
1961    /// Stamp a built snapshot with the property mask THIS channel supplies —
1962    /// [`Self::property_support`] narrowed to the addressed field by C's
1963    /// second gate ([`PropertySupport::narrowed_to_field`]). Called by both
1964    /// snapshot builders once the value has settled (after
1965    /// [`Self::attach_menu_enum`] promoted a `DBF_MENU` field to its
1966    /// `DBR_ENUM` form), so the mask is read off the same value the client
1967    /// receives and no consumer has to re-derive either gate.
1968    fn assign_property_support(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
1969        snap.properties = self.record.property_support().narrowed_to_field(
1970            snap.value.db_field_type(),
1971            self.menu_choices_for(field).is_some(),
1972        );
1973    }
1974
1975    /// The property mask a channel on `field` supplies, without building a
1976    /// snapshot — what a PVA server needs to decide which NT leaves it may
1977    /// MARK for a channel it has not read yet (QSRV resolves a group's member
1978    /// masks once, at monitor start, rather than per event).
1979    ///
1980    /// Same two gates, same owner as `Self::assign_property_support`: an
1981    /// unknown field supplies nothing.
1982    pub fn property_support_for_field(&self, field: &str) -> PropertySupport {
1983        let Some(value) = self.client_field_value(field) else {
1984            return PropertySupport::NONE;
1985        };
1986        self.record.property_support().narrowed_to_field(
1987            value.db_field_type(),
1988            self.menu_choices_for(field).is_some(),
1989        )
1990    }
1991
1992    fn populate_display_info(&self, snap: &mut super::super::snapshot::Snapshot) {
1993        let rtype = self.record.record_type();
1994        match rtype {
1995            "ai" | "ao" | "calc" | "calcout" => {
1996                let egu = self
1997                    .record
1998                    .get_field("EGU")
1999                    .and_then(|v| {
2000                        if let EpicsValue::String(s) = v {
2001                            Some(s)
2002                        } else {
2003                            None
2004                        }
2005                    })
2006                    .unwrap_or_default();
2007                let prec = self
2008                    .record
2009                    .get_field("PREC")
2010                    .and_then(|v| v.to_f64())
2011                    .unwrap_or(0.0) as i16;
2012                let hopr = self
2013                    .record
2014                    .get_field("HOPR")
2015                    .and_then(|v| v.to_f64())
2016                    .unwrap_or(0.0);
2017                let lopr = self
2018                    .record
2019                    .get_field("LOPR")
2020                    .and_then(|v| v.to_f64())
2021                    .unwrap_or(0.0);
2022                snap.display = Some(super::super::snapshot::DisplayInfo {
2023                    units: egu,
2024                    precision: prec,
2025                    upper_disp_limit: hopr,
2026                    lower_disp_limit: lopr,
2027                    ..Default::default()
2028                });
2029            }
2030            "longin" | "longout" | "int64in" | "int64out" => {
2031                let egu = self
2032                    .record
2033                    .get_field("EGU")
2034                    .and_then(|v| {
2035                        if let EpicsValue::String(s) = v {
2036                            Some(s)
2037                        } else {
2038                            None
2039                        }
2040                    })
2041                    .unwrap_or_default();
2042                let hopr = self
2043                    .record
2044                    .get_field("HOPR")
2045                    .and_then(|v| v.to_f64())
2046                    .unwrap_or(0.0);
2047                let lopr = self
2048                    .record
2049                    .get_field("LOPR")
2050                    .and_then(|v| v.to_f64())
2051                    .unwrap_or(0.0);
2052                snap.display = Some(super::super::snapshot::DisplayInfo {
2053                    units: egu,
2054                    precision: 0,
2055                    upper_disp_limit: hopr,
2056                    lower_disp_limit: lopr,
2057                    ..Default::default()
2058                });
2059            }
2060            // waveform/aai/aao — HOPR/LOPR/PREC/EGU for VAL display limits.
2061            // (waveformRecord.c:251-252,239; aaiRecord.c:280-281,268; aaoRecord.c:283-284)
2062            "waveform" | "aai" | "aao" => {
2063                let egu = self
2064                    .record
2065                    .get_field("EGU")
2066                    .and_then(|v| {
2067                        if let EpicsValue::String(s) = v {
2068                            Some(s)
2069                        } else {
2070                            None
2071                        }
2072                    })
2073                    .unwrap_or_default();
2074                let prec = self
2075                    .record
2076                    .get_field("PREC")
2077                    .and_then(|v| v.to_f64())
2078                    .unwrap_or(0.0) as i16;
2079                let hopr = self
2080                    .record
2081                    .get_field("HOPR")
2082                    .and_then(|v| v.to_f64())
2083                    .unwrap_or(0.0);
2084                let lopr = self
2085                    .record
2086                    .get_field("LOPR")
2087                    .and_then(|v| v.to_f64())
2088                    .unwrap_or(0.0);
2089                snap.display = Some(super::super::snapshot::DisplayInfo {
2090                    units: egu,
2091                    precision: prec,
2092                    upper_disp_limit: hopr,
2093                    lower_disp_limit: lopr,
2094                    ..Default::default()
2095                });
2096            }
2097            // compress — HOPR/LOPR/PREC/EGU for VAL display limits.
2098            // (compressRecord.c:478-479,464,455)
2099            "compress" => {
2100                let egu = self
2101                    .record
2102                    .get_field("EGU")
2103                    .and_then(|v| {
2104                        if let EpicsValue::String(s) = v {
2105                            Some(s)
2106                        } else {
2107                            None
2108                        }
2109                    })
2110                    .unwrap_or_default();
2111                let prec = self
2112                    .record
2113                    .get_field("PREC")
2114                    .and_then(|v| v.to_f64())
2115                    .unwrap_or(0.0) as i16;
2116                let hopr = self
2117                    .record
2118                    .get_field("HOPR")
2119                    .and_then(|v| v.to_f64())
2120                    .unwrap_or(0.0);
2121                let lopr = self
2122                    .record
2123                    .get_field("LOPR")
2124                    .and_then(|v| v.to_f64())
2125                    .unwrap_or(0.0);
2126                snap.display = Some(super::super::snapshot::DisplayInfo {
2127                    units: egu,
2128                    precision: prec,
2129                    upper_disp_limit: hopr,
2130                    lower_disp_limit: lopr,
2131                    ..Default::default()
2132                });
2133            }
2134            "motor" => {
2135                let egu = self
2136                    .record
2137                    .get_field("EGU")
2138                    .and_then(|v| {
2139                        if let EpicsValue::String(s) = v {
2140                            Some(s)
2141                        } else {
2142                            None
2143                        }
2144                    })
2145                    .unwrap_or_default();
2146                let prec = self
2147                    .record
2148                    .get_field("PREC")
2149                    .and_then(|v| v.to_f64())
2150                    .unwrap_or(0.0) as i16;
2151                let hlm = self
2152                    .record
2153                    .get_field("HLM")
2154                    .and_then(|v| v.to_f64())
2155                    .unwrap_or(0.0);
2156                let llm = self
2157                    .record
2158                    .get_field("LLM")
2159                    .and_then(|v| v.to_f64())
2160                    .unwrap_or(0.0);
2161                snap.display = Some(super::super::snapshot::DisplayInfo {
2162                    units: egu,
2163                    precision: prec,
2164                    upper_disp_limit: hlm,
2165                    lower_disp_limit: llm,
2166                    ..Default::default()
2167                });
2168            }
2169            _ => {}
2170        }
2171        // Apply the `Q:form` display-format hint. The match above builds
2172        // `snap.display` only for numeric record types — the same set for
2173        // which pvxs emits `display.form.choices`. This cache is
2174        // record-level (it is the VAL field's metadata); the VAL-only rule
2175        // pvxs applies to `display.form.index` (`iocsource.cpp:53`) is
2176        // enforced per served field in `apply_field_metadata_override`.
2177        if let Some(display) = snap.display.as_mut() {
2178            if let Some(form) = self.q_form_index() {
2179                display.form = form;
2180            }
2181        }
2182    }
2183
2184    /// Populate ControlInfo from record fields if applicable.
2185    fn populate_control_info(&self, snap: &mut super::super::snapshot::Snapshot) {
2186        let rtype = self.record.record_type();
2187        match rtype {
2188            // ao unconditionally uses DRVH/DRVL (aoRecord.c:356-357).
2189            "ao" => {
2190                let upper = self
2191                    .record
2192                    .get_field("DRVH")
2193                    .and_then(|v| v.to_f64())
2194                    .unwrap_or(0.0);
2195                let lower = self
2196                    .record
2197                    .get_field("DRVL")
2198                    .and_then(|v| v.to_f64())
2199                    .unwrap_or(0.0);
2200                snap.control = Some(super::super::snapshot::ControlInfo {
2201                    upper_ctrl_limit: upper,
2202                    lower_ctrl_limit: lower,
2203                });
2204            }
2205            // longout/int64out use DRVH/DRVL only when drvh > drvl, else HOPR/LOPR
2206            // (longoutRecord.c:282-287, int64outRecord.c:265-270).
2207            "longout" | "int64out" => {
2208                let drvh = self
2209                    .record
2210                    .get_field("DRVH")
2211                    .and_then(|v| v.to_f64())
2212                    .unwrap_or(0.0);
2213                let drvl = self
2214                    .record
2215                    .get_field("DRVL")
2216                    .and_then(|v| v.to_f64())
2217                    .unwrap_or(0.0);
2218                let (upper, lower) = if drvh > drvl {
2219                    (drvh, drvl)
2220                } else {
2221                    let hopr = self
2222                        .record
2223                        .get_field("HOPR")
2224                        .and_then(|v| v.to_f64())
2225                        .unwrap_or(0.0);
2226                    let lopr = self
2227                        .record
2228                        .get_field("LOPR")
2229                        .and_then(|v| v.to_f64())
2230                        .unwrap_or(0.0);
2231                    (hopr, lopr)
2232                };
2233                snap.control = Some(super::super::snapshot::ControlInfo {
2234                    upper_ctrl_limit: upper,
2235                    lower_ctrl_limit: lower,
2236                });
2237            }
2238            "motor" => {
2239                // Motor records use HLM/LLM as control limits
2240                let hlm = self
2241                    .record
2242                    .get_field("HLM")
2243                    .and_then(|v| v.to_f64())
2244                    .unwrap_or(0.0);
2245                let llm = self
2246                    .record
2247                    .get_field("LLM")
2248                    .and_then(|v| v.to_f64())
2249                    .unwrap_or(0.0);
2250                snap.control = Some(super::super::snapshot::ControlInfo {
2251                    upper_ctrl_limit: hlm,
2252                    lower_ctrl_limit: llm,
2253                });
2254            }
2255            // int64in uses HOPR/LOPR as control limits (int64inRecord.c:226-227)
2256            "ai" | "int64in" | "longin" | "calc" | "calcout" => {
2257                // Input records use HOPR/LOPR as control limits
2258                let hopr = self
2259                    .record
2260                    .get_field("HOPR")
2261                    .and_then(|v| v.to_f64())
2262                    .unwrap_or(0.0);
2263                let lopr = self
2264                    .record
2265                    .get_field("LOPR")
2266                    .and_then(|v| v.to_f64())
2267                    .unwrap_or(0.0);
2268                snap.control = Some(super::super::snapshot::ControlInfo {
2269                    upper_ctrl_limit: hopr,
2270                    lower_ctrl_limit: lopr,
2271                });
2272            }
2273            // Array records map their VAL control limits to HOPR/LOPR, exactly
2274            // like the display limits above (waveformRecord.c get_control_double
2275            // VAL case; aaiRecord.c:293-303; aaoRecord.c; compressRecord.c:487-501).
2276            // Without this arm an array DBR_CTRL collapses the control range to
2277            // 0/0 while the scalar records expose it.
2278            "waveform" | "aai" | "aao" | "compress" => {
2279                let hopr = self
2280                    .record
2281                    .get_field("HOPR")
2282                    .and_then(|v| v.to_f64())
2283                    .unwrap_or(0.0);
2284                let lopr = self
2285                    .record
2286                    .get_field("LOPR")
2287                    .and_then(|v| v.to_f64())
2288                    .unwrap_or(0.0);
2289                snap.control = Some(super::super::snapshot::ControlInfo {
2290                    upper_ctrl_limit: hopr,
2291                    lower_ctrl_limit: lopr,
2292                });
2293            }
2294            _ => {}
2295        }
2296    }
2297
2298    /// Populate EnumInfo — C rset `get_enum_strs`.
2299    ///
2300    /// The table comes from [`Record::enum_state_strings`], the SAME slot the
2301    /// string-put converter (`dbConvert.c::putStringEnum`) resolves against, so
2302    /// the choice list a client reads and the names it may write are one table
2303    /// by construction. It arrives already trimmed to C's `no_str` (bi/bo/busy
2304    /// drop an empty ONAM behind a set ZNAM — `boRecord.c:342-352`; mbbi/mbbo
2305    /// cut at the last non-empty state — `mbbiRecord.c:262-269`).
2306    ///
2307    /// The `DBR_STRING` half of the same channel is the record's OTHER rset slot
2308    /// (`get_enum_str`), which is not this trimmed list — see
2309    /// [`EnumStringForm`]. A record that has no such slot (every record but
2310    /// bi/bo/busy/mbbi/mbbo, including the downstream crates' own enum records)
2311    /// renders from its label list, which is what C's absent slot amounts to.
2312    fn populate_enum_info(&self, snap: &mut super::super::snapshot::Snapshot) {
2313        if let Some(strings) = self.record.enum_state_strings() {
2314            snap.enums = Some(match self.record.enum_string_form() {
2315                Some(form) => super::super::snapshot::EnumInfo::with_string_form(strings, form),
2316                None => super::super::snapshot::EnumInfo::new(strings),
2317            });
2318        }
2319    }
2320
2321    /// Get a common field value.
2322    pub fn get_common_field(&self, name: &str) -> Option<EpicsValue> {
2323        match name {
2324            "SEVR" => Some(EpicsValue::Short(self.common.sevr as i16)),
2325            "STAT" => Some(EpicsValue::Short(self.common.stat as i16)),
2326            "NSEV" => Some(EpicsValue::Short(self.common.nsev as i16)),
2327            "NSTA" => Some(EpicsValue::Short(self.common.nsta as i16)),
2328            // epics-base PR #568 / #566 — alarm message string.
2329            "AMSG" => Some(EpicsValue::String(self.common.amsg.clone().into())),
2330            "NAMSG" => Some(EpicsValue::String(self.common.namsg.clone().into())),
2331            "ACKS" => Some(EpicsValue::Short(self.common.acks as i16)),
2332            // `ACKT` and `PINI` are `DBF_MENU` (`menuYesNo` /`menuPini`,
2333            // `dbCommon.dbd.pod:335,169`), not `DBF_UCHAR`: they carry a menu
2334            // index, which `promote_menu_value` lifts to `DBR_ENUM` with the
2335            // menu's choice strings. Storing them as `Short` is what makes
2336            // them eligible for that promotion — see `promote_menu_value`.
2337            "ACKT" => Some(EpicsValue::Short(if self.common.ackt { 1 } else { 0 })),
2338            // DBF_UCHAR: served as UChar (declared type) so the raw put byte
2339            // round-trips to the wire — see the DISP/TPRO comment above.
2340            "UDF" => Some(EpicsValue::UChar(self.common.udf)),
2341            "UDFS" => Some(EpicsValue::Short(self.common.udfs)),
2342            "SCAN" => Some(EpicsValue::Enum(self.common.scan.to_u16())),
2343            "SSCN" => Some(EpicsValue::Enum(self.common.sscn.to_u16())),
2344            // `OLDSIMM` is `DBF_MENU`/`menu(menuSimm)`, stored as the menu index
2345            // and promoted to `DBR_ENUM` with the NO/YES/RAW labels by
2346            // `promote_menu_value` (shared registry — the saved copy is ALWAYS
2347            // menuSimm, unlike the live SIMM). Written only by the simulation
2348            // owner (`rec_gbl_save_simm`); `special(SPC_NOMOD)` for clients.
2349            "OLDSIMM" => Some(EpicsValue::Short(self.common.oldsimm)),
2350            "PINI" => Some(EpicsValue::Short(self.common.pini)),
2351            // DISP/TPRO/RPRO/UDF are `DBF_UCHAR` in `dbCommon.dbd`. Serve them
2352            // as `UChar` — their DECLARED type — so `project_to_declared_type`
2353            // is identity and the raw put byte reaches the wire untouched (C
2354            // stores the byte and `caget` renders `DBR_CHAR` signed: 255 → -1).
2355            // Serving `Char` here instead routed the value through the lossy
2356            // `Char → UChar` projection (signed −1 clamped to 0), so a
2357            // `caput DISP 255` read back as 0 rather than C's -1. `BKPT` is
2358            // `DBF_NOACCESS`: no `FieldDesc`, no projection, served `Char`.
2359            "TPRO" => Some(EpicsValue::UChar(self.common.tpro)),
2360            "BKPT" => Some(EpicsValue::Char(self.common.bkpt)),
2361            "FLNK" => Some(EpicsValue::String(self.common.flnk.clone().into())),
2362            // A record type whose C `.dbd` has no INP has no `.INP` channel
2363            // either — C's dbChannel resolution is the dbd, so `dbgf HI.INP` on
2364            // a histogram answers "PV 'HI.INP' not found". The port keeps INP on
2365            // `CommonFields` for every record, so `declares_inp_link()` is what
2366            // stands in for the dbd, and it must gate the read side as well as
2367            // the write side (`put_common_field`) — otherwise the field is
2368            // unloadable and unwritable yet still resolves as a channel.
2369            "INP" if self.record.declares_inp_link() => {
2370                Some(EpicsValue::String(self.common.inp.clone().into()))
2371            }
2372            "OUT" => Some(EpicsValue::String(self.common.out.clone().into())),
2373            // C's DTYP is `DBF_DEVICE`: an epicsEnum16 index into the record
2374            // type's device menu, NOT the name. The name is what this port
2375            // stores and dispatches on; the index is what the wire carries.
2376            "DTYP" => Some(EpicsValue::Enum(self.dtyp_index())),
2377            "TSE" => Some(EpicsValue::Short(self.common.tse)),
2378            "TSEL" => Some(EpicsValue::String(self.common.tsel.clone().into())),
2379            // C `UTAG` is DBF_UINT64 — exposed natively as the unsigned
2380            // 64-bit value variant so values above i64::MAX round-trip.
2381            "UTAG" => Some(EpicsValue::UInt64(self.common.utag)),
2382            "ASG" => Some(EpicsValue::String(self.common.asg.clone().into())),
2383            "ASL" => Some(EpicsValue::Char(self.common.asl)),
2384            "DESC" => Some(EpicsValue::String(self.common.desc.clone())),
2385            "PHAS" => Some(EpicsValue::Short(self.common.phas)),
2386            "EVNT" => Some(EpicsValue::String(self.common.evnt.clone().into())),
2387            "PRIO" => Some(EpicsValue::Short(self.common.prio)),
2388            "DISV" => Some(EpicsValue::Short(self.common.disv)),
2389            "DISA" => Some(EpicsValue::Short(self.common.disa)),
2390            "SDIS" => Some(EpicsValue::String(self.common.sdis.clone().into())),
2391            "DISS" => Some(EpicsValue::Short(self.common.diss)),
2392            "HYST" => Some(EpicsValue::Double(self.common.hyst)),
2393            "LCNT" => Some(EpicsValue::Short(self.common.lcnt)),
2394            "DISP" => Some(EpicsValue::UChar(self.common.disp)),
2395            "PUTF" => Some(EpicsValue::Char(if self.common.putf { 1 } else { 0 })),
2396            "RPRO" => Some(EpicsValue::UChar(self.common.rpro)),
2397            "PACT" => Some(EpicsValue::Char(if self.is_processing() { 1 } else { 0 })),
2398            // C `dbCommon.dbd`: `field(PROC,DBF_UCHAR)` — the raw put byte is
2399            // retained in `prec->proc` and served back SIGNED as `DBR_CHAR`
2400            // (`caput PROC 255` → `caget` = -1), exactly like DISP/RPRO. The
2401            // `pp(TRUE)` force-process is orthogonal: writing PROC still
2402            // reprocesses the record (put-path intercept), but the byte sticks.
2403            "PROC" => Some(EpicsValue::UChar(self.common.proc_field)),
2404            // Analog alarm fields
2405            "HIHI" => self
2406                .common
2407                .analog_alarm
2408                .as_ref()
2409                .map(|a| EpicsValue::Double(a.hihi)),
2410            "HIGH" => self
2411                .common
2412                .analog_alarm
2413                .as_ref()
2414                .map(|a| EpicsValue::Double(a.high)),
2415            "LOW" => self
2416                .common
2417                .analog_alarm
2418                .as_ref()
2419                .map(|a| EpicsValue::Double(a.low)),
2420            "LOLO" => self
2421                .common
2422                .analog_alarm
2423                .as_ref()
2424                .map(|a| EpicsValue::Double(a.lolo)),
2425            "HHSV" => self
2426                .common
2427                .analog_alarm
2428                .as_ref()
2429                .map(|a| EpicsValue::Short(a.hhsv)),
2430            "HSV" => self
2431                .common
2432                .analog_alarm
2433                .as_ref()
2434                .map(|a| EpicsValue::Short(a.hsv)),
2435            "LSV" => self
2436                .common
2437                .analog_alarm
2438                .as_ref()
2439                .map(|a| EpicsValue::Short(a.lsv)),
2440            "LLSV" => self
2441                .common
2442                .analog_alarm
2443                .as_ref()
2444                .map(|a| EpicsValue::Short(a.llsv)),
2445            // swait OUTN is aliased to common.out
2446            "OUTN" => {
2447                if self.record.record_type() == "swait" {
2448                    Some(EpicsValue::String(self.common.out.clone().into()))
2449                } else {
2450                    None
2451                }
2452            }
2453            _ => None,
2454        }
2455    }
2456
2457    /// `true` when the record type declares `name` in its own `field_list`,
2458    /// i.e. the record stores the field itself and owns whatever behaviour
2459    /// hangs off it.
2460    ///
2461    /// This separates the two meanings a link field carries. `common.inp` /
2462    /// `common.out` is the link *text* — always the value the `.db` file
2463    /// wrote, for every record type, because C device support reads
2464    /// `prec->inp` / `prec->out` at `init_record` no matter which layer owns
2465    /// the field ([`crate::server::db_loader::apply_fields`] keeps it
2466    /// populated). `parsed_inp` / `parsed_out` is the *framework's* dispatch
2467    /// of that link, and is armed only for a record type that does NOT
2468    /// declare the field: a record that declares it drives the link itself
2469    /// (`multi_output_links` for `acalcout`/`scalcout`, device support for
2470    /// `motorRecord`/`scalerRecord`, or its own `process`). Arming the
2471    /// framework path for those too would write the link twice per cycle.
2472    fn record_declares_field(&self, name: &str) -> bool {
2473        self.record.implements_field(name)
2474    }
2475
2476    /// Set a common field value from a runtime `dbPut` (CA/PVA/`dbpf`/link).
2477    /// Returns what scan index changes are needed.
2478    ///
2479    /// A `DBF_MENU` common field's string is converted by C's runtime
2480    /// converter, `dbConvert.c::putStringMenu` — see `MenuBound::DbPut`.
2481    pub fn put_common_field(
2482        &mut self,
2483        name: &str,
2484        value: EpicsValue,
2485    ) -> CaResult<CommonFieldPutResult> {
2486        self.put_common_field_bounded(name, value, MenuBound::DbPut)
2487    }
2488
2489    /// **The single owner of a record's SCAN transition** — C `dbPutField` on
2490    /// SCAN, which is `scanDelete(precord)` … `scanAdd(precord)`
2491    /// (`dbAccess.c::dbPutSpecial` SPC_SCAN, dbScan.c:236-248).
2492    ///
2493    /// Two callers reach it, and they are the two C sites that move a record
2494    /// between scan lists: a `SCAN` put ([`Self::put_common_field`]) and the
2495    /// simulation-mode scan swap (`recGblCheckSimm`, recGbl.c:427-437, which
2496    /// calls exactly the same `scanDelete`/`scanAdd` pair). Returns the delta
2497    /// for the scan-index owner (`PvDatabase::update_scan_index`) to apply once
2498    /// the record lock is down; [`CommonFieldPutResult::NoChange`] when the scan
2499    /// did not move.
2500    pub fn set_scan(&mut self, new_scan: ScanType) -> CommonFieldPutResult {
2501        let old_scan = self.common.scan;
2502        self.common.scan = new_scan;
2503        if old_scan == new_scan {
2504            return CommonFieldPutResult::NoChange;
2505        }
2506        // C `scanDelete`/`scanAdd` call the record's device support
2507        // `get_ioint_info(1)` / `get_ioint_info(0)`. Only a change of I/O Intr
2508        // *membership* reaches those; a Passive→"1 second" move calls neither.
2509        let was_io_intr = old_scan == ScanType::IoIntr;
2510        let is_io_intr = new_scan == ScanType::IoIntr;
2511        if was_io_intr != is_io_intr {
2512            self.record.set_io_intr_scan(is_io_intr);
2513        }
2514        CommonFieldPutResult::ScanChanged {
2515            old_scan,
2516            new_scan,
2517            phas: self.common.phas,
2518        }
2519    }
2520
2521    /// C `recGblSaveSimm` (`recGbl.c:421-425`) — latch the CURRENT simulation
2522    /// mode into OLDSIMM:
2523    ///
2524    /// ```c
2525    /// void recGblSaveSimm(const epicsEnum16 sscn,
2526    ///     epicsEnum16 *poldsimm, const epicsEnum16 simm) {
2527    ///     if (sscn == USHRT_MAX) return;
2528    ///     *poldsimm = simm;
2529    /// }
2530    /// ```
2531    ///
2532    /// **The only writer of `CommonFields::oldsimm`.** Must run BEFORE the SIMM
2533    /// value moves — C calls it from `special(SPC_MOD)` pass 0 (before the put)
2534    /// and from `recGblGetSimm`/`recGblInitSimm` before the SIML read. The
2535    /// `sscn == 65535` guard is C's: with SSCN unset there is no scan to swap
2536    /// to, so the latch is not even taken (and [`Self::rec_gbl_check_simm`]
2537    /// bails on the same test, so the stale OLDSIMM is never read).
2538    ///
2539    /// A record type with no SSCN/OLDSIMM in its C dbd (`busy`, `swait`) passes
2540    /// neither pointer to any recGbl helper: no-op here.
2541    pub fn rec_gbl_save_simm(&mut self) {
2542        if !self.record.uses_recgbl_simm_helpers() {
2543            return;
2544        }
2545        // C `recGblSaveSimm`: `if (*psscn == USHRT_MAX) return;` — the literal
2546        // sentinel, not "any index outside the menu".
2547        if self.common.sscn.is_unset() {
2548            return;
2549        }
2550        if let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") {
2551            self.common.oldsimm = simm;
2552        }
2553    }
2554
2555    /// C `recGblCheckSimm` (`recGbl.c:427-437`) — on a SIMM transition, swap the
2556    /// record's SCAN with SSCN:
2557    ///
2558    /// ```c
2559    /// void recGblCheckSimm(struct dbCommon *pcommon, epicsEnum16 *psscn,
2560    ///     const epicsEnum16 oldsimm, const epicsEnum16 simm) {
2561    ///     if (*psscn == USHRT_MAX) return;
2562    ///     if (simm != oldsimm) {
2563    ///         epicsUInt16 scan = pcommon->scan;
2564    ///         scanDelete(pcommon);
2565    ///         pcommon->scan = *psscn;
2566    ///         scanAdd(pcommon);
2567    ///         *psscn = scan;
2568    ///     }
2569    /// }
2570    /// ```
2571    ///
2572    /// This is what makes SSCN mean anything at all: a record configured
2573    /// `field(SCAN,"1 second") field(SSCN,"Passive")` stops periodic scanning
2574    /// the moment SIMM leaves NO, and resumes it when SIMM goes back — with the
2575    /// two fields having traded places each time. Both are a genuine swap, not
2576    /// an assignment: SSCN ends up holding the scan the record just left.
2577    ///
2578    /// **The only writer of the SIMM-driven SCAN/SSCN swap.** The scan-list
2579    /// move itself goes through the single SCAN owner [`Self::set_scan`], whose
2580    /// [`CommonFieldPutResult`] the caller hands to
2581    /// `PvDatabase::update_scan_index` once the record lock is down. Runs AFTER
2582    /// the SIMM value moved — C `special(SPC_MOD)` pass 1, and the tail of
2583    /// `recGblGetSimm`/`recGblInitSimm`.
2584    pub fn rec_gbl_check_simm(&mut self) -> CommonFieldPutResult {
2585        if !self.record.uses_recgbl_simm_helpers() {
2586            return CommonFieldPutResult::NoChange;
2587        }
2588        let Some(sim_scan) = self.common.sscn.scan() else {
2589            // `*psscn == USHRT_MAX` — SSCN unset, no swap. An SSCN that is
2590            // merely ILLEGAL still swaps: C assigns it into SCAN and `scanAdd`
2591            // then declines to scan the record.
2592            return CommonFieldPutResult::NoChange;
2593        };
2594        let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") else {
2595            return CommonFieldPutResult::NoChange;
2596        };
2597        if simm == self.common.oldsimm {
2598            return CommonFieldPutResult::NoChange;
2599        }
2600        let previous_scan = self.common.scan;
2601        let result = self.set_scan(sim_scan);
2602        self.common.sscn = SimModeScan::from_scan(previous_scan);
2603        result
2604    }
2605
2606    /// C `dbAccess.c::putAckt` (`:1285-1300`) — the **only** writer of ACKT.
2607    ///
2608    /// Reached from `dbPut` for a `DBR_PUT_ACKT` request *type*
2609    /// (`dbAccess.c:1331-1332`), ABOVE the `SPC_NOMOD` gate that refuses every
2610    /// ordinary put to the field. Posts exactly what C posts: the ACKT change,
2611    /// the ACKS it may lower, and the record-wide `DBE_ALARM` — and only when
2612    /// `ackt` actually changed (C returns 0 early otherwise).
2613    pub fn put_ackt(&mut self, value: u16) {
2614        let new_ackt = value != 0;
2615        if new_ackt == self.common.ackt {
2616            return;
2617        }
2618        use crate::server::recgbl::EventMask;
2619        let ack_mask = EventMask::VALUE | EventMask::ALARM;
2620        self.common.ackt = new_ackt;
2621        self.cleanup_subscribers();
2622        self.notify_field("ACKT", ack_mask);
2623        // C `:1294-1297`: turning transient acknowledgement off lowers a
2624        // sticky ACKS down to the current SEVR — an alarm that has already
2625        // cleared must not keep a higher unacknowledged severity.
2626        if !new_ackt && self.common.acks > self.common.sevr {
2627            self.common.acks = self.common.sevr;
2628            self.notify_field("ACKS", ack_mask);
2629        }
2630        self.notify_record_alarm();
2631    }
2632
2633    /// C `dbAccess.c::putAcks` (`:1302-1315`) — the **only** runtime writer of
2634    /// ACKS. Reached from `dbPut` for a `DBR_PUT_ACKS` request type, ABOVE the
2635    /// `SPC_NOMOD` gate.
2636    ///
2637    /// The acknowledged severity is compared against the STORED unacknowledged
2638    /// severity `acks`, not the current `sevr`: an operator acknowledging at
2639    /// the severity that was latched into ACKS clears it even after `sevr` has
2640    /// since dropped. A too-low acknowledgement changes nothing and posts
2641    /// nothing; an acknowledgement of an already-clear ACKS still posts, which
2642    /// is C's literal `if (*psev >= precord->acks)` (0 >= 0 holds).
2643    pub fn put_acks(&mut self, value: u16) {
2644        let sev = AlarmSeverity::from_u16(value);
2645        if sev < self.common.acks {
2646            return;
2647        }
2648        use crate::server::recgbl::EventMask;
2649        self.common.acks = AlarmSeverity::NoAlarm;
2650        self.cleanup_subscribers();
2651        self.notify_field("ACKS", EventMask::VALUE | EventMask::ALARM);
2652        self.notify_record_alarm();
2653    }
2654
2655    /// Set a common field value from the `.db` loader, which in C is a
2656    /// different converter with a different out-of-menu bound
2657    /// (`dbStaticRun.c::dbPutStringNum`; see `MenuBound::DbLoad`). It is what
2658    /// lets `field(SSCN,"65535")` — the menuScan "use SCAN" sentinel, out of
2659    /// the menu's 0-9 range — load, while `caput REC.SSCN 65535` is refused at
2660    /// runtime exactly as C refuses it.
2661    pub fn put_common_field_db_load(
2662        &mut self,
2663        name: &str,
2664        value: EpicsValue,
2665    ) -> CaResult<CommonFieldPutResult> {
2666        self.put_common_field_bounded(name, value, MenuBound::DbLoad)
2667    }
2668
2669    fn put_common_field_bounded(
2670        &mut self,
2671        name: &str,
2672        value: EpicsValue,
2673        bound: MenuBound,
2674    ) -> CaResult<CommonFieldPutResult> {
2675        let name = name.to_ascii_uppercase();
2676        self.record.validate_put(&name, &value)?;
2677        self.record.special(&name, false)?;
2678        // The db loader hands every common field to this path as a raw
2679        // `EpicsValue::String` (no per-field `FieldDesc` to parse against).
2680        // Coerce it to the field's canonical numeric/menu type up front so the
2681        // typed arms below apply a `field(PHAS, "1")` / `field(PRIO, "HIGH")`
2682        // directive instead of silently dropping it at IOC load. String-typed
2683        // and already-typed values pass through unchanged.
2684        let value = coerce_common_field(&name, value, bound)?;
2685        match name.as_str() {
2686            // `special(SPC_NOMOD)` — C `dbPutSpecial` refuses the put with
2687            // `S_db_noMod` (dbAccess.c:123-127). OLDSIMM is written only by the
2688            // simulation-mode owner (`rec_gbl_save_simm`).
2689            "OLDSIMM" => return Err(CaError::ReadOnlyField(name)),
2690            "SEVR" => {
2691                if let EpicsValue::Short(v) = value {
2692                    self.common.sevr = AlarmSeverity::from_u16(v as u16);
2693                }
2694            }
2695            "STAT" => {
2696                if let EpicsValue::Short(v) = value {
2697                    self.common.stat = v as u16;
2698                }
2699            }
2700            "NSEV" => {
2701                if let EpicsValue::Short(v) = value {
2702                    self.common.nsev = AlarmSeverity::from_u16(v as u16);
2703                }
2704            }
2705            "NSTA" => {
2706                if let EpicsValue::Short(v) = value {
2707                    self.common.nsta = v as u16;
2708                }
2709            }
2710            "AMSG" => {
2711                if let EpicsValue::String(s) = value {
2712                    self.common.amsg = s.as_str_lossy().into_owned();
2713                }
2714            }
2715            "NAMSG" => {
2716                if let EpicsValue::String(s) = value {
2717                    self.common.namsg = s.as_str_lossy().into_owned();
2718                }
2719            }
2720            // ACKS/ACKT carry NO acknowledgement semantics here. They are
2721            // `special(SPC_NOMOD)` in `dbCommon.dbd:150-159`, so no runtime put
2722            // reaches this arm — the gate refuses it. C's acknowledgement is
2723            // driven by the DBR *request type* (`DBR_PUT_ACKS`/`ACKT`), which
2724            // `dbPut` intercepts ABOVE the SPC_NOMOD gate and hands to
2725            // [`Self::put_acks`] / [`Self::put_ackt`]. What is left here is the
2726            // `dbLoadRecords` / `dbStaticLib` load path (`field(ACKT,"YES")`),
2727            // which stores the value verbatim — C `dbPutString` never crosses
2728            // `dbPut`.
2729            "ACKS" => {
2730                if let EpicsValue::Short(v) = value {
2731                    self.common.acks = AlarmSeverity::from_u16(v as u16);
2732                }
2733            }
2734            "ACKT" => match value {
2735                EpicsValue::Char(v) => self.common.ackt = v != 0,
2736                EpicsValue::Short(v) => self.common.ackt = v != 0,
2737                _ => return Ok(CommonFieldPutResult::NoChange),
2738            },
2739            "UDF" => {
2740                // Store the raw put byte (C keeps the epicsUInt8 verbatim); a
2741                // record that re-derives UDF on process overwrites it, one that
2742                // sources nothing this cycle keeps it (put-defect cluster #3).
2743                // The calc family reads UDF straight from `common` too
2744                // (`clears_udf() == false`), so the stored byte stands.
2745                if let EpicsValue::Char(v) = value {
2746                    self.common.udf = v;
2747                }
2748            }
2749            "UDFS" => {
2750                self.common.udfs = menu_ordinal_raw(&value);
2751            }
2752            // The `String` form never reaches these three menu arms:
2753            // `coerce_common_field` has already run it through the one
2754            // menu converter, which either produced an `Enum` index or failed
2755            // the put with `S_db_badChoice`.
2756            "SCAN" => {
2757                let new_scan = match &value {
2758                    EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
2759                    EpicsValue::Enum(v) => ScanType::from_u16(*v),
2760                    _ => return Ok(CommonFieldPutResult::NoChange),
2761                };
2762                let result = self.set_scan(new_scan);
2763                if !matches!(result, CommonFieldPutResult::NoChange) {
2764                    self.record.on_put(&name);
2765                    self.record.special(&name, true)?;
2766                    return Ok(result);
2767                }
2768            }
2769            "SSCN" => {
2770                let new_sscn = match &value {
2771                    EpicsValue::Short(v) => SimModeScan::from_u16(*v as u16),
2772                    EpicsValue::Enum(v) => SimModeScan::from_u16(*v),
2773                    _ => return Ok(CommonFieldPutResult::NoChange),
2774                };
2775                self.common.sscn = new_sscn;
2776            }
2777            // `PINI` is `menu(menuPini)` — the six choices NO/YES/RUN/RUNNING/
2778            // PAUSE/PAUSED (`menuPini.dbd.pod:59-65`). Resolved exactly like
2779            // `SCAN`: a menu label or a bare index, never a truthiness test.
2780            // The pre-fix `bool` arm collapsed `RUN` (index 2) to `false`, so
2781            // `caput REC.PINI RUN` *disabled* PINI instead of selecting the
2782            // iocRun pass.
2783            "PINI" => {
2784                // Store the RAW ordinal (see [`CommonFields::pini`]): C's numeric
2785                // menu put keeps `(epicsEnum16)`, so an out-of-range `caput
2786                // REC.PINI 6` / `-1` round-trips and simply matches no lifecycle
2787                // pass in `doRecordPini`. A `String` label is already resolved to
2788                // `Enum` by `coerce_common_field` (menuPini via `putStringMenu`).
2789                self.common.pini = match &value {
2790                    EpicsValue::Short(v) => *v,
2791                    EpicsValue::Char(v) => *v as i16,
2792                    EpicsValue::Enum(v) => *v as i16,
2793                    _ => return Ok(CommonFieldPutResult::NoChange),
2794                };
2795            }
2796            "TPRO" => {
2797                if let EpicsValue::Char(v) = value {
2798                    self.common.tpro = v;
2799                }
2800            }
2801            "BKPT" => {
2802                if let EpicsValue::Char(v) = value {
2803                    self.common.bkpt = v;
2804                }
2805            }
2806            "FLNK" => {
2807                if let EpicsValue::String(s) = value {
2808                    self.common.flnk = s.as_str_lossy().into_owned();
2809                    self.parsed_flnk = parse_forward_link_v2(&self.common.flnk);
2810                }
2811            }
2812            "INP" => {
2813                // A record type whose C `.dbd` has no INP must refuse it, the
2814                // way C's dbd does ("field not found" at load, record inert) —
2815                // `histogram`'s input link is SVL, not INP
2816                // (histogramRecord.dbd.pod:212). Without this the port accepts a
2817                // `field(INP,...)` no C IOC can load.
2818                if !self.record.declares_inp_link() {
2819                    return Err(CaError::FieldNotFound("INP".to_string()));
2820                }
2821                if let EpicsValue::String(s) = value {
2822                    self.check_link_assignment("INP", &s.as_str_lossy(), bound)?;
2823                    self.common.inp = s.as_str_lossy().into_owned();
2824                    if !self.record_declares_field("INP") {
2825                        self.parsed_inp = parse_link_v2(&self.common.inp);
2826                    }
2827                }
2828            }
2829            "OUT" => {
2830                if let EpicsValue::String(s) = value {
2831                    let s = s.as_str_lossy();
2832                    self.check_link_assignment("OUT", &s, bound)?;
2833                    // C `dbParseLink` (dbStaticLib.c:2382-2386) discards a
2834                    // CP/CPP modifier on a DBF_OUTLINK and warns once, naming
2835                    // the holder record, its field and the target. The discard
2836                    // itself is owned by `parse_output_link_v2` below; only the
2837                    // diagnostic lives here, where the record name exists and
2838                    // the link text is being (re)loaded rather than re-parsed
2839                    // per process cycle.
2840                    if out_link_discards_cp(&s) {
2841                        tracing::warn!(
2842                            target: "epics_base_rs::record",
2843                            record = %self.name,
2844                            field = "OUT",
2845                            link = %s,
2846                            "Discarding CP/CPP modifier in CA output link"
2847                        );
2848                    }
2849                    self.common.out = s.into_owned();
2850                    // C `dbDbPutValue` (dbDbLink.c:386-389): an OUT
2851                    // link processes its target only on an explicit
2852                    // ` PP` token (or a `.PROC` destination). A bare
2853                    // OUT link is NPP — `parse_output_link_v2`
2854                    // downgrades the modifier-less `ProcessPassive`
2855                    // default that `parse_link_v2` would otherwise
2856                    // apply.
2857                    if !self.record_declares_field("OUT") {
2858                        self.parsed_out = parse_output_link_v2(&self.common.out);
2859                    }
2860                    // C `longoutRecord.c::special` (PR #6c573b4 part 2)
2861                    // and similar OOCH-style hooks need `after=true`
2862                    // to fire after the link has actually moved. The
2863                    // earlier `validate_put` + `special(name, false)`
2864                    // pair only covered the before-side.
2865                    self.record.special(&name, true)?;
2866                }
2867            }
2868            // Two shapes reach DTYP and both name a device support:
2869            //
2870            // * the `.db` loader hands over the NAME verbatim, and it may be a
2871            //   name registered at runtime by a downstream crate ("asynInt32")
2872            //   that no vendored `.dbd` declares — C would reject that at load,
2873            //   the port's registry accepts it (Tier 3);
2874            // * a `dbPut` arrives as the menu INDEX, because `DBF_DEVICE` is
2875            //   served as `DBR_ENUM` and `coerce_put_value` already resolved an
2876            //   incoming label through the device menu (C `putStringMenu`,
2877            //   which fails `S_db_badChoice` on a name the menu does not have).
2878            //
2879            // The index is meaningful against the MERGED device menu (static
2880            // `device()` declarations + runtime-contributed device support) —
2881            // the exact list `coerce_put_value` bounded it by. Resolving it
2882            // against the static-only menu here would drop every contributed
2883            // name (asyn's "asynInt32", scaler-rs's "Asyn Scaler") back to
2884            // NoChange, leaving DTYP unset after a valid put.
2885            "DTYP" => match value {
2886                EpicsValue::String(s) => self.common.dtyp = s.as_str_lossy().into_owned(),
2887                EpicsValue::Enum(i) => {
2888                    let merged = super::merged_device_menu(self.record.record_type());
2889                    match merged.get(i as usize) {
2890                        Some(name) => self.common.dtyp = (*name).to_string(),
2891                        None => return Ok(CommonFieldPutResult::NoChange),
2892                    }
2893                }
2894                _ => return Ok(CommonFieldPutResult::NoChange),
2895            },
2896            "TSE" => {
2897                if let EpicsValue::Short(v) = value {
2898                    self.common.tse = v;
2899                }
2900            }
2901            "TSEL" => {
2902                if let EpicsValue::String(s) = value {
2903                    self.common.tsel = s.as_str_lossy().into_owned();
2904                    self.parsed_tsel = parse_link_v2(&self.common.tsel);
2905                }
2906            }
2907            "UTAG" => {
2908                // C UTAG is DBF_UINT64 — accept any integer-shaped value and
2909                // store the unsigned 64-bit tag. The db loader feeds every
2910                // common field as EpicsValue::String, so parse field(UTAG, "N")
2911                // rather than dropping it silently at IOC load; a CA write to
2912                // this u64 field crosses as DBR_DOUBLE (CA has no uint64 wire
2913                // type), so accept Double too.
2914                match value {
2915                    EpicsValue::UInt64(v) => self.common.utag = v,
2916                    EpicsValue::Int64(v) => self.common.utag = v as u64,
2917                    EpicsValue::Long(v) => self.common.utag = v as u64,
2918                    EpicsValue::Short(v) => self.common.utag = v as u64,
2919                    EpicsValue::Enum(v) => self.common.utag = v as u64,
2920                    EpicsValue::Char(v) => self.common.utag = v as u64,
2921                    EpicsValue::Double(v) => self.common.utag = v as u64,
2922                    EpicsValue::String(s) => {
2923                        if let Ok(EpicsValue::UInt64(v)) =
2924                            EpicsValue::parse(DbFieldType::UInt64, s.as_str_lossy().trim())
2925                        {
2926                            self.common.utag = v;
2927                        }
2928                    }
2929                    _ => {}
2930                }
2931            }
2932            "ASG" => {
2933                if let EpicsValue::String(s) = value {
2934                    self.common.asg = s.as_str_lossy().into_owned();
2935                }
2936            }
2937            "ASL" => {
2938                // C dbCommon.ASL is `epicsUInt32` in the .dbd but
2939                // only ever 0 or 1; accept Char / Short / Long for
2940                // the common put paths and clamp to {0, 1}.
2941                // db_loader feeds every common field as
2942                // `EpicsValue::String`; also accept that so a
2943                // `.db` `field(ASL, "1")` directive isn't silently
2944                // ignored at IOC load.
2945                let n: i64 = match value {
2946                    EpicsValue::Char(v) => v as i64,
2947                    EpicsValue::Short(v) => v as i64,
2948                    EpicsValue::Long(v) => v as i64,
2949                    EpicsValue::Int64(v) => v,
2950                    EpicsValue::String(s) => s.as_str_lossy().trim().parse().unwrap_or(0),
2951                    _ => return Ok(CommonFieldPutResult::NoChange),
2952                };
2953                self.common.asl = if n != 0 { 1 } else { 0 };
2954            }
2955            "DESC" => {
2956                if let EpicsValue::String(s) = value {
2957                    // DBF_STRING data field — store the bytes verbatim so a
2958                    // non-UTF-8 DESC round-trips unchanged.
2959                    self.common.desc = s;
2960                }
2961            }
2962            "PHAS" => {
2963                if let EpicsValue::Short(v) = value {
2964                    let old_phas = self.common.phas;
2965                    self.common.phas = v;
2966                    // Only a record that IS in a scan list can be re-sorted
2967                    // within one; the same gate the index owner applies.
2968                    if old_phas != v && self.common.scan.scan_list().is_some() {
2969                        let scan = self.common.scan;
2970                        self.record.on_put(&name);
2971                        self.record.special(&name, true)?;
2972                        return Ok(CommonFieldPutResult::PhasChanged {
2973                            scan,
2974                            old_phas,
2975                            new_phas: v,
2976                        });
2977                    }
2978                }
2979            }
2980            "EVNT" => {
2981                // C `EVNT` is DBF_STRING (event name). Accept a
2982                // string directly; accept a numeric value too for
2983                // backward compatibility (numeric events / a calc
2984                // record driving EVNT) by formatting it as a string.
2985                match value {
2986                    EpicsValue::String(s) => self.common.evnt = s.as_str_lossy().into_owned(),
2987                    EpicsValue::Short(v) => self.common.evnt = v.to_string(),
2988                    EpicsValue::Long(v) => self.common.evnt = v.to_string(),
2989                    EpicsValue::Enum(v) => self.common.evnt = v.to_string(),
2990                    EpicsValue::Double(v) => {
2991                        // Match C `eventNameToHandle`: a double with
2992                        // an integer part is treated as that integer.
2993                        self.common.evnt = (v as i64).to_string();
2994                    }
2995                    _ => {}
2996                }
2997            }
2998            "PRIO" => {
2999                if let EpicsValue::Short(v) = value {
3000                    self.common.prio = v;
3001                }
3002            }
3003            "DISV" => {
3004                if let EpicsValue::Short(v) = value {
3005                    self.common.disv = v;
3006                }
3007            }
3008            "DISA" => {
3009                if let EpicsValue::Short(v) = value {
3010                    self.common.disa = v;
3011                }
3012            }
3013            "SDIS" => {
3014                if let EpicsValue::String(s) = value {
3015                    self.common.sdis = s.as_str_lossy().into_owned();
3016                    self.parsed_sdis = parse_link_v2(&self.common.sdis);
3017                }
3018            }
3019            "DISS" => {
3020                self.common.diss = menu_ordinal_raw(&value);
3021            }
3022            "HYST" => {
3023                if let Some(v) = value.to_f64() {
3024                    self.common.hyst = v;
3025                }
3026            }
3027            "LCNT" => {
3028                if let EpicsValue::Short(v) = value {
3029                    self.common.lcnt = v;
3030                }
3031            }
3032            "DISP" => {
3033                if let EpicsValue::Char(v) = value {
3034                    self.common.disp = v;
3035                }
3036            }
3037            "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
3038            "RPRO" => {
3039                if let EpicsValue::Char(v) = value {
3040                    self.common.rpro = v;
3041                }
3042            }
3043            "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
3044            // C `dbPut` stores the raw byte in `prec->proc` (retained across
3045            // processing — C never resets it); `coerce_common_field` has
3046            // already projected the put onto `DBF_UCHAR` (→ `Char`). The
3047            // `pp(TRUE)` reprocess is driven separately by the put-path
3048            // force-process intercept, so this arm ONLY records the byte.
3049            "PROC" => {
3050                if let EpicsValue::Char(v) = value {
3051                    self.common.proc_field = v;
3052                }
3053            }
3054            // Analog alarm limits. The DB-load String was already coerced to
3055            // `Double` by `coerce_common_field` — the one owner of
3056            // "what type does this common field hold" — so every writer
3057            // (`.db` load, `caput`, a link) lands here with a numeric value.
3058            "HIHI" => {
3059                if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
3060                    a.hihi = v;
3061                }
3062            }
3063            "HIGH" => {
3064                if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
3065                    a.high = v;
3066                }
3067            }
3068            "LOW" => {
3069                if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
3070                    a.low = v;
3071                }
3072            }
3073            "LOLO" => {
3074                if let (Some(v), Some(a)) = (value.to_f64(), self.common.analog_alarm.as_mut()) {
3075                    a.lolo = v;
3076                }
3077            }
3078            "HHSV" => {
3079                if let Some(a) = &mut self.common.analog_alarm {
3080                    a.hhsv = menu_ordinal_raw(&value);
3081                }
3082            }
3083            "HSV" => {
3084                if let Some(a) = &mut self.common.analog_alarm {
3085                    a.hsv = menu_ordinal_raw(&value);
3086                }
3087            }
3088            "LSV" => {
3089                if let Some(a) = &mut self.common.analog_alarm {
3090                    a.lsv = menu_ordinal_raw(&value);
3091                }
3092            }
3093            "LLSV" => {
3094                if let Some(a) = &mut self.common.analog_alarm {
3095                    a.llsv = menu_ordinal_raw(&value);
3096                }
3097            }
3098            // swait-specific: OUTN is the output link name for swait records.
3099            // Mirrors to common.out so the processing framework dispatches it.
3100            "OUTN" => {
3101                if self.record.record_type() != "swait" {
3102                    // No OUTN field on any other record type — the same
3103                    // `S_dbLib_fieldNotFound` the catch-all below reports.
3104                    return Err(self.unknown_field_error(name));
3105                }
3106                if let EpicsValue::String(s) = value {
3107                    self.common.out = s.as_str_lossy().into_owned();
3108                    // Bare OUT link is NPP — see the "OUT" arm.
3109                    self.parsed_out = parse_output_link_v2(&self.common.out);
3110                }
3111            }
3112            // C `dbNameToAddr` (dbAccess.c:660-676) resolves the field part
3113            // with `dbFindFieldPart`, then falls back to `dbGetAttributePart`.
3114            // A name that is neither a record field, nor a dbCommon field, nor
3115            // an attribute resolves to nothing (`S_dbLib_fieldNotFound`), so
3116            // `dbPutField` is never reached and the caller reports the error —
3117            // `dbpf` prints "PV '%s' not found" and returns -1 (dbTest.c:787-795).
3118            // Returning success here made a put to a misspelled field a silent
3119            // no-op.
3120            //
3121            // But a field the record's `.dbd` DECLARES and no arm above stored
3122            // is NOT unknown: C `dbPut` writes it into record memory even when
3123            // no record code reads it back (`caput dfanout.HOPR 10`). Land it in
3124            // the per-instance declared-override store — the write analog of
3125            // `declared_default` — so the put is accepted and a later read
3126            // reflects it. `put_declared_override` still returns
3127            // `unknown_field_error` for a name with no `dbFldDes`, so a
3128            // misspelled field is refused exactly as before.
3129            _ => return self.put_declared_override(&name, value),
3130        }
3131        self.record.on_put(&name);
3132        // C `dbPut` (dbAccess.c:1399-1405) returns the after-put
3133        // `dbPutSpecial(paddr, 1)` status to the caller — the stored value
3134        // stays, but the monitor post and the process are skipped and the
3135        // client sees the failure. Never drop it.
3136        self.record.special(&name, true)?;
3137        Ok(CommonFieldPutResult::NoChange)
3138    }
3139
3140    /// The error C reports for a write to a field name that
3141    /// [`Self::put_common_field`] does not own.
3142    ///
3143    /// Two C outcomes, split by whether the name resolves at all:
3144    ///
3145    /// - A record *attribute* (`NAME`, `RTYP`) resolves — `dbGetAttributePart`
3146    ///   succeeds — but the write is refused: `NAME` is `special(SPC_NOMOD)`
3147    ///   (dbCommon.dbd:13-17) so `dbPutSpecial` pass 0 returns `S_db_noMod`
3148    ///   (dbAccess.c:123-124), and an attribute address carries
3149    ///   `special == SPC_ATTRIBUTE`, which `dbPutField` rejects with the same
3150    ///   `S_db_noMod` (dbAccess.c:1252-1253).
3151    /// - Anything else does not resolve: `S_dbLib_fieldNotFound`.
3152    fn unknown_field_error(&self, name: String) -> CaError {
3153        if self.get_virtual_field(&name).is_some() {
3154            CaError::ReadOnlyField(name)
3155        } else {
3156            CaError::FieldNotFound(name)
3157        }
3158    }
3159
3160    /// Store a put to a field the record's `.dbd` DECLARES but the record
3161    /// models no storage for — the WRITE owner of [`Self::declared_overrides`]
3162    /// and the write analog of [`Self::declared_default`].
3163    ///
3164    /// Reached only from [`Self::put_common_field_bounded`]'s catch-all, i.e.
3165    /// after both `Record::put_field` (returned `FieldNotFound`) and every
3166    /// `dbCommon` arm above have declined the field. Three gates, mirroring
3167    /// C `dbNameToAddr`/`dbPut`:
3168    ///
3169    /// * NO `dbFldDes` (`field_desc` is `None`) — the name is not a field of
3170    ///   this record type at all. C resolves nothing and `dbPutField` reports
3171    ///   `S_dbLib_fieldNotFound`; return [`Self::unknown_field_error`] (which
3172    ///   also renders `NAME`/`RTYP` as the read-only attributes they are).
3173    /// * `special(SPC_NOMOD)` — a declared field that is immutable
3174    ///   ([`Self::is_no_mod`]: the `.dbd` `read_only`/attribute bit or the
3175    ///   record's runtime `field_no_mod`). C refuses the put with `S_db_noMod`;
3176    ///   the runtime dispatch already gates this via `field_io::check_no_mod`,
3177    ///   but the db-load path does not, so enforce it here too — never store an
3178    ///   SPC_NOMOD field in the override map.
3179    /// * [`FieldDesc::runtime_typed`] — a field whose served type C's
3180    ///   `cvt_dbaddr` re-derives from record state (`waveform.VAL` from `FTVL`,
3181    ///   `aSub.A` from `FTA`). Such a field is record-owned by definition, so
3182    ///   its `put_field` should have taken the put; if it somehow reached here
3183    ///   the override store must not shadow it (`declared_default` skips it for
3184    ///   the same reason). Treat as not-found rather than store a value under
3185    ///   the wrong type.
3186    /// * PARTIALLY modeled — `Record::get_field` serves the field but no
3187    ///   `put_field` arm accepts it (`calcout.PVAL` → `self.pval`). The record
3188    ///   owns the read path, so the write belongs in its own `put_field`, not a
3189    ///   shadow cell; refuse here rather than store a value `resolve_field`
3190    ///   would never reach. See the inline note on the `get_field` guard.
3191    ///
3192    /// Otherwise coerce the incoming value to the field's C-declared DBF type
3193    /// through the one write-side value-coercion owner
3194    /// ([`coerce_put_value`](crate::server::record::coerce_put_value)) — so a
3195    /// `.db`/`caput` string parses with C's range rules (`caput REC.PREC 99999`
3196    /// into a `DBF_SHORT` is refused, not wrapped) and a menu label resolves
3197    /// against the field's own choices — and store it. Returns
3198    /// [`CommonFieldPutResult::NoChange`]: there is no scan/phas/alarm side
3199    /// effect for a metadata field with no record behaviour, and the caller's
3200    /// value-field monitor post reads the stored value back through
3201    /// [`Self::resolve_field`].
3202    fn put_declared_override(
3203        &mut self,
3204        name: &str,
3205        value: EpicsValue,
3206    ) -> CaResult<CommonFieldPutResult> {
3207        let Some(desc) = self.field_desc(name) else {
3208            return Err(self.unknown_field_error(name.to_string()));
3209        };
3210        if desc.runtime_typed {
3211            return Err(self.unknown_field_error(name.to_string()));
3212        }
3213        if self.is_no_mod(name) {
3214            // C `dbPutSpecial` pass 0 refuses SPC_NOMOD with `S_db_noMod`.
3215            return Err(CaError::ReadOnlyField(name.to_string()));
3216        }
3217        // The override is the WRITABLE TWIN of `declared_default`, and
3218        // `declared_default` is `resolve_field`'s fallback ONLY when the record
3219        // itself serves nothing (`Record::get_field` is `None`). If the record
3220        // DOES serve this field (`get_field` is `Some`), it is not unmodeled —
3221        // it is PARTIALLY modeled: a getter into record memory (e.g.
3222        // `calcout.PVAL` → `self.pval`, which `process()` also writes) but no
3223        // matching `put_field` arm. Storing here would place the value in a
3224        // second cell that `resolve_field` never reaches (`get_field` shadows
3225        // the override) and that no `process()` keeps in step — a silent write
3226        // loss. Such a field's put belongs in the record's OWN `put_field`
3227        // (a per-record setter, a distinct change); refuse it here rather than
3228        // half-accept it, so `resolve_field` stays single-valued. A field the
3229        // record does not serve at all falls through to be stored.
3230        if self.record.get_field(name).is_some() {
3231            return Err(self.unknown_field_error(name.to_string()));
3232        }
3233        let target = desc.dbf_type;
3234        let coerced =
3235            crate::server::record::coerce_put_value(self.record.as_ref(), name, target, value)?;
3236        self.declared_overrides
3237            .insert(name.to_ascii_uppercase(), coerced);
3238        Ok(CommonFieldPutResult::NoChange)
3239    }
3240
3241    /// Get virtual fields (NAME, RTYP).
3242    pub fn get_virtual_field(&self, name: &str) -> Option<EpicsValue> {
3243        match name {
3244            "NAME" => Some(EpicsValue::String(self.name.clone().into())),
3245            "RTYP" => Some(EpicsValue::String(
3246                self.record.record_type().to_string().into(),
3247            )),
3248            _ => None,
3249        }
3250    }
3251
3252    /// Evaluate alarms based on record type and current value.
3253    /// Uses rec_gbl_set_sevr to accumulate into nsta/nsev.
3254    ///
3255    /// CALC_ALARM is NOT raised here. C raises it inside the record's own
3256    /// `process()` (`calcRecord.c:121-123`, `calcoutRecord.c:238-241`,
3257    /// `sCalcoutRecord.c:357-363`, `aCalcoutRecord.c:304-305`,
3258    /// `swaitRecord.c:409-410`), and in the port [`Record::check_alarms`] — which
3259    /// runs immediately before this — is that owner. It used to be raised here
3260    /// instead, keyed on a hardcoded `rtype` list plus a `CALC_ALARM` pseudo-field
3261    /// no DBD declares; swait is what that construction cost: it carried the flag
3262    /// but was not on the list, so a failed `calcPerform` alarmed nowhere.
3263    pub fn evaluate_alarms(&mut self) {
3264        use crate::server::recgbl;
3265
3266        // Check UDF first — but only for record types whose C support carries
3267        // the `if (prec->udf) recGblSetSevr(..., UDF_ALARM, ...)` guard. C has
3268        // no central UDF alarm; see `Record::raises_udf_alarm`.
3269        if self.record.raises_udf_alarm() {
3270            recgbl::rec_gbl_check_udf(
3271                &mut self.common,
3272                self.record.udf_alarm_on_exact_one(),
3273                self.record.udf_alarm_message(),
3274            );
3275        }
3276
3277        // The analog-alarm SLOT is the enumeration — a record has the ladder iff
3278        // `new_boxed` gave it a config, which is the one place the C `.dbd`
3279        // survey lives. A second `match rtype` here was the same list written
3280        // twice, and the two could disagree: scalcout was in neither, so its ten
3281        // C alarm fields could not even be put.
3282        //
3283        // bi / bo / busy / mbbi / mbbo STATE+COS (and mbbo SOFT) alarm evaluation
3284        // lives in each record's `Record::check_alarms` hook (C `checkAlarms`);
3285        // those records carry no analog config, so they never reach here and
3286        // cannot double-raise.
3287        if let Some(ref alarm_cfg) = self.common.analog_alarm.clone() {
3288            let val = match self.record.val() {
3289                Some(EpicsValue::Double(v)) => v,
3290                Some(EpicsValue::Long(v)) => v as f64,
3291                Some(EpicsValue::Int64(v)) => v as f64,
3292                _ => return,
3293            };
3294            self.evaluate_analog_alarm(val, alarm_cfg);
3295        }
3296    }
3297
3298    fn evaluate_analog_alarm(&mut self, val: f64, cfg: &AnalogAlarmConfig) {
3299        use crate::server::recgbl::{self, alarm_status};
3300
3301        // C `checkAlarms` returns immediately on a UDF cycle: it raises
3302        // `UDF_ALARM`/`UDFS` (already done by `rec_gbl_check_udf` in
3303        // `evaluate_alarms`), zeroes `AFVL` on the AFTC-capable records, and
3304        // returns BEFORE the range check — so `LALM` is left untouched and
3305        // `AFVL` is not filtered this cycle. The identical guard appears in
3306        // every record that shares this arm (`aiRecord.c:319-323`,
3307        // `aoRecord.c:383-386`, `longinRecord.c:274-278`,
3308        // `longoutRecord.c:317-320`, `int64inRecord.c:267-271`,
3309        // `int64outRecord.c:298-301`, `calcRecord.c:300-304`,
3310        // `calcoutRecord.c:563-566`). AFTC-capable records (ai/longin/
3311        // int64in/calc) carry `AFVL` and zero it (`prec->afvl = 0`); the
3312        // out records (ao/longout/int64out/calcout) have no `AFVL` and just
3313        // return. Running the range check here would drift `LALM` to `val`
3314        // (NaN on an undefined cycle) and filter `AFVL` — both observable.
3315        if self.common.udf != 0 {
3316            if matches!(
3317                self.record.record_type(),
3318                "calc" | "ai" | "longin" | "int64in"
3319            ) && self.record.get_field("AFVL").and_then(|v| v.to_f64()) != Some(0.0)
3320            {
3321                let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
3322            }
3323            return;
3324        }
3325
3326        let hyst = self.common.hyst;
3327        let lalm = self
3328            .record
3329            .get_field("LALM")
3330            .and_then(|v| v.to_f64())
3331            .unwrap_or(val);
3332
3333        // C-style per-level hysteresis: alarm fires if val passes the level,
3334        // OR if we were already at that alarm level (lalm == alev) and val
3335        // hasn't retreated past the hysteresis margin.
3336        //
3337        // `alarm_range` is the C-style integer level: 1=Lolo, 2=Low,
3338        // 3=Normal, 4=High, 5=Hihi. Required for the calc-record AFTC
3339        // filter (`calcRecord.c::checkAlarms:339-381`) which filters
3340        // on the range level (not on severity) and re-maps back.
3341        // C's `checkAlarms` enables each level with a NONZERO test on the raw
3342        // severity ordinal (`if (prec->hhsv && …)`) and passes that raw ordinal
3343        // to `recGblSetSevr`; `recGblResetAlarms` then clamps the resulting
3344        // *severity* to `INVALID_ALARM` while the *status* keeps the level. So an
3345        // out-of-range selector (`HHSV = 4`) still fires HIHI and lands
3346        // SEVR=INVALID/STAT=HIHI — reproduced by testing `!= 0` and mapping the
3347        // ordinal through [`AlarmSeverity::from_u16`] (which clamps `>= 3` to
3348        // `Invalid`).
3349        let (mut new_sevr, mut new_stat, mut alev, mut alarm_range) = if cfg.hhsv != 0
3350            && (val >= cfg.hihi || (lalm == cfg.hihi && val >= cfg.hihi - hyst))
3351        {
3352            (
3353                AlarmSeverity::from_u16(cfg.hhsv as u16),
3354                alarm_status::HIHI_ALARM,
3355                cfg.hihi,
3356                5u16,
3357            )
3358        } else if cfg.llsv != 0 && (val <= cfg.lolo || (lalm == cfg.lolo && val <= cfg.lolo + hyst))
3359        {
3360            (
3361                AlarmSeverity::from_u16(cfg.llsv as u16),
3362                alarm_status::LOLO_ALARM,
3363                cfg.lolo,
3364                1u16,
3365            )
3366        } else if cfg.hsv != 0 && (val >= cfg.high || (lalm == cfg.high && val >= cfg.high - hyst))
3367        {
3368            (
3369                AlarmSeverity::from_u16(cfg.hsv as u16),
3370                alarm_status::HIGH_ALARM,
3371                cfg.high,
3372                4u16,
3373            )
3374        } else if cfg.lsv != 0 && (val <= cfg.low || (lalm == cfg.low && val <= cfg.low + hyst)) {
3375            (
3376                AlarmSeverity::from_u16(cfg.lsv as u16),
3377                alarm_status::LOW_ALARM,
3378                cfg.low,
3379                2u16,
3380            )
3381        } else {
3382            (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val, 3u16)
3383        };
3384
3385        // C parity: the alarm-range AFTC low-pass filter
3386        // (`{ai,longin,int64in,calc}Record.c::checkAlarms`) smooths the
3387        // integer `alarmRange` and re-maps. Only records that carry the
3388        // AFTC/AFVL fields run it — `ao`/`longout`/`int64out`/`calcout`
3389        // have no AFTC field (confirmed via the respective `.dbd.pod`),
3390        // so they are excluded.
3391        let aftc_capable = matches!(
3392            self.record.record_type(),
3393            "calc" | "ai" | "longin" | "int64in"
3394        );
3395        if aftc_capable {
3396            let aftc = self
3397                .record
3398                .get_field("AFTC")
3399                .and_then(|v| v.to_f64())
3400                .unwrap_or(0.0);
3401            let afvl = self
3402                .record
3403                .get_field("AFVL")
3404                .and_then(|v| v.to_f64())
3405                .unwrap_or(0.0);
3406            if aftc > 0.0 {
3407                let now = crate::runtime::general_time::get_current();
3408                let (filtered_range, new_afvl) = crate::server::records::alarm_filter::aftc_filter(
3409                    alarm_range,
3410                    aftc,
3411                    afvl,
3412                    self.common.time,
3413                    now,
3414                );
3415                let _ = self.record.put_field("AFVL", EpicsValue::Double(new_afvl));
3416                if filtered_range != alarm_range {
3417                    // Re-map filtered range back to (sevr, stat, alev).
3418                    let (mapped_sevr, mapped_stat, mapped_alev) = match filtered_range {
3419                        5 => (
3420                            AlarmSeverity::from_u16(cfg.hhsv as u16),
3421                            alarm_status::HIHI_ALARM,
3422                            cfg.hihi,
3423                        ),
3424                        4 => (
3425                            AlarmSeverity::from_u16(cfg.hsv as u16),
3426                            alarm_status::HIGH_ALARM,
3427                            cfg.high,
3428                        ),
3429                        2 => (
3430                            AlarmSeverity::from_u16(cfg.lsv as u16),
3431                            alarm_status::LOW_ALARM,
3432                            cfg.low,
3433                        ),
3434                        1 => (
3435                            AlarmSeverity::from_u16(cfg.llsv as u16),
3436                            alarm_status::LOLO_ALARM,
3437                            cfg.lolo,
3438                        ),
3439                        _ => (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val),
3440                    };
3441                    new_sevr = mapped_sevr;
3442                    new_stat = mapped_stat;
3443                    alev = mapped_alev;
3444                    alarm_range = filtered_range;
3445                }
3446            } else {
3447                // aftc <= 0 disables the filter. C `checkAlarms`
3448                // (e.g. aiRecord.c:356,402) initialises the local
3449                // `afvl = 0` and unconditionally stores `prec->afvl =
3450                // afvl` at the end, so a disabled filter drives AFVL to
3451                // 0. Mirror that here so a stale accumulator from a prior
3452                // `aftc > 0` run cannot mis-seed the filter if AFTC is
3453                // re-enabled later.
3454                if afvl != 0.0 {
3455                    let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
3456                }
3457            }
3458        }
3459        let _ = alarm_range; // suppress unused-var on non-calc paths
3460
3461        if new_sevr != AlarmSeverity::NoAlarm {
3462            recgbl::rec_gbl_set_sevr(&mut self.common, new_stat, new_sevr);
3463            // C sets LALM to the alarm threshold level, not the current value
3464            let _ = self.record.put_field("LALM", EpicsValue::Double(alev));
3465        } else {
3466            // No alarm condition: reset LALM to current value (like C)
3467            let _ = self.record.put_field("LALM", EpicsValue::Double(val));
3468        }
3469    }
3470
3471    /// Invoke the registered subroutine (`sub`/`aSub` `SNAM`) if one is
3472    /// bound, before the record's `process()` body runs.
3473    ///
3474    /// C `subRecord.c::do_sub` / `aSubRecord.c::do_sub` call the named
3475    /// subroutine on EVERY `process()`. The function registry lives on the
3476    /// framework (`RecordInstance::subroutine`), not on the record, so the
3477    /// record's own `process()` is a no-op for these two types and the
3478    /// framework must drive the call. This is the SINGLE owner of that call
3479    /// for every dispatch path: the main engine
3480    /// (`process_record_with_links_inner`, the SCAN / event / CA-put-to-PP /
3481    /// FLNK path) and the by-name `process_local` (`db.process_record`,
3482    /// QSRV group / foreign-call path) both route through here, so a
3483    /// `sub`/`aSub` runs identically regardless of how it is processed.
3484    /// Previously only `process_local` invoked the subroutine, so on the
3485    /// main engine path `VAL`/`VALA..VALU`/`OUTA..OUTU` never updated.
3486    /// The cycle's status is delivered to the record on EVERY exit path — see
3487    /// [`Record::set_subroutine_status`], which aSub's OUT-link gate reads. The
3488    /// delivery is factored out of the body below so a future early return
3489    /// cannot skip it: the body returns the status, this wrapper publishes it.
3490    pub(crate) fn run_registered_subroutine(&mut self) -> CaResult<()> {
3491        let outcome = self.run_subroutine_body();
3492        // A subroutine that errored out has no C counterpart (a C subroutine
3493        // returns a `long`); it is a failed cycle, so it takes the non-zero
3494        // arm — no outputs.
3495        let status = *outcome.as_ref().unwrap_or(&SUBROUTINE_STATUS_ERROR);
3496        self.record.set_subroutine_status(status);
3497        outcome.map(|_| ())
3498    }
3499
3500    /// Returns C `process`'s `status` for this cycle: 0 only when `do_sub` ran
3501    /// and returned 0.
3502    fn run_subroutine_body(&mut self) -> CaResult<i64> {
3503        use crate::server::recgbl::{self, alarm_status};
3504
3505        // aSub `LFLG=READ`: a `SUBL` re-resolution that found a bad/unregistered
3506        // name (C `fetch_values` -> `S_db_BadSub`) or failed to read the link
3507        // signals "skip do_sub this cycle" — C `process` runs `do_sub` only on
3508        // `!status`. The framework's failed input-link fetch arms the same flag.
3509        // One-shot: taken (cleared) whether or not a subroutine is set, so it
3510        // never leaks into the next cycle. The single consumer of the flag,
3511        // shared by every process path.
3512        if std::mem::take(&mut self.suppress_subroutine_run) {
3513            return Ok(SUBROUTINE_STATUS_SKIPPED);
3514        }
3515
3516        // Clone the Arc so the borrow on `self.subroutine` is released
3517        // before we mutate `self.record` / `self.common` below.
3518        let Some(sub_fn) = self.subroutine.clone() else {
3519            // C `do_sub` (aSubRecord.c:459-465, subRecord.c:118-123)
3520            // short-circuits an EMPTY SNAM to `return 0` BEFORE the
3521            // `pfunc == NULL` -> `S_db_BadSub` check: a subroutine record with
3522            // no SNAM is not a bad-sub, it is a no-op that completes with
3523            // status 0. Only a NON-empty, unregistered SNAM yields
3524            // `S_db_BadSub`.
3525            //
3526            // aSub depends on this: C `process` (aSubRecord.c:224) runs
3527            // `prec->val = status` (= 0) every cycle, forcing VAL back to 0.
3528            // C `monitor()` (aSubRecord.c:414) posts VAL only on
3529            // `val != oval`, so with val==oval==0 a periodic (SCAN) cycle
3530            // posts nothing — the driven `dbPut`s are the only VAL events.
3531            // Without the reset the port leaves VAL holding the last client
3532            // put, and the deadband gate re-posts it on every scan (the aSub
3533            // scanned monitor over-posts: 7 updates where C posts 4). `sub`
3534            // never reaches here with an empty SNAM — it parks PACT at init
3535            // (`Record::init_record_parks_pact`, subRecord.c:119-123) and does
3536            // not process — so the VAL write is scoped to aSub, whose VAL is
3537            // the do_sub status.
3538            let snam_empty = matches!(
3539                self.record.get_field("SNAM"),
3540                Some(EpicsValue::String(s)) if s.is_empty()
3541            );
3542            if snam_empty {
3543                if self.record.record_type() == "aSub" {
3544                    // C `prec->val = do_sub() = 0`.
3545                    let _ = self.record.put_field("VAL", EpicsValue::Long(0));
3546                }
3547                return Ok(0);
3548            }
3549            // A non-empty but unregistered SNAM: C `do_sub` returns
3550            // `S_db_BadSub`, so the cycle's status is non-zero.
3551            return Ok(SUBROUTINE_STATUS_NO_SUB);
3552        };
3553        // C `do_sub` returns the subroutine's `long` status.
3554        let status = sub_fn(&mut *self.record)?;
3555
3556        // aSub publishes the status as VAL (C `aSubRecord.c:223`
3557        // `prec->val = status`). The subroutine's computed outputs live in
3558        // VALA..VALU, so VAL is the return code and overwrites whatever the
3559        // closure may have written to VAL. `sub` does NOT do this — its VAL
3560        // is the value the subroutine computed.
3561        if self.record.record_type() == "aSub" {
3562            // aSub VAL is DBF_LONG (epicsInt32); the do_sub status is a C `long`
3563            // truncated into it (`prec->val = status`).
3564            let _ = self
3565                .record
3566                .put_field("VAL", EpicsValue::Long(status as i32));
3567        }
3568
3569        // A negative status raises SOFT_ALARM at the record's BRSV severity
3570        // (C `do_sub`: `if (status < 0) recGblSetSevr(SOFT_ALARM,
3571        // prec->brsv)`). It accumulates into nsta/nsev for this cycle's
3572        // recGblResetAlarms commit and runs before checkAlarms, so a higher
3573        // analog severity (e.g. the shared analog-alarm owner) still wins via
3574        // the raise-only rule. BRSV defaults to NO_ALARM, under which
3575        // recGblSetSevr is a no-op.
3576        if status < 0 {
3577            let brsv = self
3578                .record
3579                .get_field("BRSV")
3580                .and_then(|v| v.to_f64())
3581                .map(|f| AlarmSeverity::from_u16(f as u16))
3582                .unwrap_or(AlarmSeverity::NoAlarm);
3583            recgbl::rec_gbl_set_sevr(&mut self.common, alarm_status::SOFT_ALARM, brsv);
3584        } else if self.record.record_type() == "aSub" {
3585            // C `aSubRecord.c::do_sub` (469-470): a subroutine that ran and
3586            // returned `>= 0` DEFINES the record — `else prec->udf = FALSE`.
3587            // aSub opts out of the framework's per-cycle blanket UDF re-derive
3588            // (`Record::clears_udf` == false), so THIS is aSub's UDF clear,
3589            // reached only on the path where the subroutine actually executed
3590            // (past the suppress / no-sub early returns above). `sub` keeps the
3591            // blanket re-derive (`clears_udf` true, C `do_sub` `udf =
3592            // isnan(val)`), so it is deliberately not cleared here.
3593            self.common.udf = 0;
3594        }
3595        Ok(status)
3596    }
3597
3598    /// The single owner of a process cycle's SUBSCRIBER posts — C `monitor()`'s
3599    /// "post every subscribed field this cycle touched" loop.
3600    ///
3601    /// Every processing path (`process_record_with_links_inner`, the deferred
3602    /// async-completion path, the simulation path, and [`Self::process_local`])
3603    /// calls this; none of them may reimplement the rules, because a rule that
3604    /// holds on one path and not another is a monitor that fires on a scan cycle
3605    /// but not on an async completion. The per-field mask resolvers
3606    /// ([`AuxPostMask`], [`crate::server::record::value_gate`]) were already
3607    /// single-owned for the same reason — this is the loop around them.
3608    ///
3609    /// It also UPDATES `last_posted` for everything it emits, and it TAKES the
3610    /// record's per-cycle post mask ([`Record::take_cycle_posted_fields`]), so
3611    /// it must run exactly once per cycle.
3612    ///
3613    /// The rules, in order:
3614    ///
3615    /// * The deadband field (default VAL), the
3616    ///   [`recgbl::RECGBL_POSTED_ALARM_FIELDS`](crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS)
3617    ///   (SEVR/STAT/AMSG/ACKS) and UDF are emitted by the caller with their own
3618    ///   C masks and are skipped here.
3619    /// * [`Record::event_posted_fields`] post from their own event path
3620    ///   (waveform HASH) — never from change detection.
3621    /// * [`Record::process_posted_fields`], when declared, is the closed set of
3622    ///   fields a process cycle may post at all.
3623    /// * A secondary value field ([`Record::fields_posted_with_value_mask`])
3624    ///   carries VAL's monitor mask, gated per its [`ValuePostGate`].
3625    /// * A CHANGED field carries [`AuxPostMask::mask_for`] — unless it is a
3626    ///   [`Record::fields_posted_only_when_marked`] field, which C never
3627    ///   change-detects (aCalcout AA..LL) and which therefore posts from its
3628    ///   mark alone.
3629    /// * An UNCHANGED field posts only if the record marked it this cycle:
3630    ///   statically ([`Record::force_posted_fields`]), per-cycle
3631    ///   ([`Record::take_cycle_posted_fields`]), on the alarm transition
3632    ///   ([`Record::alarm_cycle_monitored_fields`]), or in the DBE_LOG sweep
3633    ///   ([`Record::log_swept_fields`]).
3634    pub(crate) fn collect_subscriber_posts(
3635        &mut self,
3636        deadband_field: &str,
3637        deadband_mask: EventMask,
3638        alarm_bits: EventMask,
3639        aux_post: AuxPostMask,
3640        include_val: bool,
3641    ) -> Vec<(String, EpicsValue, EventMask)> {
3642        use crate::server::record::{CyclePostMask, ValuePostGate, value_gate};
3643
3644        // C's default for a change-detected auxiliary post:
3645        // `monitor_mask | DBE_VALUE | DBE_LOG` (calcRecord.c:420, subRecord.c:400;
3646        // motor `DBE_VAL_LOG` for marked fields, motorRecord.cc:3522-3645).
3647        let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
3648        let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
3649            &[]
3650        } else {
3651            self.record.alarm_cycle_monitored_fields()
3652        };
3653        let force_fields = self.record.force_posted_fields();
3654        // TAKE — this also clears the state it answers from (C's
3655        // `pcalc->newm = 0`), which is why this loop may run only once per cycle.
3656        let cycle_posted = self.record.take_cycle_posted_fields();
3657        let log_swept = self.record.log_swept_fields();
3658        // C change-detects nothing about these fields; only the record's own
3659        // per-cycle mark may post them (aCalcout AA..LL — no PAA..PLL previous
3660        // copy exists to compare against).
3661        let marked_only = self.record.fields_posted_only_when_marked();
3662        let value_masked = self.record.fields_posted_with_value_mask();
3663        let event_posted = self.record.event_posted_fields();
3664        let process_posted = self.record.process_posted_fields();
3665
3666        let mut sub_updates: Vec<(String, EpicsValue, EventMask)> = Vec::new();
3667        for (field, subs) in &self.subscribers {
3668            if subs.is_empty()
3669                || field == deadband_field
3670                // SEVR/STAT/AMSG/ACKS are posted by `recGblResetAlarms` itself,
3671                // each with its own C mask (recGbl.c:201-217) — the caller emits
3672                // them from `alarm_field_posts`. A second, change-detected copy
3673                // here would double-post with a mask C never uses for them
3674                // (`alarm_bits | DBE_VALUE | DBE_LOG` instead of C's DBE_VALUE
3675                // on ACKS). UDF is excluded for the opposite reason: NO C
3676                // `monitor()` posts it at all, so a processing cycle that
3677                // redefines VAL must emit no `.UDF` event (a caput to `.UDF`
3678                // still posts, through the generic put path).
3679                || crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS.contains(&field.as_str())
3680                || field == "UDF"
3681                || event_posted.contains(&field.as_str())
3682                || !process_posted.is_none_or(|allowed| allowed.contains(&field.as_str()))
3683            {
3684                continue;
3685            }
3686            let Some(val) = self.resolve_field(field) else {
3687                continue;
3688            };
3689            let changed = match self.posted_value(field) {
3690                Some(prev) => prev != &val,
3691                None => true,
3692            };
3693            if let Some(gate) = value_gate(value_masked, field) {
3694                // C posts this secondary value field with VAL's own monitor_mask,
3695                // from inside the guard that decides whether VAL posts at all —
3696                // never a forced DBE_VALUE|DBE_LOG. `ValuePostGate` says whether C
3697                // also re-tests the field's own value inside that guard (ai RVAL,
3698                // aiRecord.c:462) or posts it whenever the guard fires (timestamp
3699                // RVAL, timestampRecord.c:160).
3700                let post = match gate {
3701                    ValuePostGate::OnChange => changed && !deadband_mask.is_empty(),
3702                    ValuePostGate::WithValue => include_val,
3703                };
3704                if post {
3705                    sub_updates.push((field.clone(), val.clone(), deadband_mask));
3706                }
3707            } else if changed && !marked_only.contains(&field.as_str()) {
3708                sub_updates.push((
3709                    field.clone(),
3710                    val.clone(),
3711                    aux_post.mask_for(field, alarm_bits, deadband_mask),
3712                ));
3713            } else if force_fields.contains(&field.as_str()) {
3714                // C `monitor()` posts a statically re-marked field with
3715                // `monitor_mask | DBE_VAL_LOG` even when unchanged.
3716                sub_updates.push((field.clone(), val.clone(), aux_mask));
3717            } else if cycle_posted.iter().any(|(name, _)| *name == field) {
3718                // One event per MARK, each with the mask of the C call site that
3719                // made it (`CyclePostMask`) — a field marked twice (aCalcout's
3720                // AMASK `afterCalc` post AND its NEWM `monitor()` post) is posted
3721                // twice, exactly as C posts it from both loops.
3722                for (_, cycle_mask) in cycle_posted.iter().filter(|(name, _)| *name == field) {
3723                    let mask = match cycle_mask {
3724                        CyclePostMask::Value => EventMask::VALUE,
3725                        CyclePostMask::ValueLog => EventMask::VALUE | EventMask::LOG,
3726                        CyclePostMask::MonitorValueLog => aux_mask,
3727                    };
3728                    sub_updates.push((field.clone(), val.clone(), mask));
3729                }
3730            } else if alarm_fanout.contains(&field.as_str()) {
3731                // C motor `monitor()` (motorRecord.cc:3513-3645) posts every listed
3732                // field once `monitor_mask != 0`, so a DBE_ALARM-only subscriber
3733                // observes the alarm moment on any of them.
3734                sub_updates.push((field.clone(), val.clone(), alarm_bits));
3735            }
3736            // C `scalerRecord.c::monitor():757-773` posts EVERY S1..Snch with a
3737            // literal DBE_LOG on every cycle it runs (it runs when `ss == IDLE`,
3738            // scalerRecord.c:510). That sweep is INDEPENDENT of the change post,
3739            // not an alternative to it: on the count-completion cycle `ss` is
3740            // IDLE and `updateCounts()` has ALREADY posted each changed Sn with
3741            // DBE_VALUE (:582), so C emits two events for that field in that one
3742            // cycle — DBE_VALUE, then DBE_LOG. Making this an `else if` on
3743            // `changed` dropped the DBE_LOG half exactly when it matters: a
3744            // DBE_LOG-only archiver would never receive the final counts.
3745            //
3746            // The sweep carries the ALARM-transition bits too. DEVIATION from C,
3747            // deliberate — CBUG-B19. C's `monitor()` opens with
3748            // `monitor_mask = recGblResetAlarms(pscal); monitor_mask |=
3749            // (DBE_VALUE|DBE_LOG);` and then posts with a LITERAL `DBE_LOG`
3750            // (scalerRecord.c:764-771) — `monitor_mask` is assigned, OR-ed, and
3751            // never read. Those two lines are dead, and their only plausible use
3752            // was as the third `db_post_events` argument.
3753            // `recGblResetAlarms` returns the alarm-transition mask that every
3754            // other record ORs into its value posts, so discarding it drops the
3755            // alarm bit: a client subscribed to `Sn` with DBE_ALARM receives
3756            // NOTHING on an alarm-severity transition of the record.
3757            //
3758            // The DBE_VALUE half of C's dead `|=` is deliberately NOT
3759            // resurrected: this sweep is unconditional, so adding VALUE would
3760            // fire a value event at every VALUE subscriber on every idle scan,
3761            // changed or not — that would be a new defect, not a fix. The value
3762            // path is separately served by the change post (C's `updateCounts()`
3763            // DBE_VALUE at `:582`).
3764            if log_swept.contains(&field.as_str()) {
3765                sub_updates.push((field.clone(), val, EventMask::LOG | alarm_bits));
3766            }
3767        }
3768        for (field, val, _) in &sub_updates {
3769            self.record_value_post(field, val.clone());
3770        }
3771        sub_updates
3772    }
3773
3774    /// Basic process: process record, evaluate alarms, timestamp, build snapshot.
3775    /// This does NOT handle links — see process_with_context in database.rs.
3776    ///
3777    /// Returns the value/log snapshot plus a list of alarm-field posts
3778    /// (`SEVR`/`STAT`/`AMSG`/`ACKS`) with their individual C event masks.
3779    /// `SEVR` is posted `DBE_VALUE` only; `STAT`/`AMSG` carry `DBE_ALARM`
3780    /// (sevr/amsg change) and/or `DBE_VALUE` (stat change). The caller
3781    /// must fire these via `notify_field` so a `DBE_VALUE`-only `.SEVR`
3782    /// subscriber is not missed on an alarm-only change and a
3783    /// `DBE_ALARM`-only subscriber is not wrongly notified — C parity
3784    /// with `recGblResetAlarms` (recGbl.c:201-220), matching the
3785    /// `processing.rs` link path.
3786    pub fn process_local(
3787        &mut self,
3788    ) -> CaResult<(
3789        ProcessSnapshot,
3790        Vec<(&'static str, crate::server::recgbl::EventMask)>,
3791    )> {
3792        use crate::server::recgbl::{self, EventMask};
3793        const LCNT_ALARM_THRESHOLD: i16 = 10;
3794
3795        if self.pact.swap(true, std::sync::atomic::Ordering::AcqRel) {
3796            // C `dbProcess` PACT-active guard (dbAccess.c:544-557):
3797            //
3798            //   if ((precord->stat == SCAN_ALARM) ||
3799            //       (precord->lcnt++ < MAX_LOCK) ||
3800            //       (precord->sevr >= INVALID_ALARM)) goto all_done;
3801            //   recGblSetSevrMsg(precord, SCAN_ALARM, INVALID_ALARM,
3802            //                    "Async in progress");
3803            //
3804            // The alarm fires EXACTLY ONCE — on the attempt whose
3805            // pre-increment lcnt equals MAX_LOCK — and is then blocked
3806            // by the stat == SCAN_ALARM / sevr >= INVALID bails, the
3807            // same shape as the link path
3808            // (`process_record_with_links_inner`). The pre-fix guard
3809            // here used post-increment `lcnt >= threshold` with no
3810            // already-raised bail, so every reentrant attempt past the
3811            // threshold re-posted the unchanged SEVR/STAT/VAL (and the
3812            // first fire came one attempt early); it also wrote
3813            // sevr/stat directly, skipping `recGblSetSevrMsg` +
3814            // `recGblResetAlarms` — losing the "Async in progress"
3815            // AMSG and the acks bookkeeping the reset performs.
3816            let already_scan_alarm = self.common.stat == recgbl::alarm_status::SCAN_ALARM;
3817            let already_invalid = self.common.sevr >= AlarmSeverity::Invalid;
3818            let lcnt_before = self.common.lcnt;
3819            self.common.lcnt = lcnt_before.saturating_add(1);
3820            if already_scan_alarm || lcnt_before < LCNT_ALARM_THRESHOLD || already_invalid {
3821                return Ok((
3822                    ProcessSnapshot {
3823                        changed_fields: Vec::new(),
3824                    },
3825                    Vec::new(),
3826                ));
3827            }
3828            recgbl::rec_gbl_set_sevr_msg(
3829                &mut self.common,
3830                recgbl::alarm_status::SCAN_ALARM,
3831                AlarmSeverity::Invalid,
3832                "Async in progress",
3833            );
3834            let _ = recgbl::rec_gbl_reset_alarms(&mut self.common);
3835            // Per-field C masks (recGbl.c:201-220): this guard only
3836            // runs on a fresh SCAN_ALARM/INVALID raise, so sevr AND
3837            // stat both moved — SEVR posts DBE_VALUE, STAT/AMSG post
3838            // the shared `stat_mask` = DBE_ALARM|DBE_VALUE, VAL posts
3839            // DBE_VALUE|DBE_LOG plus `val_mask` = DBE_ALARM.
3840            let stat_mask = EventMask::ALARM | EventMask::VALUE;
3841            let mut changed_fields = Vec::new();
3842            if let Some(val) = self.record.val() {
3843                changed_fields.push((
3844                    "VAL".to_string(),
3845                    val,
3846                    EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
3847                ));
3848            }
3849            changed_fields.push((
3850                "SEVR".to_string(),
3851                EpicsValue::Short(self.common.sevr as i16),
3852                EventMask::VALUE,
3853            ));
3854            changed_fields.push((
3855                "STAT".to_string(),
3856                EpicsValue::Short(self.common.stat as i16),
3857                stat_mask,
3858            ));
3859            // AMSG carries "Async in progress" alongside the STAT
3860            // transition (C recGbl.c posts STAT and AMSG together
3861            // when any alarm field moved).
3862            changed_fields.push((
3863                "AMSG".to_string(),
3864                EpicsValue::String(self.common.amsg.clone().into()),
3865                stat_mask,
3866            ));
3867            return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
3868        }
3869        self.common.lcnt = 0;
3870        // RAII guard that resets `self.pact` to false on drop — both for the
3871        // normal exit path and for any `?` early return. The guard holds a raw
3872        // pointer rather than a reference because we still need `self` mutably
3873        // while the guard is alive (the record body below mutates other `self`
3874        // fields).
3875        //
3876        // This is the one PACT release that does not go through `leave_pact`,
3877        // and it provably frees nothing: `process_local` holds `&mut self` for
3878        // the whole PACT window, and a put-notify is parked only through
3879        // `park_notify_put`, which needs that same `&mut`. So no deferral can
3880        // be created inside the window, and the `swap(true)` above proved none
3881        // existed on entry (`deferred_notify_put.is_some()` ⟹ PACT).
3882        debug_assert!(
3883            self.deferred_notify_put.is_none(),
3884            "PactExit invariant: a parked put-notify implies PACT, which the \
3885             swap above proved was clear"
3886        );
3887        struct ProcessGuard(*const AtomicBool);
3888        // SAFETY: AtomicBool is Sync; raw pointers don't auto-derive
3889        // Send. We hand-roll Send because the ptr targets a field of
3890        // `self`, which the caller already proves can be borrowed
3891        // through this code path. The pointer is only ever read for an
3892        // atomic store, never written, dereferenced for raw access, or
3893        // escaped from this scope.
3894        unsafe impl Send for ProcessGuard {}
3895        impl Drop for ProcessGuard {
3896            fn drop(&mut self) {
3897                // SAFETY: `self.0` was constructed from
3898                // `&self.pact as *const AtomicBool` below, where
3899                // `self` is the live RecordInstance whose lifetime
3900                // strictly outlives `_guard`. RecordInstance is
3901                // !Unpin-equivalent in practice (we never move it
3902                // while held in the database's `Arc<RwLock<_>>`), so
3903                // the pointer remains valid until Drop runs.
3904                unsafe { &*self.0 }.store(false, std::sync::atomic::Ordering::Release);
3905            }
3906        }
3907        let _guard = ProcessGuard(&self.pact as *const AtomicBool);
3908
3909        // Call subroutine if registered (for sub/aSub records). Single owner
3910        // shared with the main engine path — see `run_registered_subroutine`.
3911        self.run_registered_subroutine()?;
3912        // Soft-Channel input records must skip the RVAL->VAL convert
3913        // (C `devAiSoft.c` `read_ai` returns 2 = "don't convert" for
3914        // every Soft-Channel input record, incl. one with a constant /
3915        // unset INP). Without this, `process_local` on a soft input
3916        // with a preset VAL — e.g. NaN — would run `convert()` and
3917        // clobber it, after which the UDF check below would see a
3918        // defined value and wrongly clear UDF. The
3919        // `processing.rs` link path already does this; `process_local`
3920        // is the separate foreign-call path (`db.process_record`) and
3921        // needs the same skip. "Raw Soft Channel" has a distinct DTYP
3922        // so it is excluded by `is_soft` and still runs convert.
3923        //
3924        // Gated on `soft_channel_skips_convert()` — identical to the
3925        // `processing.rs` link path — so this only suppresses the
3926        // `RVAL → VAL` convert step. `set_device_did_compute` is an
3927        // overloaded hook: `ai/bi/mbbi/mbbi_direct` read it as
3928        // "skip convert" (override true), but `epid` reads it as
3929        // "skip the whole built-in PID compute" (keeps default false).
3930        // Without this gate, a Soft-Channel `epid` driven through
3931        // `process_local` (`db.process_record`, e.g. QSRV group proc
3932        // members) would skip `do_pid()` entirely — the regression
3933        // d1032fe5 fixed on the `processing.rs` path only.
3934        {
3935            let is_soft = self.common.dtyp.is_empty() || self.common.dtyp == "Soft Channel";
3936            let is_output = self.record.can_device_write();
3937            if is_soft && !is_output && self.record.soft_channel_skips_convert() {
3938                self.record.set_device_did_compute(true);
3939            }
3940        }
3941        // Push framework-owned common state (UDF/PHAS/TSE/TSEL) so the
3942        // record's process() can see it — same as the processing.rs link
3943        // path. `process_local` is the foreign-call path
3944        // (`db.process_record`); without this a record driven through it
3945        // (e.g. QSRV group-process members) would not see UDF/TSE.
3946        {
3947            let ctx = self.common.process_context();
3948            self.record.set_process_context(&ctx);
3949        }
3950        let outcome = self.record.process()?;
3951        let process_result = outcome.result;
3952        // Note: process_local() does not execute ProcessActions — those are
3953        // handled by the full process_record_with_links() path in processing.rs.
3954
3955        // If the record reports it modified a metadata-class field during
3956        // process(), invalidate the metadata cache so the next snapshot
3957        // rebuilds from the new values. Default impl returns false, so
3958        // most records pay zero cost here.
3959        if self.record.took_metadata_change() {
3960            self.invalidate_metadata_cache();
3961            // mirror C db_post_events(precord, NULL, DBE_PROPERTY) after record processing.
3962            let fields: Vec<String> = self.subscribers.keys().cloned().collect();
3963            for f in fields {
3964                self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
3965            }
3966        }
3967
3968        if process_result == RecordProcessResult::AsyncPending {
3969            // Async: PACT stays set, no further processing this cycle
3970            // Don't clear processing flag (guard won't run — we leak it intentionally)
3971            std::mem::forget(_guard);
3972            return Ok((
3973                ProcessSnapshot {
3974                    changed_fields: Vec::new(),
3975                },
3976                Vec::new(),
3977            ));
3978        }
3979        if let RecordProcessResult::AsyncPendingNotify(fields) = process_result {
3980            // Intermediate notification (e.g. DMOV=0 at move start).
3981            // Unlike AsyncPending, we DO release the processing flag so
3982            // subsequent I/O Intr cycles can continue processing normally.
3983            self.common.time = crate::runtime::general_time::get_current();
3984            // Filter out fields that haven't actually changed, and update
3985            // MLST/last_posted for those that have. Each intermediate
3986            // post carries DBE_VALUE|DBE_LOG — C motor's mid-move
3987            // `db_post_events` calls use `DBE_VAL_LOG`
3988            // (motorRecord.cc:2606 DMOV, and every other do_work post);
3989            // no alarm transition ran on this pending pass.
3990            let mut changed_fields = Vec::new();
3991            for (name, val) in fields {
3992                let changed = match self.posted_value(&name) {
3993                    Some(prev) => prev != &val,
3994                    None => true,
3995                };
3996                if changed {
3997                    if name == "VAL" {
3998                        if let Some(f) = val.to_f64() {
3999                            self.put_coerced("MLST", f);
4000                            self.common.mlst = Some(f);
4001                        }
4002                    }
4003                    self.record_value_post(&name, val.clone());
4004                    changed_fields.push((name, val, EventMask::VALUE | EventMask::LOG));
4005                }
4006            }
4007            // _guard drops here, clearing the processing flag
4008            return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
4009        }
4010        if process_result == RecordProcessResult::CompleteNoEmit {
4011            // The record accumulated this cycle without emitting (compress
4012            // `status == 1`). C `compressRecord.c:365` runs the completion
4013            // epilogue (udf clear, timestamp, monitor, FLNK) only on an emit
4014            // cycle (`if (status != 1)`), so a non-emitting cycle must publish
4015            // nothing — skip the epilogue and return an empty snapshot, exactly
4016            // as the production engine path does in `processing.rs`. This keeps
4017            // the emit-gate uniform across both process-dispatch paths so the
4018            // invariant holds by construction, not by "process_local never
4019            // produces it". CompleteNoEmit is synchronous (PACT already
4020            // cleared); the `_guard` drops here, clearing the processing flag.
4021            return Ok((
4022                ProcessSnapshot {
4023                    changed_fields: Vec::new(),
4024                },
4025                Vec::new(),
4026            ));
4027        }
4028
4029        // `CompleteDeferOutput` (swait ODLY delay-start) is NOT special-cased
4030        // here: it deliberately shares the Complete value-side snapshot builder
4031        // below. C `swaitRecord.c::process` posts the value side (`monitor()`,
4032        // line 475) on the delaying cycle, so building the snapshot now is the
4033        // correct, parity-matching behavior — unlike `CompleteNoEmit` above,
4034        // whose fall-through would wrongly emit. The variant's *other* halves —
4035        // holding PACT across the delay and deferring OUT/OEVT/FLNK to the
4036        // `ReprocessAfter` continuation — are the engine path's responsibility
4037        // (`processing.rs::process_record_with_links_inner`); `process_local` is
4038        // a body-only test helper that dispatches no FLNK/output and no
4039        // `ProcessAction`, and no test drives a swait ODLY record through it. So
4040        // the invariant still holds by construction across both dispatch paths:
4041        // both publish the value side here, both leave the output side to the
4042        // engine.
4043
4044        // UDF update before alarm evaluation — C parity (see
4045        // `processing.rs`). A NaN / undefined value keeps UDF true so
4046        // `recGblCheckUDF` raises UDF_ALARM this cycle instead of the
4047        // record reporting a stale/garbage value with no alarm.
4048        if self.record.clears_udf() {
4049            self.common.udf = self.record.value_is_undefined() as u8;
4050        }
4051        // Per-record alarm hook (C `checkAlarms()`).
4052        self.record.check_alarms(&mut self.common);
4053
4054        // Evaluate alarms (accumulates into nsta/nsev)
4055        self.evaluate_alarms();
4056
4057        // Transfer nsta/nsev → sevr/stat, detect alarm change
4058        let alarm_result = recgbl::rec_gbl_reset_alarms(&mut self.common);
4059
4060        self.common.time = crate::runtime::general_time::get_current();
4061        // UDF already updated above — do not clear unconditionally.
4062
4063        // Deadband check for VAL monitor filtering
4064        let (include_val, include_archive) = self.check_deadband_ext();
4065        // C `recGblResetAlarms` `val_mask = DBE_ALARM`
4066        // (recGbl.c:194/203/212): every monitored-value post this cycle
4067        // carries DBE_ALARM when the severity/status OR the alarm
4068        // message moved — same parity rule as the `processing.rs`
4069        // paths.
4070        let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
4071            EventMask::ALARM
4072        } else {
4073            EventMask::NONE
4074        };
4075
4076        // Build snapshot
4077        let mut changed_fields = Vec::new();
4078        // Same deadband-field routing and per-field mask as the
4079        // `processing.rs` paths: the tracked field posts the classes
4080        // that actually fired (MDEL → DBE_VALUE, ADEL → DBE_LOG, alarm
4081        // movement → DBE_ALARM); a non-primary deadband field (motor
4082        // RBV — C motor `monitor()`, motorRecord.cc:3468-3507) leaves
4083        // VAL to the generic change-detection loop below.
4084        let deadband_field = self.record.monitor_deadband_field();
4085        // The mask every change-detected aux field posts with — owned by
4086        // `AuxPostMask`, the same resolver the `processing.rs` paths use, so
4087        // this builder cannot drift from them on what mask a field carries.
4088        let aux_post = AuxPostMask::of(self.record.as_ref());
4089        // The deadband field's post — mask owned by `deadband_post`, the single
4090        // assembler C's `db_post_events(&prec->val, monitor_mask)` maps to.
4091        let deadband = self.deadband_post(alarm_bits, include_val, include_archive);
4092        let deadband_mask = deadband.mask;
4093        if let Some((field, value)) = deadband.field {
4094            changed_fields.push((field, value, deadband_mask));
4095        }
4096        // C `recGblResetAlarms` (recGbl.c:201-220) posts each alarm
4097        // field with its OWN per-field mask, not one record-wide mask:
4098        //   * SEVR — DBE_VALUE, ONLY on a sevr change.
4099        //   * STAT — DBE_ALARM (sevr change) | DBE_VALUE (stat change).
4100        //   * ACKS — DBE_VALUE, only when an alarm field moved.
4101        // Pushing SEVR/STAT into `changed_fields` collapses them onto
4102        // the single record-wide `event_mask` (which carries ALARM on
4103        // `alarm_changed`): a DBE_VALUE-only `.SEVR` subscriber would
4104        // miss a stat-only-driven sevr change, and a DBE_ALARM-only
4105        // `.SEVR` subscriber would be wrongly notified. Post them via
4106        // `notify_field` with their individual masks instead — exactly
4107        // as the `processing.rs` link path does.
4108        let sevr_changed = self.common.sevr != alarm_result.prev_sevr;
4109        let stat_changed = self.common.stat != alarm_result.prev_stat;
4110        let stat_mask = {
4111            let mut m = EventMask::NONE;
4112            // C `recGblResetAlarms` carries DBE_ALARM on the STAT/AMSG
4113            // posts whenever the severity OR the alarm message moved —
4114            // not on a severity change alone. Aligning with the
4115            // `processing.rs` link path (and `complete_async_record`).
4116            if sevr_changed || alarm_result.amsg_changed {
4117                m |= EventMask::ALARM;
4118            }
4119            if stat_changed {
4120                m |= EventMask::VALUE;
4121            }
4122            m
4123        };
4124        let mut alarm_posts: Vec<(&'static str, EventMask)> = Vec::new();
4125        if sevr_changed {
4126            alarm_posts.push(("SEVR", EventMask::VALUE));
4127        }
4128        if !stat_mask.is_empty() {
4129            alarm_posts.push(("STAT", stat_mask));
4130            // AMSG shares STAT's mask — C posts it alongside STAT when
4131            // any alarm field moved.
4132            alarm_posts.push(("AMSG", stat_mask));
4133        }
4134        // C parity (recGbl.c:214-217): ACKS is posted (DBE_VALUE) whenever the
4135        // alarm-acknowledge rule fires — `acks_posted` already folds in C's
4136        // `if (stat_mask)` guard, and the post carries no value-change test.
4137        if alarm_result.acks_posted {
4138            alarm_posts.push(("ACKS", EventMask::VALUE));
4139        }
4140
4141        // The cycle's subscriber posts — assembled by the single owner
4142        // `collect_subscriber_posts`, shared with every `processing.rs` path.
4143        changed_fields.extend(self.collect_subscriber_posts(
4144            deadband_field,
4145            deadband_mask,
4146            alarm_bits,
4147            aux_post,
4148            include_val,
4149        ));
4150        // C waveform/aai/aao `monitor()` posts HASH with a literal
4151        // `DBE_VALUE` only on a content-hash change (waveformRecord.c:
4152        // 317-319), independent of the VAL post mask. `array_hash_changed`
4153        // was set by `check_deadband_ext` this cycle.
4154        if self.array_hash_changed {
4155            if let Some(h) = self.resolve_field("HASH") {
4156                changed_fields.push(("HASH".to_string(), h, EventMask::VALUE));
4157            }
4158        }
4159
4160        // No `.UDF` post — C `monitor()` posts UDF nowhere, and
4161        // `recGblResetAlarms` (recGbl.c:204-216) posts only SEVR/STAT/AMSG/
4162        // ACKS. A `.UDF` event exists only where C's generic `dbPut` posts
4163        // the field it wrote (dbAccess.c:1420-1430) — i.e. a client caput to
4164        // `.UDF` itself.
4165
4166        Ok((ProcessSnapshot { changed_fields }, alarm_posts))
4167    }
4168
4169    /// Put a f64 value into a record field, coercing to the field's native type.
4170    pub(crate) fn put_coerced(&mut self, field: &str, val: f64) {
4171        use crate::types::EpicsValue;
4172        let target_type = self
4173            .record
4174            .get_field(field)
4175            .map(|v| v.db_field_type())
4176            .unwrap_or(crate::types::DbFieldType::Double);
4177        let coerced = EpicsValue::Double(val).convert_to(target_type);
4178        let _ = self.record.put_field(field, coerced);
4179    }
4180
4181    /// Check MDEL/ADEL deadbands for VAL monitor/archive filtering.
4182    /// Returns `(monitor_trigger, archive_trigger)`.
4183    ///
4184    /// Updates `MLST`/`ALST` (record-owned) and the `CommonFields`
4185    /// `mlst/alst` shadow when a trigger fires. Records without
4186    /// MDEL/ADEL (e.g. motor) default to deadband=0 (any actual
4187    /// change triggers).
4188    ///
4189    /// Delegates per-axis deadband comparison to the free function
4190    /// [`check_deadband`] below — see that function's docstring for
4191    /// the four-quadrant NaN/infinity rule mirroring C
4192    /// `recGblCheckDeadband` (recGbl.c:345-370).
4193    ///
4194    /// **C-parity design note**: the Rust port uses `NaN` as the
4195    /// "never posted" sentinel for `MLST`/`ALST`. C achieves the
4196    /// same first-publish guarantee by allocating MLST/ALST in
4197    /// BSS-zeroed storage with a value of 0.0 that the C code is
4198    /// allowed to match against — but the first observed value is
4199    /// not necessarily 0.0, and the C rule "MLST==0 means never
4200    /// posted" relies on the deadband comparison `abs(val - 0.0)`
4201    /// firing on any non-zero first value. NaN is strictly more
4202    /// correct for the Rust port because a legitimate first
4203    /// `val=0.0` still fires on `NaN.is_nan() → true`. This
4204    /// sentinel-as-design is intentional, documented inside
4205    /// [`check_deadband`] (the `oldval.is_nan() → return true` short
4206    /// circuit). It is NOT a deviation inherited from an earlier
4207    /// silent compromise — `record_tests.rs::deadband_*` pins both
4208    /// the NaN-sentinel behaviour and the C four-quadrant transitions.
4209    /// The single owner of the deadband field's monitor post — C `monitor()`'s
4210    /// `db_post_events(&prec->val, monitor_mask)`, the one post every record
4211    /// makes for the value it deadbands.
4212    ///
4213    /// [`Self::check_deadband_ext`] decides WHETHER the MDEL/ADEL classes fired;
4214    /// this decides what the resulting post looks like, and it is the only place
4215    /// that assembles that mask. The three `processing.rs` snapshot builders and
4216    /// the `notify_monitors` path all route through here, so a record's mask rule
4217    /// cannot hold on one processing path and not another.
4218    ///
4219    /// Two record hooks strip C's `DBE_LOG` from the post:
4220    ///
4221    /// * [`Record::value_only_change_fields`] — C posts a literal `DBE_VALUE`
4222    ///   (scaler VAL, scalerRecord.c:478).
4223    /// * [`Record::fields_posted_with_monitor_mask`] — C posts
4224    ///   `monitor_mask | DBE_VALUE` (event VAL, eventRecord.c:163). `monitor_mask`
4225    ///   there is `recGblResetAlarms`'s return, i.e. the alarm bits alone, so the
4226    ///   post carries `DBE_VALUE` (+ `DBE_ALARM` when the alarm moved) and never
4227    ///   the archive `DBE_LOG` — an event's VAL reaches a `DBE_LOG` archiver on
4228    ///   no cycle at all.
4229    ///
4230    /// [`DeadbandPost::field`] is `None` when no class fired, i.e. when C's
4231    /// `if (monitor_mask)` guard would skip the post.
4232    /// C `monitor()`'s VALUE / LOG gate for the primary-value post —
4233    /// `(include_val, include_archive)`, the single owner every processing path
4234    /// feeds into [`Self::deadband_post`] and [`Self::collect_subscriber_posts`].
4235    /// Keeping it in one place is what stops the rule from holding on the
4236    /// synchronous path but not the async-continuation / put-notify paths.
4237    pub(crate) fn value_include_classes(&mut self) -> (bool, bool) {
4238        // fanout/seq "trigger" records post VAL only with the alarm events
4239        // `recGblResetAlarms` returns, never DBE_VALUE/DBE_LOG — see
4240        // `Record::process_posts_value_monitor`. The alarm bits still reach VAL
4241        // via `deadband_post`'s `alarm_bits`, so an alarm transition still posts
4242        // it; only the value/archive classes are suppressed.
4243        if !self.record.process_posts_value_monitor() {
4244            return (false, false);
4245        }
4246        match self.record.monitor_value_changed() {
4247            // lsi/lso post VALUE|LOG only when the string actually changed (C
4248            // `lsiRecord.c`/`lsoRecord.c` monitor: `len != olen || memcmp(oval,
4249            // val, len)`); they have no MDEL/ADEL deadband to express that, so
4250            // the gate is explicit. The MPST/APST `menuPost` "Always" override
4251            // OR-adds DBE_VALUE / DBE_LOG even on an unchanged cycle (C monitor:
4252            // `if (mpst == menuPost_Always) events |= DBE_VALUE; if (apst ==
4253            // menuPost_Always) events |= DBE_LOG;`).
4254            Some(changed) => {
4255                let (val_always, archive_always) = self.record.monitor_always_post();
4256                (changed || val_always, changed || archive_always)
4257            }
4258            None => {
4259                if self.record.uses_monitor_deadband() {
4260                    self.check_deadband_ext()
4261                } else {
4262                    // Binary records (bi/bo/busy/mbbi/mbbo): always post monitors
4263                    (true, true)
4264                }
4265            }
4266        }
4267    }
4268
4269    pub(crate) fn deadband_post(
4270        &self,
4271        alarm_bits: EventMask,
4272        include_val: bool,
4273        include_archive: bool,
4274    ) -> DeadbandPost {
4275        let field = self.record.monitor_deadband_field();
4276        let log_suppressed = self.record.value_only_change_fields().contains(&field)
4277            || self
4278                .record
4279                .fields_posted_with_monitor_mask()
4280                .contains(&field);
4281
4282        let mut mask = alarm_bits;
4283        if include_val {
4284            mask |= EventMask::VALUE;
4285        }
4286        if include_archive && !log_suppressed {
4287            mask |= EventMask::LOG;
4288        }
4289
4290        // The closed set applies to THIS post too. `process_posted_fields` is
4291        // "the CLOSED set of fields a process cycle of this record may post" —
4292        // and the deadband post is a post. A record whose C `monitor()` never
4293        // names the deadband field must not have one invented for it: transform
4294        // `monitor()` (transformRecord.c:786-808) walks A..P and posts no VAL
4295        // at all — VAL is an inert dummy (`:422`) — so an alarm cycle, whose
4296        // `alarm_bits` alone make `mask` non-empty, was firing a `.VAL` monitor
4297        // C never sends. Gating here rather than at each builder keeps the
4298        // single owner of the deadband post the single enforcer of the set.
4299        let in_closed_set = self
4300            .record
4301            .process_posted_fields()
4302            .is_none_or(|allowed| allowed.contains(&field));
4303
4304        let value = if mask.is_empty() || !in_closed_set {
4305            None
4306        } else if field == "VAL" {
4307            self.record.val()
4308        } else {
4309            self.resolve_field(field)
4310        };
4311        DeadbandPost {
4312            mask,
4313            field: value.map(|v| (field.to_string(), v)),
4314        }
4315    }
4316
4317    pub fn check_deadband_ext(&mut self) -> (bool, bool) {
4318        // C waveform/aai/aao `monitor()` (waveformRecord.c:291-326) replaces
4319        // the analog MDEL/ADEL deadband with the MPST/APST "Always vs On
4320        // Change" mechanism: the record hashes its array content and posts
4321        // `DBE_VALUE`/`DBE_LOG` either always or only when the hash changed,
4322        // and posts `HASH` (`DBE_VALUE`) on a hash change. The record owns
4323        // the hash compute + `HASH` update; `array_hash_changed` carries the
4324        // event to the snapshot builders, which post `HASH` (the field is
4325        // excluded from the generic change-detection loop via
4326        // `event_posted_fields`).
4327        if let Some(post) = self.record.array_monitor_post() {
4328            self.array_hash_changed = post.hash_changed;
4329            return (post.post_value, post.post_archive);
4330        }
4331        self.array_hash_changed = false;
4332
4333        // The deadband is evaluated against `monitor_deadband_value()`,
4334        // not `val()` directly: a record whose monitored quantity is
4335        // not its primary value (e.g. the motor record, VAL=setpoint /
4336        // RBV=readback — C `monitor()` deadbands RBV) overrides that
4337        // hook. Default is `val()`, so other records are unaffected.
4338        let val = match self
4339            .record
4340            .monitor_deadband_value()
4341            .and_then(|v| v.to_f64())
4342        {
4343            Some(v) => v,
4344            None => return (true, true),
4345        };
4346
4347        let mdel = self
4348            .record
4349            .get_field("MDEL")
4350            .and_then(|v| v.to_f64())
4351            .unwrap_or(0.0);
4352        let adel = self
4353            .record
4354            .get_field("ADEL")
4355            .and_then(|v| v.to_f64())
4356            .unwrap_or(0.0);
4357
4358        // Use record's MLST/ALST fields if available, otherwise fall back to CommonFields
4359        let mlst = self
4360            .record
4361            .get_field("MLST")
4362            .and_then(|v| v.to_f64())
4363            .or(self.common.mlst)
4364            .unwrap_or(f64::NAN);
4365        let alst = self
4366            .record
4367            .get_field("ALST")
4368            .and_then(|v| v.to_f64())
4369            .or(self.common.alst)
4370            .unwrap_or(f64::NAN);
4371
4372        let monitor_trigger = check_deadband(val, mlst, mdel);
4373        let archive_trigger = check_deadband(val, alst, adel);
4374
4375        if archive_trigger {
4376            self.put_coerced("ALST", val);
4377            self.common.alst = Some(val);
4378        }
4379        if monitor_trigger {
4380            self.put_coerced("MLST", val);
4381            self.common.mlst = Some(val);
4382        }
4383
4384        (monitor_trigger, archive_trigger)
4385    }
4386
4387    /// Build a Snapshot for a given value, populated with the record's display metadata.
4388    /// Uses the metadata cache so the populate cost is paid at most once
4389    /// per metadata-stable interval (cf. `cached_metadata`).
4390    pub fn make_monitor_snapshot(
4391        &self,
4392        field: &str,
4393        value: EpicsValue,
4394    ) -> super::super::snapshot::Snapshot {
4395        // A monitor update is posted from the record's own change-detection
4396        // loop, which hands over the STORED variant. Project it onto the
4397        // field's declared type here, at the same owner the GET path and the
4398        // CA create-channel path use, or a client that was told `DBR_ENUM` at
4399        // create time would be posted a `DBR_SHORT` update.
4400        let value = self.project_to_declared_type(field, value);
4401        let mut snap = super::super::snapshot::Snapshot::new(
4402            value,
4403            self.common.stat,
4404            self.common.sevr as u16,
4405            self.common.time,
4406        );
4407        // Carry the record's `utag` into the monitor update's
4408        // `timeStamp.userTag`, same as the GET path
4409        // (`snapshot_for_field`) and pvxs `iocsource.cpp:245`. Narrows
4410        // the 64-bit `epicsUTag` to the int32 wire field by low-32-bit
4411        // truncation.
4412        snap.user_tag = self.common.utag as i32;
4413        // Same amsg carry as the GET path (`snapshot_for_field`): a monitor
4414        // update serves the record's own `common.amsg`, so PVA
4415        // `alarm.message` matches a read of the same channel.
4416        snap.alarm.amsg = self.common.amsg.clone();
4417        let meta = self.cached_metadata();
4418        snap.display = meta.display;
4419        snap.control = meta.control;
4420        snap.enums = meta.enums;
4421        // Same per-field routing owner as the GET path — a monitor update must
4422        // carry the same metadata a read of that field would.
4423        self.route_field_metadata(field, &mut snap);
4424        // Per-field RSET metadata, same as the GET path
4425        // (`snapshot_for_field`) — a monitor update for VELO must carry
4426        // VELO's limits, not the record-level VAL limits.
4427        self.apply_field_metadata_override(field, &mut snap);
4428        // A monitored DBF_MENU field carries the same DBR_ENUM value and
4429        // choice labels as the GET path, so a `camonitor`/`pvmonitor`
4430        // update shows the menu label, not a bare index.
4431        self.attach_menu_enum(field, &mut snap);
4432        // Same owner, same settled value, same mask as the GET path.
4433        self.assign_property_support(field, &mut snap);
4434        snap
4435    }
4436
4437    /// Apply a record's per-field metadata override (C RSET
4438    /// `get_units`/`get_precision`/`get_graphic_double`/
4439    /// `get_control_double`/`get_alarm_double`, all keyed by field)
4440    /// over the cached record-level metadata. Shared by the GET and
4441    /// monitor snapshot builders. Computed live on every call — never
4442    /// cached — so overrides derived from fields outside the
4443    /// `is_metadata_field` set cannot go stale.
4444    ///
4445    /// This is also where the record-level `Q:form` info tag is narrowed to
4446    /// the served field: QSRV assigns `display.form.index` only when the
4447    /// channel addresses the VAL field (`IOCSource::initialize` gates it on
4448    /// `dbIsValueField(dbChannelFldDes(chan))`, `iocsource.cpp:53`; the form
4449    /// *menu*, `form.choices`, is published for every field). The metadata
4450    /// cache is per-record, so a channel on `REC.RVAL` of a record carrying
4451    /// `info(Q:form, "Hex")` used to report Hex where pvxs reports Default.
4452    /// Both `Snapshot` producers (`snapshot_for_field` for GET,
4453    /// `make_monitor_snapshot` for updates) run this one owner, so
4454    /// `DisplayInfo::form` means exactly one thing on every path: the form
4455    /// index that applies to THIS field.
4456    fn apply_field_metadata_override(
4457        &self,
4458        field: &str,
4459        snap: &mut super::super::snapshot::Snapshot,
4460    ) {
4461        if let Some(display) = snap.display.as_mut()
4462            && !crate::server::database::is_value_field(field)
4463        {
4464            display.form = 0;
4465        }
4466        let Some(ov) = self.record.field_metadata_override(field) else {
4467            return;
4468        };
4469        if ov.units.is_some()
4470            || ov.precision.is_some()
4471            || ov.disp_limits.is_some()
4472            || ov.alarm_limits.is_some()
4473        {
4474            let d = snap.display.get_or_insert_with(Default::default);
4475            if let Some(units) = ov.units {
4476                d.units = units;
4477            }
4478            if let Some(precision) = ov.precision {
4479                d.precision = precision;
4480            }
4481            if let Some((upper, lower)) = ov.disp_limits {
4482                d.upper_disp_limit = upper;
4483                d.lower_disp_limit = lower;
4484            }
4485            if let Some((hihi, high, low, lolo)) = ov.alarm_limits {
4486                d.upper_alarm_limit = hihi;
4487                d.upper_warning_limit = high;
4488                d.lower_warning_limit = low;
4489                d.lower_alarm_limit = lolo;
4490            }
4491        }
4492        if let Some((upper, lower)) = ov.ctrl_limits {
4493            let c = snap.control.get_or_insert_with(Default::default);
4494            c.upper_ctrl_limit = upper;
4495            c.lower_ctrl_limit = lower;
4496        }
4497    }
4498
4499    /// C's rset metadata slots route **per field**, on `dbGetFieldIndex`. The
4500    /// port's metadata cache is the record's VAL metadata, and serving it to
4501    /// every field is what made a non-VAL field report VAL's limits.
4502    ///
4503    /// Every base record's `get_control_double` / `get_alarm_double` has the
4504    /// same two-arm shape: a listed set of field indices that take the
4505    /// record's own limits, and a `default:` arm that hands the field to
4506    /// `recGblGetControlDouble` / `recGblGetAlarmDouble` — the field TYPE's
4507    /// numeric range, and four NaN. This routes the `default:` arm; a listed
4508    /// field keeps the cache, which already holds exactly the record's own
4509    /// limits (and already distinguishes `ao`'s DRVH/DRVL from `ai`'s
4510    /// HOPR/LOPR).
4511    ///
4512    /// The three slots' listed sets are **different**, and each has its own
4513    /// owner here: [`Self::control_explicit_field`],
4514    /// [`Self::graphic_explicit_field`] and [`Self::alarm_explicit_field`].
4515    /// They are separate switches over separate field lists in C, so the
4516    /// membership question is asked once per slot, never once for both — and
4517    /// each list varies by record TYPE, so it is asked once per type too.
4518    ///
4519    /// Measured on a real `softIocPVX` against `record(calc,"X"){}`:
4520    /// `.PHAS` (DBF_SHORT, unlisted) serves control ±32767 — the SHRT range —
4521    /// while `.VAL` and `.HIHI` (both listed) serve 0/0 from HOPR/LOPR.
4522    ///
4523    /// Deliberately NOT routed here, and why:
4524    ///
4525    /// * **display (graphic) limits.** Unlike control, the `default:` arm of
4526    ///   `get_graphic_double` tries a LINK first
4527    ///   (`calcRecord.c` `get_linkNumber` → `dbGetGraphicLimits`) and only
4528    ///   falls to `recGbl` for a field that backs no link. A constant (unset)
4529    ///   link has no metadata getters, so the `dbAccess.c:216` 0/0 seed stands
4530    ///   — measured: `CALC.A` serves display 0/0 but control ±1e300. Which
4531    ///   fields back links is per-record C knowledge the port models nowhere
4532    ///   (no `FieldDesc` relation, no trait hook), so defaulting display to the
4533    ///   type range would CREATE defects on every link-backed field.
4534    /// * **units / precision.** Same link-first shape
4535    ///   (`calcRecord.c` `get_units`, `get_precision`).
4536    ///
4537    /// Both remain a measured residue rather than a guess.
4538    ///
4539    /// The last arm is not the same for every record type — a slot can also
4540    /// fall through WITHOUT delegating, keeping the seed. That fact is one bit
4541    /// per record type, read from its C source: [`control_default_arm`].
4542    fn route_field_metadata(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
4543        // The rset slots this record type actually supplies. A NULL slot makes
4544        // `dbAccess.c` clear the option bit, so the leaf is never served and
4545        // there is nothing to route — minting a value here would put a
4546        // fabricated number on the CA wire, which has no marking layer to
4547        // suppress it (`codec.rs` `get_limits` reads these structs ungated).
4548        let slots = self.record.property_support();
4549        let rtype = self.record.record_type();
4550
4551        // C `get_control_double`'s last arm. No base record routes control
4552        // through a link: `dbGetControlLimits` has zero callers in all of
4553        // base, so unlike display this arm needs no link branch.
4554        if slots.control_double && !Self::control_explicit_field(rtype, field) {
4555            let (upper, lower) =
4556                match super::record_trait::control_default_arm(self.record.record_type()) {
4557                    // `recGblGetControlDouble` → `getMaxRangeValues(field_type)`.
4558                    // A type with no case in C's switch (STRING/MENU/DEVICE/links)
4559                    // is written by nothing, leaving the `dbAccess.c:256` seed —
4560                    // which is 0/0, exactly what `unwrap_or` supplies.
4561                    super::record_trait::RsetDefaultArm::RecGblRange => {
4562                        self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
4563                    }
4564                    // The slot exists but writes nothing here, so the same
4565                    // `dbAccess.c:256` seed stands. Modelled as a value rather
4566                    // than as `None`: C's option bit is ON (the slot is supplied
4567                    // and returned 0), so the leaf IS served — carrying the seed.
4568                    super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
4569                };
4570            snap.control = Some(super::super::snapshot::ControlInfo {
4571                upper_ctrl_limit: upper,
4572                lower_ctrl_limit: lower,
4573            });
4574        }
4575
4576        // C `get_graphic_double`'s last arm. Unlike control this one has a LINK
4577        // branch ahead of the recGbl call, so the three answers are: keep the
4578        // cache (listed on HOPR/LOPR), the link's limits, or the default arm.
4579        if slots.graphic_double && !Self::graphic_explicit_field(rtype, field) {
4580            let (upper, lower) = if Self::graphic_link_backed_field(rtype, field) {
4581                // `dbGetGraphicLimits` on a CONSTANT link writes nothing — a
4582                // constant has no metadata getters — so the `dbAccess.c:216`
4583                // seed stands. The port has no link metadata to consult, and a
4584                // link that IS connected would be answered by the upstream
4585                // record, which this routing does not model either; both land
4586                // here as the seed.
4587                (0.0, 0.0)
4588            } else {
4589                match super::record_trait::graphic_default_arm(rtype) {
4590                    super::record_trait::RsetDefaultArm::RecGblRange => {
4591                        self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
4592                    }
4593                    super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
4594                }
4595            };
4596            let d = snap.display.get_or_insert_with(Default::default);
4597            d.upper_disp_limit = upper;
4598            d.lower_disp_limit = lower;
4599        }
4600
4601        // C `get_alarm_double`, BOTH arms. This branch owns the four limits
4602        // outright: `slots.alarm_double` is exactly the condition under which
4603        // `getProperties` assigns the four `valueAlarm.*Limit` leaves, so
4604        // whenever the leaves are served this assigns them.
4605        //
4606        // The explicit arm used to be left to the record-level metadata cache
4607        // (`populate_display_info`), whose `match rtype` covered only some of
4608        // the types that supply the slot. A type it missed reached the wire
4609        // with `snap.display == None` and the four leaves kept the NT's
4610        // structural 0 — measured: DFANOUT.VAL, SEL.VAL and SUB.VAL served 0
4611        // where C serves NaN. That made "which limits does VAL carry" depend on
4612        // a match arm existing somewhere else, which is the dual meaning this
4613        // single owner removes.
4614        if slots.alarm_double {
4615            let (hihi, high, low, lolo) = if Self::alarm_explicit_field(rtype, field) {
4616                self.explicit_alarm_limits(rtype)
4617            } else {
4618                // The link-backed sibling arm lands on the same answer:
4619                // `dbAccess.c:294` seeds four NaN, and a constant link supplies
4620                // no alarm limits to overwrite them.
4621                crate::server::recgbl::rec_gbl_get_alarm_double()
4622            };
4623            // The four alarm limits live on DisplayInfo because that mirrors
4624            // C's `dbr_gr_double` packing, which the CA encoder depends on.
4625            // Minting it here is safe: every other DisplayInfo field defaults
4626            // to the same value the `None` path already served.
4627            let d = snap.display.get_or_insert_with(Default::default);
4628            d.upper_alarm_limit = hihi;
4629            d.upper_warning_limit = high;
4630            d.lower_warning_limit = low;
4631            d.lower_alarm_limit = lolo;
4632        }
4633    }
4634
4635    /// The four limits C's `get_alarm_double` serves for the fields its rset
4636    /// lists — [`alarm_explicit_fields`](super::record_trait::alarm_explicit_fields).
4637    ///
4638    /// Read through [`Self::resolve_field`], the same unified accessor C's
4639    /// `prec->hihi` is. The port stores the eight alarm fields in one of two
4640    /// disjoint homes — `common.analog_alarm` for the types with the analog
4641    /// ladder (`ai`/`ao`/`calc`/…), the record's own struct for the types
4642    /// without it (`dfanout`/`sel`) — and `resolve_field` spans both. Reading
4643    /// the ladder slot directly instead would answer NaN for every `dfanout`
4644    /// and `sel` no matter how its HIHI/HHSV were set, because those two types
4645    /// have no slot at all.
4646    fn explicit_alarm_limits(&self, rtype: &str) -> (f64, f64, f64, f64) {
4647        let limit = |name: &str| {
4648            self.resolve_field(name)
4649                .and_then(|v| v.to_f64())
4650                .unwrap_or(0.0)
4651        };
4652        // The raw stored ordinal, NOT clamped to 0..=3: C tests `prec->hhsv`
4653        // for NONZERO, so an out-of-range severity still enables its limit.
4654        let severity = |name: &str| {
4655            self.resolve_field(name)
4656                .and_then(|v| v.to_f64())
4657                .unwrap_or(0.0) as i16
4658        };
4659        match super::record_trait::alarm_val_arm(rtype) {
4660            super::record_trait::AlarmValArm::Unconditional => {
4661                (limit("HIHI"), limit("HIGH"), limit("LOW"), limit("LOLO"))
4662            }
4663            super::record_trait::AlarmValArm::Gated => (
4664                gated(severity("HHSV"), limit("HIHI")),
4665                gated(severity("HSV"), limit("HIGH")),
4666                gated(severity("LSV"), limit("LOW")),
4667                gated(severity("LLSV"), limit("LOLO")),
4668            ),
4669        }
4670    }
4671
4672    /// The fields C's **`get_control_double`** answers with the record's own
4673    /// cached limits, rather than letting them fall to the `default:` arm.
4674    ///
4675    /// "VAL plus the seven alarm bands" is one type's list, not the shared
4676    /// one: it holds for `ai` (`aiRecord.c:267-288`), `ao`, `calc`, `calcout`,
4677    /// `longin`, `longout`, `int64in`, `int64out` and `sub`
4678    /// (`subRecord.c:272-292`) — the `_` arm — and for no other type. Every
4679    /// list below is transcribed from that type's own rset:
4680    ///
4681    /// * `aSub` (`aSubRecord.c:372-376`) is a bare `recGblGetControlDouble`:
4682    ///   it lists NOTHING, VAL included.
4683    /// * `seq` (`seqRecord.c:342-353`) lists only DLYn and `bo`
4684    ///   (`boRecord.c:310-318`) only HIGH — and both answer a LITERAL rather
4685    ///   than the cache, so they come from
4686    ///   [`Record::field_metadata_override`] (which runs after this routing
4687    ///   and wins over the `default:` arm). Nothing of these two types keeps
4688    ///   the cache, VAL included.
4689    /// * `dfanout` (`dfanoutRecord.c:197-213`) lists VAL and the three
4690    ///   latches but NOT the four bands.
4691    /// * `sel` (`selRecord.c:203-235`) lists the eight plus `A`..`L` /
4692    ///   `LA`..`LL`; `acalcout`/`scalcout` (`aCalcoutRecord.c:793-822`,
4693    ///   `sCalcoutRecord.c:653-680`) list VAL and the four bands but NOT the
4694    ///   latches, plus `A`..`L` / `PA`..`PL`.
4695    /// * `epid` (`epidRecord.c:157-180`) lists VAL, the four bands and CVAL on
4696    ///   HOPR/LOPR; `motor` (`motorRecord.cc:3269-3305`) lists VAL and RBV on
4697    ///   HLM/LLM.
4698    /// * the array types (`waveformRecord.c:268-289`, `aaiRecord.c:293-310`,
4699    ///   `aaoRecord.c:296-313`, `compressRecord.c:487-502`,
4700    ///   `histogramRecord.c:458-475`, `subArrayRecord.c:262-291`) list VAL
4701    ///   alone on the cache — their other listed fields answer computed spans,
4702    ///   so those too come from [`Record::field_metadata_override`].
4703    ///
4704    /// Fields whose listed case answers something OTHER than the record's
4705    /// cached limits are deliberately absent — `motor`'s DVAL/DRBV (DHLM/DLLM)
4706    /// and `epid`'s OVAL/P/I/D (DRVH/DRVL) have no override yet and so still
4707    /// take the `default:` arm.
4708    ///
4709    /// **Not** the other two slots' lists — see [`Self::alarm_explicit_field`]
4710    /// (smaller) and [`Self::graphic_explicit_field`] (larger, and cut short
4711    /// for different types). C's three rset arms are separate switches over
4712    /// separate field lists, so one shared predicate could only ever be right
4713    /// for one of them.
4714    fn control_explicit_field(rtype: &str, field: &str) -> bool {
4715        // The two types that list nothing the cache can answer, VAL included.
4716        if matches!(rtype, "aSub" | "seq" | "bo") {
4717            return false;
4718        }
4719        if crate::server::database::is_value_field(field) {
4720            return true;
4721        }
4722        let f = field.to_ascii_uppercase();
4723        let bands: &[&str] = match rtype {
4724            "dfanout" => &["LALM", "ALST", "MLST"],
4725            "acalcout" | "scalcout" | "epid" => &["HIHI", "HIGH", "LOW", "LOLO"],
4726            "waveform" | "aai" | "aao" | "compress" | "histogram" | "subArray" | "motor" => &[],
4727            _ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
4728        };
4729        if bands.contains(&f.as_str()) {
4730            return true;
4731        }
4732        match rtype {
4733            // sel's args are 12 (`SEL_MAX`), not the calc family's 21.
4734            "sel" => Self::calc_arg_field(&f, 12),
4735            "acalcout" | "scalcout" => {
4736                Self::calc_arg_field(&f, 12)
4737                    || matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
4738            }
4739            "epid" => f == "CVAL",
4740            "motor" => f == "RBV",
4741            _ => false,
4742        }
4743    }
4744
4745    /// The fields C's **`get_alarm_double`** lists explicitly — **VAL alone**,
4746    /// not the eight [`Self::control_explicit_field`] lists.
4747    ///
4748    /// Transcribed from every rset in base that supplies the slot; each is a
4749    /// bare `if (dbGetFieldIndex(paddr) == indexof(VAL))` with every other
4750    /// field falling to `recGblGetAlarmDouble` (`recGbl.c:155-162`, four NaN):
4751    /// `aiRecord.c:293`, `aoRecord.c:365`, `calcRecord.c:258`,
4752    /// `calcoutRecord.c`, `dfanoutRecord.c:216`, `int64inRecord.c:235`,
4753    /// `int64outRecord.c`, `longinRecord.c`, `longoutRecord.c`,
4754    /// `selRecord.c:222`, `subRecord.c:236`.
4755    ///
4756    /// So `.HIHI` serves VAL's *control* limits but NOT VAL's *alarm* limits —
4757    /// the band fields' four alarm limits are the recGbl NaN. Routing both
4758    /// slots off one VAL-class predicate is what put the record's own
4759    /// valueAlarm limits on all eight.
4760    ///
4761    /// Which fields each type lists — and the fact that some list none, and
4762    /// that `motor` lists two — is one per-type table,
4763    /// [`alarm_explicit_fields`](super::record_trait::alarm_explicit_fields);
4764    /// what that listed arm ANSWERS is its twin,
4765    /// [`alarm_val_arm`](super::record_trait::alarm_val_arm). Keeping the two
4766    /// questions in one place is what lets this predicate stay a pure
4767    /// membership test.
4768    fn alarm_explicit_field(rtype: &str, field: &str) -> bool {
4769        super::record_trait::alarm_explicit_fields(rtype)
4770            .iter()
4771            .any(|f| field.eq_ignore_ascii_case(f))
4772    }
4773
4774    /// `A`..`A+n-1` (a single letter) or `LA`..`LA+n-1` — C's calc-family
4775    /// argument fields, addressed by index range rather than by name.
4776    ///
4777    /// `calcRecord.c:161-167` / `calcoutRecord.c:417-423` test
4778    /// `idx >= indexof(A) && idx < indexof(A) + CALCPERFORM_NARGS`, and the dbd
4779    /// declares those `CALCPERFORM_NARGS` fields contiguously as the single
4780    /// letters `A`..`U` (`postfix.h:29` = 21, `calcRecord.dbd.pod:801-985`), so
4781    /// the index range and the letter range are the same set.
4782    fn calc_arg_field(field: &str, nargs: u8) -> bool {
4783        let last = b'A' + nargs - 1;
4784        match field.as_bytes() {
4785            [c] => c.is_ascii_uppercase() && *c <= last,
4786            [b'L', c] => c.is_ascii_uppercase() && *c <= last,
4787            _ => false,
4788        }
4789    }
4790
4791    /// The fields C's **`get_graphic_double`** answers with the record's own
4792    /// `HOPR`/`LOPR` — which is exactly what the VAL metadata cache already
4793    /// holds, so routing must leave them on it.
4794    ///
4795    /// The third membership question, and a third distinct set: the alarm arm
4796    /// lists VAL alone and the control arm lists the eight, but graphic lists
4797    /// the eight PLUS a per-type tail, and two types cut it short.
4798    ///
4799    /// * base analog (`aiRecord.c:244-266`, `aoRecord.c:316-339`,
4800    ///   `calcRecord.c:187-212`, `calcoutRecord.c:452-484`,
4801    ///   `subRecord.c:222-247`, `selRecord.c:181-201`,
4802    ///   `dfanoutRecord.c:181-195`, `longinRecord.c:190-204`,
4803    ///   `longoutRecord.c`, `int64inRecord.c:196-210`, `int64outRecord.c`):
4804    ///   the eight.
4805    /// * `acalcout`/`scalcout` (`aCalcoutRecord.c:1046`, `sCalcoutRecord.c:906`)
4806    ///   list only VAL/HIHI/HIGH/LOW/LOLO — NOT LALM/ALST/MLST — plus the
4807    ///   `A`..`L` and `PA`..`PL` ranges.
4808    /// * `sel` (`selRecord.c:193-196`) also lists `A`..`L` / `LA`..`LL`, via a
4809    ///   GCC case range. It has no link arm at all, so its args are HOPR/LOPR
4810    ///   where calc's identically-named ones are link-backed.
4811    /// * the SVAL family (`aiRecord.c:253`, `longinRecord.c`,
4812    ///   `int64inRecord.c:205`), `ao`'s `OVAL`/`PVAL`/`IVOV`
4813    ///   (`aoRecord.c:322-338`), and `compress`'s `IHIL`/`ILIL`
4814    ///   (`compressRecord.c:474-476`).
4815    ///
4816    /// Fields whose graphic case answers something OTHER than HOPR/LOPR are
4817    /// NOT here — they cannot keep the cache and are supplied by
4818    /// [`Record::field_metadata_override`] instead (`histogram` WDTH,
4819    /// `subArray`/`waveform`/`aai`/`aao` index fields, `seq` DLYn,
4820    /// `calcout` ODLY).
4821    fn graphic_explicit_field(rtype: &str, field: &str) -> bool {
4822        // The two types that do not list VAL. Neither switch is keyed on
4823        // VAL at all: `seqRecord.c:282-297` keys on `index - indexof(DLY0)`,
4824        // so every field BELOW DLY0 — VAL included — reaches
4825        // `recGblGetGraphicDouble`; `aSubRecord.c:350-368` keys on the link
4826        // number, and VAL is neither an inlink nor an outlink, so it falls out
4827        // having written nothing (the `graphic_default_arm` Seed).
4828        //
4829        // Measured: `SEQ.VAL` served display 0/0 — the empty VAL cache — where
4830        // C serves the DBF_LONG range.
4831        if matches!(rtype, "seq" | "aSub") {
4832            return false;
4833        }
4834        if crate::server::database::is_value_field(field) {
4835            return true;
4836        }
4837        let f = field.to_ascii_uppercase();
4838        let bands: &[&str] = match rtype {
4839            "acalcout" | "scalcout" => &["HIHI", "HIGH", "LOW", "LOLO"],
4840            _ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
4841        };
4842        if bands.contains(&f.as_str()) {
4843            return true;
4844        }
4845        match rtype {
4846            "ai" | "longin" | "int64in" => f == "SVAL",
4847            "ao" => matches!(f.as_str(), "OVAL" | "PVAL" | "IVOV"),
4848            "compress" => matches!(f.as_str(), "IHIL" | "ILIL"),
4849            // sel's args are 12 (`SEL_MAX`), not the calc family's 21.
4850            "sel" => Self::calc_arg_field(&f, 12),
4851            // A..L and PA..PL, both to HOPR/LOPR.
4852            "acalcout" | "scalcout" => {
4853                Self::calc_arg_field(&f, 12)
4854                    || matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
4855            }
4856            _ => false,
4857        }
4858    }
4859
4860    /// The fields whose C `get_graphic_double` routes through a LINK —
4861    /// `dbGetGraphicLimits` on the link that backs the field, not the field's
4862    /// own type range.
4863    ///
4864    /// This is the one thing display needs that control never did:
4865    /// `dbGetControlLimits` has zero callers in all of base, so the control
4866    /// arm had no link branch to model. Graphic does, and it is what makes the
4867    /// display default arm NOT a straight flip to the type range.
4868    ///
4869    /// A link left unset is a CONSTANT link, which supplies no metadata
4870    /// getters, so `dbGetGraphicLimits` writes nothing and the
4871    /// `dbAccess.c:216` `(0.0, 0.0)` seed stands — measured: `CALC.A` serves
4872    /// display 0/0 where its DBF_DOUBLE type range would be ±1e300, while
4873    /// `CALC.PHAS` (no link) serves the DBF_SHORT range ±32767.
4874    ///
4875    /// Four types, both by mechanical index test:
4876    /// * `calc`/`calcout`/`sub` — `get_linkNumber` (`calcRecord.c:161-167`,
4877    ///   `calcoutRecord.c:417-423`, `subRecord.c:198-204`): `A`..`A+NARGS` and
4878    ///   `LA`..`LA+NARGS`, both onto `&prec->inpa + n`. calc/calcout use
4879    ///   `CALCPERFORM_NARGS` (21); `sub` uses `INP_ARG_MAX`
4880    ///   (`subRecord.c:89`), also 21.
4881    /// * `seq` — `seqRecord.c:322-338`: field offset from `DLY0` with
4882    ///   `offset & 3 == 2` is `DOn`, routed through `get_dol(prec, offset)`.
4883    ///
4884    /// `sel` names its args the same way and is deliberately NOT here: its
4885    /// rset lists them explicitly on HOPR/LOPR and calls `dbGetGraphicLimits`
4886    /// nowhere.
4887    fn graphic_link_backed_field(rtype: &str, field: &str) -> bool {
4888        let f = field.to_ascii_uppercase();
4889        match rtype {
4890            "calc" | "calcout" | "sub" => Self::calc_arg_field(&f, 21),
4891            // DLY0/DOL0/DO0/LNK0, DLY1/... — DOn is offset 2 of each group of
4892            // four, i.e. the `DO` prefix over the same 0-F suffix set.
4893            "seq" => {
4894                matches!(f.as_bytes(), [b'D', b'O', c] if c.is_ascii_digit() || (b'A'..=b'F').contains(c))
4895            }
4896            _ => false,
4897        }
4898    }
4899
4900    /// The field's type as the **dbd declares it**, which is the only type
4901    /// `recGblGetPrec` / `getMaxRangeValues` ever see.
4902    ///
4903    /// C reads `pdbFldDes->field_type` (`recGbl.c:127`, `:151`, `:169`) — the
4904    /// STATIC descriptor — so a `cvt_dbaddr` retype (the port's
4905    /// `runtime_typed`, DBF_NOACCESS in the dbd) never reaches the switch and
4906    /// the switch has no case for it. `None` reproduces that: no case, no
4907    /// write.
4908    fn static_field_type(&self, field: &str) -> Option<crate::types::DbFieldType> {
4909        let desc = self.field_desc(field)?;
4910        (!desc.runtime_typed).then_some(desc.dbf_type)
4911    }
4912
4913    /// `recGblGetGraphicDouble` / `recGblGetControlDouble` for `field` — the
4914    /// same `getMaxRangeValues` table both C entry points share
4915    /// (`recGbl.c:146-171`). `None` where C's switch has no case (STRING,
4916    /// MENU, DEVICE, NOACCESS, links), which writes nothing.
4917    fn rec_gbl_range_for(&self, field: &str) -> Option<(f64, f64)> {
4918        let desc = self.field_desc(field)?;
4919        crate::server::recgbl::rec_gbl_get_graphic_double(
4920            self.static_field_type(field),
4921            desc.menu.is_some(),
4922        )
4923    }
4924
4925    /// Notify subscribers from a snapshot (call outside lock).
4926    /// Each entry carries its own posting mask: only subscribers whose
4927    /// mask intersects that field's mask are notified, and the
4928    /// delivered [`MonitorEvent`] reports exactly that field's classes
4929    /// (C `db_post_events(prec, &field, mask)` per-field granularity).
4930    pub fn notify_from_snapshot(&self, snapshot: &ProcessSnapshot) {
4931        use crate::server::database::filters::FilteredMonitorEvent;
4932        use crate::server::recgbl::EventMask;
4933
4934        // Same ambient-origin inheritance as `notify_field_with_origin`:
4935        // a process cycle driven by an in-process writer's put tags its
4936        // posts with the writer's origin, so the writer's own filtered
4937        // subscriptions do not hear its cascade. 0 outside any scope.
4938        let origin = ambient_write_origin();
4939
4940        for (field, value, posting_mask) in &snapshot.changed_fields {
4941            let posting_mask = *posting_mask;
4942            if let Some(subs) = self.subscribers.get(field) {
4943                // Build a full snapshot once per field (with display metadata)
4944                let mon_snap = self.make_monitor_snapshot(field, value.clone());
4945                for sub in subs {
4946                    // Paused subscriber (`db_event_disable`): suppress at
4947                    // the source — no delivery, no coalesce.
4948                    if !sub.active {
4949                        continue;
4950                    }
4951                    let sub_mask = EventMask::from_bits(sub.mask);
4952                    // Only send when posting mask intersects subscriber mask.
4953                    // Empty posting mask means nothing changed — skip.
4954                    if !posting_mask.is_empty() && sub_mask.intersects(posting_mask) {
4955                        let event = MonitorEvent {
4956                            snapshot: mon_snap.clone(),
4957                            origin,
4958                            mask: posting_mask,
4959                        };
4960                        // Server-side filter chain (3.15.7). Empty chain
4961                        // is identity, so no behaviour change for the
4962                        // common no-filter case.
4963                        let filtered = if sub.filters.is_empty() {
4964                            Some(event)
4965                        } else {
4966                            sub.filters
4967                                .apply(FilteredMonitorEvent::new(event))
4968                                .map(|fe| fe.event)
4969                        };
4970                        let Some(event) = filtered else {
4971                            continue;
4972                        };
4973                        // C `db_queue_event_log`: append, or replace this
4974                        // monitor's last queued entry in place when the queue
4975                        // is in flow control or nearly full. The queue owns
4976                        // that decision and counts the displaced value.
4977                        sub.post(event);
4978                    }
4979                }
4980            }
4981        }
4982    }
4983
4984    /// Notify subscribers of a specific field, filtering by event mask.
4985    pub fn notify_field(&mut self, field: &str, mask: crate::server::recgbl::EventMask) {
4986        self.notify_field_with_origin(field, mask, 0);
4987    }
4988
4989    /// C `db_post_events(precord, NULL, DBE_ALARM)`: post a record-wide
4990    /// alarm event. Delivers to every subscriber on any field whose mask
4991    /// includes DBE_ALARM, each carrying its own monitored field's current
4992    /// value (the per-field `notify_field` already filters by mask
4993    /// intersection). Used by the alarm-acknowledge (ACKT/ACKS) put path so
4994    /// an alarm-mask monitor on any field observes the acknowledgement.
4995    pub fn notify_record_alarm(&mut self) {
4996        let fields: Vec<String> = self.subscribers.keys().cloned().collect();
4997        for field in fields {
4998            self.notify_field(&field, crate::server::recgbl::EventMask::ALARM);
4999        }
5000    }
5001
5002    /// Notify subscribers with an origin tag for self-write filtering.
5003    ///
5004    /// This is C `db_post_events(precord, pfield, mask)` for one field, and —
5005    /// per the `last_posted` contract — the poster that advances the
5006    /// already-published value when `mask` carries a value class. Taking
5007    /// `&mut self` is what makes that unbypassable: there is no way to publish
5008    /// a field's value through the framework without the change detector
5009    /// learning that it was published.
5010    pub fn notify_field_with_origin(
5011        &mut self,
5012        field: &str,
5013        mask: crate::server::recgbl::EventMask,
5014        origin: u64,
5015    ) {
5016        use crate::server::database::filters::FilteredMonitorEvent;
5017        // A poster that carries no origin of its own inherits the ambient
5018        // one (0 outside any scope): this is how every post inside an
5019        // SNL writer's synchronous put+process cascade gets the writer's
5020        // tag without threading a parameter through the whole processing
5021        // machinery. An explicit origin always wins.
5022        let origin = if origin != 0 {
5023            origin
5024        } else {
5025            ambient_write_origin()
5026        };
5027        // A value-class post publishes the field to its DBE_VALUE/DBE_LOG
5028        // subscribers, exactly as C's `dbPut` does for the put field
5029        // (dbAccess.c:1414) — record it so the next process cycle's
5030        // change-detection loop does not publish the same value a second
5031        // time. An alarm-only / property-only post publishes no value, so it
5032        // leaves the map alone.
5033        let publishes_value = mask.intersects(
5034            crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
5035        );
5036        let mut posted: Option<EpicsValue> = None;
5037        if let Some(subs) = self.subscribers.get(field) {
5038            if let Some(value) = self.resolve_field(field) {
5039                if publishes_value {
5040                    posted = Some(value.clone());
5041                }
5042                let mon_snap = self.make_monitor_snapshot(field, value);
5043                for sub in subs {
5044                    // Paused subscriber (`db_event_disable`): suppress at
5045                    // the source — no delivery, no coalesce.
5046                    if !sub.active {
5047                        continue;
5048                    }
5049                    let sub_mask = crate::server::recgbl::EventMask::from_bits(sub.mask);
5050                    if mask.is_empty() || sub_mask.intersects(mask) {
5051                        let event = MonitorEvent {
5052                            snapshot: mon_snap.clone(),
5053                            origin,
5054                            mask,
5055                        };
5056                        // Server-side filter chain (3.15.7). Empty
5057                        // chain (the default for every subscriber
5058                        // until a `.{filter:opts}` PV-name suffix
5059                        // parser wires one in) is the identity, so
5060                        // existing subscribers see no behaviour
5061                        // change. A filter returning `None` silences
5062                        // this event for this subscriber only.
5063                        let filtered = if sub.filters.is_empty() {
5064                            Some(event)
5065                        } else {
5066                            sub.filters
5067                                .apply(FilteredMonitorEvent::new(event))
5068                                .map(|fe| fe.event)
5069                        };
5070                        let Some(event) = filtered else {
5071                            continue;
5072                        };
5073                        // Same single post owner as the snapshot path.
5074                        sub.post(event);
5075                    }
5076                }
5077            }
5078        }
5079        // The value is now published to this field's value-class subscribers:
5080        // hand it to the `last_posted` owner so the change detector does not
5081        // publish it again. Delivery to any individual subscriber may have
5082        // been filtered out, exactly as C's `db_post_events` may find an empty
5083        // `mlis` — C still leaves `monitor()`'s `*_lst` state advanced by the
5084        // cycle that ran, so the post, not the delivery, is what counts.
5085        if let Some(value) = posted {
5086            self.record_value_post(field, value);
5087        }
5088    }
5089
5090    /// Add a subscriber for a specific field. Returns `None` when the
5091    /// per-field subscriber cap (`EPICS_CAS_MAX_SUBSCRIBERS_PER_PV`)
5092    /// is reached. the parallel cap on `ProcessVariable`
5093    /// defends against a misbehaving client opening many
5094    /// MONITOR ops against one shared PV; the same defence is needed
5095    /// for record fields, which the CA server's
5096    /// `ChannelTarget::RecordField` path lands on.
5097    pub fn add_subscriber(
5098        &mut self,
5099        field: &str,
5100        sid: u32,
5101        data_type: DbFieldType,
5102        mask: u16,
5103    ) -> Option<EventReader> {
5104        self.add_subscriber_on(&EventUser::new(), field, sid, data_type, mask)
5105    }
5106
5107    /// Add a field subscriber whose events queue on `user`'s event queue —
5108    /// C `db_add_event` with the circuit's `event_user` as context. Every
5109    /// subscription on one CA circuit shares that queue and therefore its
5110    /// `nDuplicates`, so a duplicate queued for one of them releases the
5111    /// EVENTS_OFF drain for all of them (`dbEvent.c:947`). In-process consumers
5112    /// use [`Self::add_subscriber`], which gives each its own `event_user`.
5113    pub fn add_subscriber_on(
5114        &mut self,
5115        user: &EventUser,
5116        field: &str,
5117        sid: u32,
5118        data_type: DbFieldType,
5119        mask: u16,
5120    ) -> Option<EventReader> {
5121        let cap = crate::server::pv::max_subscribers_per_pv();
5122        let field_str = field.to_string();
5123        let bucket = self.subscribers.entry(field_str.clone()).or_default();
5124        // Reap rows whose consumer is gone before
5125        // counting against the cap. A record field whose value
5126        // never changes (e.g. a quasi-static catalog field) never
5127        // triggers `notify_field_with_origin`'s retain-filter, so
5128        // a long-lived subscribe-disconnect storm could pin the
5129        // bucket at `cap` worth of dead rows and lock out
5130        // genuine new subscribers.
5131        bucket.retain(|s| !s.is_closed());
5132        if bucket.len() >= cap {
5133            tracing::warn!(
5134                record = %self.name,
5135                field = %field_str,
5136                live = bucket.len(),
5137                cap,
5138                "record field subscriber cap reached, refusing add_subscriber"
5139            );
5140            return None;
5141        }
5142        let (sink, reader) = crate::server::event_queue::attach(user, sid);
5143        bucket.push(Subscriber {
5144            sid,
5145            data_type,
5146            mask,
5147            sink,
5148            filters: crate::server::database::filters::FilterChain::new(),
5149            active: true,
5150        });
5151        // Initialize last_posted with current value so the first process cycle
5152        // doesn't treat it as "changed" (the initial value is already sent
5153        // to the client as part of EVENT_ADD response).
5154        if !self.last_posted.contains_key(&field_str) {
5155            if let Some(val) = self.resolve_field(&field_str) {
5156                self.last_posted.insert(field_str, val);
5157            }
5158        }
5159        Some(reader)
5160    }
5161
5162    /// Attach a filter to the most recently added subscriber for
5163    /// `field`. Returns `false` when no subscriber exists yet on that
5164    /// field (call `add_subscriber` first). The CA / PVA channel-name
5165    /// parsers will use this once `.{filter:opts}` syntax is wired.
5166    /// Tests can also use it directly to compose filter chains.
5167    pub fn attach_filter_to_last_subscriber(
5168        &mut self,
5169        field: &str,
5170        filter: std::sync::Arc<dyn crate::server::database::filters::SubscriptionFilter>,
5171    ) -> bool {
5172        if let Some(bucket) = self.subscribers.get_mut(field) {
5173            if let Some(sub) = bucket.last_mut() {
5174                sub.filters.push(filter);
5175                return true;
5176            }
5177        }
5178        false
5179    }
5180
5181    /// Remove a subscriber by subscription ID from all fields.
5182    pub fn remove_subscriber(&mut self, sid: u32) {
5183        for subs in self.subscribers.values_mut() {
5184            subs.retain(|s| s.sid != sid);
5185        }
5186    }
5187
5188    /// Pause / resume one subscriber's event flow at the source
5189    /// (`db_event_disable` / `db_event_enable`). `active == false`
5190    /// suppresses every subsequent post to this subscriber, so the record stops
5191    /// doing per-event work for it. Entries already queued stay queued and are
5192    /// still delivered, exactly as in C: `db_event_disable` only unlinks the
5193    /// subscription from the record's monitor list (`dbEvent.c:521-533`) and
5194    /// never reaches into the event queue. No-op if no subscriber has this
5195    /// `sid`. The caller holds the record write lock, so this is exclusive with
5196    /// the read-locked post paths that consult `Subscriber::active`.
5197    pub fn set_subscriber_active(&mut self, sid: u32, active: bool) {
5198        for subs in self.subscribers.values_mut() {
5199            for sub in subs.iter_mut() {
5200                if sub.sid == sid {
5201                    sub.active = active;
5202                }
5203            }
5204        }
5205    }
5206
5207    /// Clean up subscriber rows whose consumer is gone.
5208    pub fn cleanup_subscribers(&mut self) {
5209        for subs in self.subscribers.values_mut() {
5210            subs.retain(|s| !s.is_closed());
5211        }
5212    }
5213}
5214
5215/// C `recGblCheckDeadband` parity (recGbl.c:345-370). The four branches
5216/// the C path enumerates:
5217///
5218/// 1. Both `newval` and `oldval` finite: `delta = |old - new|`, fire when
5219///    `delta > deadband`.
5220/// 2. Exactly one of {newval, oldval} is NaN, the other not — OR exactly
5221///    one is +/-inf, the other not: `delta = +inf`, always fires.
5222/// 3. Both infinite with opposite signs: `delta = +inf`, always fires.
5223/// 4. Otherwise (e.g. both NaN, both same-signed infinity): no fire.
5224///
5225/// `oldval = NaN` is treated as "never posted" and fires (matches the
5226/// `mlst.is_nan() → trigger` short-circuit the Rust port already had).
5227/// `deadband < 0` fires unconditionally (matches `delta > deadband`
5228/// with a negative deadband — same effect on every numeric value).
5229pub(crate) fn check_deadband(newval: f64, oldval: f64, deadband: f64) -> bool {
5230    // Fire unconditionally when no prior posting has happened. C achieves
5231    // the same effect through the field being default-initialised to a
5232    // sentinel; Rust uses NaN-as-sentinel.
5233    if oldval.is_nan() {
5234        return true;
5235    }
5236    // Negative deadband short-circuits — any value passes.
5237    if deadband < 0.0 {
5238        return true;
5239    }
5240    let new_finite = newval.is_finite();
5241    let old_finite = oldval.is_finite();
5242    if new_finite && old_finite {
5243        return (newval - oldval).abs() > deadband;
5244    }
5245    // From here on, at least one of the two is not finite. We've already
5246    // ruled out oldval=NaN above, so any newval=NaN here is the "newval
5247    // went NaN while oldval was finite/inf" case — must fire (C case 2).
5248    if newval.is_nan() {
5249        return true;
5250    }
5251    // Exactly one infinite, the other finite: C case 2 → fire.
5252    if new_finite != old_finite {
5253        return true;
5254    }
5255    // Both infinite. Opposite signs → fire (C case 3); same sign → no
5256    // fire (C path leaves delta=0 and the `delta > deadband` check fails
5257    // for any non-negative deadband).
5258    newval != oldval
5259}
5260
5261#[cfg(test)]
5262mod device_menu_marking_tests {
5263    use super::*;
5264    use crate::server::records::ai::AiRecord;
5265    use crate::server::records::calc::CalcRecord;
5266    use crate::server::records::mbbo::MbboRecord;
5267
5268    /// C `dbAccess.c:176-179`: a `DBF_DEVICE` field whose record type declares
5269    /// no device support has `pfldDes->ftPvt == NULL` and takes `goto nostrs`,
5270    /// which clears `DBR_ENUM_STRS` — the client is sent NO choice list.
5271    ///
5272    /// `calc` declares no `device()` line, so QSRV2 omits `value.choices` on
5273    /// `CALC.DTYP`. The port used to default the missing menu to `[]` and mark
5274    /// an empty list instead.
5275    #[test]
5276    fn dtyp_of_a_record_type_with_no_device_support_supplies_no_choices() {
5277        let inst = RecordInstance::new("X".into(), CalcRecord::default());
5278        assert!(
5279            super::super::dbd_generated::device_menu("calc").is_none(),
5280            "precondition: calc declares no device() line (C ftPvt == NULL)"
5281        );
5282        assert!(
5283            inst.device_choices().is_none(),
5284            "a record type with no device menu must report None, not an empty list"
5285        );
5286        assert!(
5287            inst.enum_string_form_for("DTYP").is_none(),
5288            "DTYP must supply no enum-string form, so no `value.choices` is marked"
5289        );
5290    }
5291
5292    /// The other side of C's `dbAccess.c:205` comment — *"indicate option data
5293    /// not available. distinct from no_str==0"*. `ai` DOES declare device
5294    /// support, so its menu exists and its choices are served.
5295    #[test]
5296    fn dtyp_of_a_record_type_with_device_support_supplies_its_choices() {
5297        let inst = RecordInstance::new("X".into(), AiRecord::default());
5298        let choices = inst
5299            .device_choices()
5300            .expect("ai declares device() lines, so its menu exists");
5301        assert!(
5302            choices.iter().any(|c| c.as_str_lossy() == "Soft Channel"),
5303            "ai's device menu must carry its declared choices, got {choices:?}"
5304        );
5305        assert!(inst.enum_string_form_for("DTYP").is_some());
5306    }
5307
5308    /// An unset `DTYP` is index 0 on both sides of the distinction — a record
5309    /// type with no device menu has no slot for any DTYP, so the index stays 0
5310    /// rather than panicking or shifting.
5311    #[test]
5312    fn dtyp_index_is_zero_when_the_record_type_has_no_device_menu() {
5313        let inst = RecordInstance::new("X".into(), CalcRecord::default());
5314        assert_eq!(inst.dtyp_index(), 0);
5315    }
5316
5317    /// A downstream crate's registered device menu (asyn's) is merged AFTER the
5318    /// base-declared choices, matching a C fat softIoc that loaded `asyn.dbd`:
5319    /// `mbbo.DTYP` = the three base soft entries then `asynInt32`,
5320    /// `asynUInt32Digital`, in that order. `dtyp_index` reads the merged list,
5321    /// so an `mbbo` bound to `asynInt32` reports index 3 — the wire value C
5322    /// serves — instead of the appended-as-own-slot index the port gave before
5323    /// the menu was known.
5324    #[test]
5325    fn a_registered_device_menu_merges_after_the_base_declared_choices() {
5326        // The list asyn's generated `dbd_generated::DEVICE_MENU_MBBO` carries.
5327        static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
5328        super::super::register_device_menu("mbbo", ASYN_MBBO);
5329
5330        let mut inst = RecordInstance::new("X".into(), MbboRecord::default());
5331        let merged: Vec<String> = inst
5332            .device_choices()
5333            .expect("mbbo declares device() lines")
5334            .iter()
5335            .map(|c| c.as_str_lossy().into_owned())
5336            .collect();
5337        assert_eq!(
5338            merged,
5339            vec![
5340                "Soft Channel",
5341                "Raw Soft Channel",
5342                "Async Soft Channel",
5343                "asynInt32",
5344                "asynUInt32Digital",
5345            ],
5346            "base-declared choices first, asyn-contributed appended in asyn.dbd order"
5347        );
5348
5349        inst.common.dtyp = "asynInt32".into();
5350        assert_eq!(
5351            inst.dtyp_index(),
5352            3,
5353            "an asyn DTYP indexes into the merged menu, not an appended own slot"
5354        );
5355    }
5356
5357    /// The None-vs-empty contract survives the merge: a record type neither
5358    /// base nor any downstream crate contributes a `device()` for (calc) stays
5359    /// `None`, never `Some([])`, even after asyn menus are registered in this
5360    /// process.
5361    #[test]
5362    fn calc_stays_none_after_asyn_menus_are_registered() {
5363        static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
5364        super::super::register_device_menu("mbbo", ASYN_MBBO);
5365
5366        let inst = RecordInstance::new("X".into(), CalcRecord::default());
5367        assert!(
5368            inst.device_choices().is_none(),
5369            "calc declares no device() and gets no contribution — still None"
5370        );
5371    }
5372}
5373
5374#[cfg(test)]
5375mod property_support_owner_tests {
5376    use crate::server::record::record_trait::default_property_support;
5377    use crate::server::snapshot::PropertySupport as P;
5378
5379    /// `sseqRecord.c:124-144` — the rset table NULLs every property slot
5380    /// except `get_precision`:
5381    ///
5382    /// ```c
5383    /// NULL,           /* get_units */
5384    /// get_precision,  /* get_precision */
5385    /// NULL,           /* get_enum_str */
5386    /// NULL,           /* get_enum_strs */
5387    /// NULL,           /* put_enum_str */
5388    /// NULL,           /* get_graphic_double */
5389    /// NULL,           /* get_control_double */
5390    /// NULL            /* get_alarm_double */
5391    /// ```
5392    ///
5393    /// `sseq` was previously grouped with the full-numeric synApps types, so
5394    /// the port marked six leaves per field that QSRV2 omits entirely.
5395    #[test]
5396    fn sseq_supplies_only_precision() {
5397        assert_eq!(
5398            default_property_support("sseq"),
5399            P {
5400                precision: true,
5401                ..P::NONE
5402            }
5403        );
5404    }
5405
5406    /// A record type the table does not name keeps the permissive
5407    /// `NUMERIC` default rather than silently losing metadata. This is the
5408    /// arm `asyn` used to land on — and why marking had to become a trait
5409    /// method: asyn-rs cannot add a row here.
5410    #[test]
5411    fn an_untranscribed_record_type_keeps_the_permissive_default() {
5412        assert_eq!(default_property_support("no-such-record-type"), P::NUMERIC);
5413    }
5414}
5415
5416#[cfg(test)]
5417mod metadata_cache_tests {
5418    use super::*;
5419    use crate::server::records::ai::AiRecord;
5420
5421    /// Helper: build an AiRecord wrapped in a RecordInstance with EGU/PREC/HOPR/LOPR set.
5422    fn ai_instance() -> RecordInstance {
5423        let mut rec = AiRecord::default();
5424        let _ = rec.put_field("EGU", EpicsValue::String("degC".into()));
5425        let _ = rec.put_field("PREC", EpicsValue::Short(2));
5426        let _ = rec.put_field("HOPR", EpicsValue::Double(100.0));
5427        let _ = rec.put_field("LOPR", EpicsValue::Double(0.0));
5428        let _ = rec.put_field("VAL", EpicsValue::Double(25.0));
5429        RecordInstance::new("TEMP".to_string(), rec)
5430    }
5431
5432    /// a record-field monitor whose event queue has run short of room
5433    /// replaces its last queued entry in place (C `db_queue_event_log`,
5434    /// `dbEvent.c:812-820`), and the displaced value — which the consumer never
5435    /// observed — must be counted in the shared `dropped_monitor_events()`
5436    /// counter (C `nreplace`), the same accounting a `ProcessVariable` post
5437    /// uses. Before the fix the record-field path overwrote its coalesce slot
5438    /// without counting, hiding slow-consumer loss on the path most CA/PVA
5439    /// database monitors use. The counter is process-global, so the assertion is
5440    /// a strict monotonic increase (robust under parallel tests); the
5441    /// revert-verify runs this test in isolation.
5442    #[test]
5443    fn bfr10_record_field_overflow_counts_dropped_event() {
5444        use crate::server::event_queue::{event_que_size, events_per_que};
5445        use crate::server::pv::dropped_monitor_events;
5446        use crate::server::recgbl::EventMask;
5447        let mut inst = ai_instance();
5448        // Keep the reader alive and do NOT drain, so the ring fills to the
5449        // replace threshold and later posts displace the tail entry.
5450        let _reader = inst
5451            .add_subscriber(
5452                "VAL",
5453                1,
5454                crate::types::DbFieldType::Double,
5455                EventMask::VALUE.bits(),
5456            )
5457            .expect("subscriber added");
5458        let before = dropped_monitor_events();
5459        let posts = event_que_size() - events_per_que() + 10;
5460        for _ in 0..posts {
5461            inst.notify_field_with_origin("VAL", EventMask::VALUE, 0);
5462        }
5463        let after = dropped_monitor_events();
5464        assert!(
5465            after > before,
5466            "a post that replaces an unobserved queued entry must record a \
5467             dropped monitor event (before={before}, after={after})"
5468        );
5469    }
5470
5471    #[test]
5472    fn metadata_field_set_check() {
5473        // Sanity check that the metadata field set is recognized.
5474        assert!(is_metadata_field("EGU"));
5475        assert!(is_metadata_field("PREC"));
5476        assert!(is_metadata_field("HOPR"));
5477        assert!(is_metadata_field("LOPR"));
5478        assert!(is_metadata_field("HIHI"));
5479        assert!(is_metadata_field("DRVH"));
5480        assert!(is_metadata_field("ZNAM"));
5481        assert!(is_metadata_field("ZRST"));
5482        assert!(is_metadata_field("FFST"));
5483
5484        // Non-metadata fields should NOT invalidate the cache
5485        assert!(!is_metadata_field("VAL"));
5486        assert!(!is_metadata_field("DESC"));
5487        assert!(!is_metadata_field("SCAN"));
5488        assert!(!is_metadata_field("PHAS"));
5489    }
5490
5491    #[test]
5492    fn cache_starts_empty_then_populates_on_first_snapshot() {
5493        let inst = ai_instance();
5494
5495        // Cache starts empty
5496        assert!(inst.metadata_cache.lock().unwrap().is_none());
5497
5498        // First snapshot triggers populate + cache store
5499        let snap = inst.snapshot_for_field("VAL").unwrap();
5500        let display = snap.display.expect("ai snapshot must have display");
5501        assert_eq!(display.units, "degC");
5502        assert_eq!(display.precision, 2);
5503        assert_eq!(display.upper_disp_limit, 100.0);
5504        assert_eq!(display.lower_disp_limit, 0.0);
5505
5506        // Cache is now populated
5507        assert!(inst.metadata_cache.lock().unwrap().is_some());
5508    }
5509
5510    #[test]
5511    fn q_form_info_tag_sets_display_form_index() {
5512        // pvxs maps the `Q:form` info tag to `display.form.index` for the
5513        // VAL field (iocsource.cpp:42-62). "Hex" is slot 4 of the
5514        // seven-entry menu (Default/String/Binary/Decimal/Hex/...).
5515        let mut inst = ai_instance();
5516        inst.set_info("Q:form", "Hex");
5517        let snap = inst.snapshot_for_field("VAL").unwrap();
5518        let display = snap.display.expect("ai snapshot must have display");
5519        assert_eq!(display.form, 4, "Q:form=Hex -> display.form index 4");
5520    }
5521
5522    /// R16-31: `Q:form` is a record-level info tag, but QSRV assigns
5523    /// `display.form.index` only when the channel addresses the VAL field
5524    /// (`if(dbIsValueField(dbChannelFldDes(chan)))`, `iocsource.cpp:53`). A
5525    /// snapshot of any other field of the same record reports the default
5526    /// form, on both the GET and the monitor producer.
5527    #[test]
5528    fn q_form_applies_to_the_val_field_only() {
5529        let mut inst = ai_instance();
5530        inst.set_info("Q:form", "Hex");
5531
5532        let val = inst.snapshot_for_field("VAL").unwrap();
5533        assert_eq!(val.display.expect("ai display").form, 4);
5534
5535        for non_val in ["RVAL", "SEVR", "HOPR"] {
5536            let Some(snap) = inst.snapshot_for_field(non_val) else {
5537                panic!("ai.{non_val} must resolve");
5538            };
5539            assert_eq!(
5540                snap.display.expect("ai display").form,
5541                0,
5542                "Q:form must not reach ai.{non_val} — pvxs applies it to VAL only"
5543            );
5544        }
5545
5546        // The monitor producer shares the same per-field owner.
5547        let update = inst.make_monitor_snapshot("RVAL", EpicsValue::Long(7));
5548        assert_eq!(
5549            update.display.expect("ai display").form,
5550            0,
5551            "a monitor update on a non-VAL field carries the default form too"
5552        );
5553        let update = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0));
5554        assert_eq!(update.display.expect("ai display").form, 4);
5555    }
5556
5557    #[test]
5558    fn q_form_absent_or_unknown_leaves_form_default() {
5559        // No `Q:form` tag -> form stays 0 (Default).
5560        let inst = ai_instance();
5561        let snap = inst.snapshot_for_field("VAL").unwrap();
5562        assert_eq!(snap.display.expect("ai display").form, 0);
5563
5564        // Unrecognised tag -> pvxs leaves the index untouched (0).
5565        let mut inst2 = ai_instance();
5566        inst2.set_info("Q:form", "Nonsense");
5567        let snap2 = inst2.snapshot_for_field("VAL").unwrap();
5568        assert_eq!(snap2.display.expect("ai display").form, 0);
5569    }
5570
5571    /// `info(Q:time:tag)` resolves to pvxs's `nsecMask`
5572    /// (`ioc/typeutils.cpp:79-88`). The prefix test there is a byte-exact
5573    /// `strncmp("nsec:lsb:", 9)` and the digit count is fed straight to
5574    /// `(uint64_t(1u)<<dig)-1u` — no case folding, no whitespace tolerance
5575    /// around the prefix, and no bounds clamp. Each boundary gets a case.
5576    #[test]
5577    fn qtime_nsec_mask_matches_pvxs_updatensecmask() {
5578        let cases: &[(&str, u64)] = &[
5579            // parses: `epicsParseInt32` skips whitespace around the digits
5580            // and accepts a sign.
5581            ("nsec:lsb:20", (1 << 20) - 1),
5582            ("nsec:lsb:1", 1),
5583            ("nsec:lsb: 4 ", 0xF),
5584            ("nsec:lsb:+4", 0xF),
5585            // no clamp: 31 is the mask pvxs actually serves (the old Rust
5586            // `(1..=30)` guard dropped it), and 0 is pvxs's "off" mask.
5587            ("nsec:lsb:31", 0x7FFF_FFFF),
5588            ("nsec:lsb:0", 0),
5589            // `strncmp` is byte-exact: case-folded or whitespace-split
5590            // prefixes do not match, so pvxs leaves `nsecMask` at 0.
5591            ("NSEC:LSB:4", 0),
5592            ("Nsec:Lsb:4", 0),
5593            ("nsec: lsb: 4", 0),
5594            (" nsec:lsb:4", 0),
5595            // `epicsParseInt32` failures: no conversion, extraneous trailing
5596            // bytes, overflow past epicsInt32.
5597            ("nsec:lsb:", 0),
5598            ("nsec:lsb:abc", 0),
5599            ("nsec:lsb:4x", 0),
5600            ("nsec:lsb:4 5", 0),
5601            ("nsec:lsb:99999999999999999999", 0),
5602            ("nsec:lsb:2147483648", 0),
5603        ];
5604        for (tag, want) in cases {
5605            let mut inst = ai_instance();
5606            inst.set_info("Q:time:tag", *tag);
5607            assert_eq!(
5608                inst.qtime_nsec_mask(),
5609                *want,
5610                "info(Q:time:tag, {tag:?}) must resolve to nsecMask {want:#x}"
5611            );
5612        }
5613        // Tag absent entirely → pvxs never enters the `if(auto val = ...)`
5614        // body and `nsecMask` stays 0.
5615        assert_eq!(ai_instance().qtime_nsec_mask(), 0);
5616    }
5617
5618    /// End-to-end on the snapshot: `nsec:lsb:31` publishes
5619    /// `nanoseconds & ~mask` (0, since nanoseconds < 1e9 < 2^31) and
5620    /// `userTag = nanoseconds & mask` (pvxs `iocsource.cpp:239-248`). The
5621    /// old `(1..=30)` clamp served the raw nanoseconds and the record's
5622    /// utag instead.
5623    #[test]
5624    fn qtime_nsec_lsb_31_is_served_not_ignored() {
5625        use std::time::{Duration, SystemTime};
5626        let mut inst = ai_instance();
5627        // 123_456_700, not …789: Windows `SystemTime` is a FILETIME with 100 ns
5628        // resolution, so a sub-100 ns literal is truncated on readback and the
5629        // assertion below would see …700. Any value < 2^31 exercises the
5630        // nsec:lsb:31 mask identically, so pin one that survives the round trip.
5631        inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
5632        inst.common.utag = 5;
5633        inst.set_info("Q:time:tag", "nsec:lsb:31");
5634
5635        let snap = inst.snapshot_for_field("VAL").unwrap();
5636        assert_eq!(snap.user_tag, 123_456_700);
5637        assert_eq!(snap.timestamp.subsec_nanos(), 0);
5638        assert_eq!(snap.timestamp.unix_secs(), 42);
5639    }
5640
5641    /// The mirror boundary: a tag pvxs's `strncmp` rejects must leave the
5642    /// timestamp and the record's own utag alone. The old case-insensitive
5643    /// split matched `NSEC:LSB:4` and masked the wire timestamp pvxs serves
5644    /// unmasked.
5645    #[test]
5646    fn qtime_uppercase_tag_leaves_timestamp_untouched() {
5647        use std::time::{Duration, SystemTime};
5648        let mut inst = ai_instance();
5649        // 100 ns-multiple so the subsec_nanos assertion holds on Windows too;
5650        // see qtime_nsec_lsb_31_is_served_not_ignored for the FILETIME reason.
5651        inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
5652        inst.common.utag = 5;
5653        inst.set_info("Q:time:tag", "NSEC:LSB:4");
5654
5655        let snap = inst.snapshot_for_field("VAL").unwrap();
5656        assert_eq!(
5657            snap.user_tag, 5,
5658            "record utag must survive a non-matching tag"
5659        );
5660        assert_eq!(snap.timestamp.subsec_nanos(), 123_456_700);
5661    }
5662
5663    /// the served `timeStamp.userTag` defaults to the record's `utag`
5664    /// (pvxs `iocsource.cpp:245`), on both the GET (`snapshot_for_field`)
5665    /// and MONITOR (`make_monitor_snapshot`) paths. Pre-fix both hard-set
5666    /// it to 0, dropping the record's tag. A bit-31 utag also pins the
5667    /// `u64 -> i32` narrowing: the low 32 bits' pattern is preserved
5668    /// (no clamp), matching pvxs assigning `epicsUTag` into the `Int32`
5669    /// wire field.
5670    #[test]
5671    fn snapshot_serves_record_utag_as_timestamp_usertag() {
5672        let mut inst = ai_instance();
5673        // no `info(Q:time:tag, ...)` on this record, so the nsec-LSB
5674        // override never fires and the utag default is what is served.
5675        inst.common.utag = 0x9000_0000;
5676        let want = 0x9000_0000u32 as i32;
5677
5678        let get = inst.snapshot_for_field("VAL").unwrap();
5679        assert_eq!(
5680            get.user_tag, want,
5681            "GET path must serve the record's utag as timeStamp.userTag"
5682        );
5683
5684        let mon = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0));
5685        assert_eq!(
5686            mon.user_tag, want,
5687            "MONITOR path must carry the record's utag too"
5688        );
5689    }
5690
5691    #[test]
5692    fn cache_hit_returns_same_metadata() {
5693        let inst = ai_instance();
5694
5695        // Prime the cache
5696        let snap1 = inst.snapshot_for_field("VAL").unwrap();
5697        let display1 = snap1.display.unwrap();
5698
5699        // Subsequent snapshots return the same cached metadata
5700        let snap2 = inst.snapshot_for_field("VAL").unwrap();
5701        let display2 = snap2.display.unwrap();
5702
5703        assert_eq!(display1.units, display2.units);
5704        assert_eq!(display1.precision, display2.precision);
5705        assert_eq!(display1.upper_disp_limit, display2.upper_disp_limit);
5706        assert_eq!(display1.lower_disp_limit, display2.lower_disp_limit);
5707    }
5708
5709    #[test]
5710    fn invalidate_clears_cache() {
5711        let inst = ai_instance();
5712        let _ = inst.snapshot_for_field("VAL");
5713        assert!(inst.metadata_cache.lock().unwrap().is_some());
5714
5715        inst.invalidate_metadata_cache();
5716        assert!(inst.metadata_cache.lock().unwrap().is_none());
5717    }
5718
5719    #[test]
5720    fn notify_field_written_invalidates_for_metadata_field() {
5721        let inst = ai_instance();
5722        let _ = inst.snapshot_for_field("VAL");
5723        assert!(inst.metadata_cache.lock().unwrap().is_some());
5724
5725        // Writing a metadata field should invalidate
5726        inst.notify_field_written("EGU");
5727        assert!(inst.metadata_cache.lock().unwrap().is_none());
5728    }
5729
5730    #[test]
5731    fn notify_field_written_skips_non_metadata_field() {
5732        let inst = ai_instance();
5733        let _ = inst.snapshot_for_field("VAL");
5734        assert!(inst.metadata_cache.lock().unwrap().is_some());
5735
5736        // Writing a value field should NOT invalidate the cache
5737        inst.notify_field_written("VAL");
5738        assert!(inst.metadata_cache.lock().unwrap().is_some());
5739
5740        // Same for DESC
5741        inst.notify_field_written("DESC");
5742        assert!(inst.metadata_cache.lock().unwrap().is_some());
5743    }
5744
5745    #[test]
5746    fn notify_field_written_is_case_insensitive() {
5747        let inst = ai_instance();
5748        let _ = inst.snapshot_for_field("VAL");
5749        assert!(inst.metadata_cache.lock().unwrap().is_some());
5750
5751        // Lowercase metadata field name should still trigger invalidation
5752        inst.notify_field_written("egu");
5753        assert!(inst.metadata_cache.lock().unwrap().is_none());
5754    }
5755
5756    /// epics-base faac1df1 — `notify_field_written_if_changed` must
5757    /// SKIP the cache invalidation when the metadata field's value
5758    /// didn't actually change. Otherwise a stream of idempotent puts
5759    /// from a CSS panel binds DBE_PROPERTY subscribers to bogus
5760    /// "property changed" events on every cycle.
5761    #[test]
5762    fn notify_field_written_if_changed_skips_when_unchanged() {
5763        let mut inst = ai_instance();
5764        let _ = inst.snapshot_for_field("VAL");
5765        assert!(inst.metadata_cache.lock().unwrap().is_some());
5766
5767        // Capture prev, do a no-op put, then notify — cache must remain.
5768        let prev = inst.record.get_field("EGU");
5769        let _ = inst.record.put_field("EGU", prev.clone().unwrap());
5770        inst.notify_field_written_if_changed("EGU", prev.as_ref());
5771        assert!(
5772            inst.metadata_cache.lock().unwrap().is_some(),
5773            "no-op put must not invalidate the metadata cache"
5774        );
5775    }
5776
5777    /// And when the value DID change, the cache must invalidate.
5778    #[test]
5779    fn notify_field_written_if_changed_invalidates_on_real_change() {
5780        let mut inst = ai_instance();
5781        let _ = inst.snapshot_for_field("VAL");
5782        assert!(inst.metadata_cache.lock().unwrap().is_some());
5783
5784        let prev = inst.record.get_field("EGU");
5785        let _ = inst
5786            .record
5787            .put_field("EGU", EpicsValue::String("kPa".into()));
5788        inst.notify_field_written_if_changed("EGU", prev.as_ref());
5789        assert!(
5790            inst.metadata_cache.lock().unwrap().is_none(),
5791            "real metadata change must invalidate cache"
5792        );
5793    }
5794
5795    /// Non-metadata fields don't carry property semantics — the
5796    /// `if_changed` variant must never invalidate for them, matching
5797    /// the existing `notify_field_written` short-circuit.
5798    #[test]
5799    fn notify_field_written_if_changed_skips_non_metadata_field() {
5800        let mut inst = ai_instance();
5801        let _ = inst.snapshot_for_field("VAL");
5802        assert!(inst.metadata_cache.lock().unwrap().is_some());
5803        // VAL is not in is_metadata_field set — must be skipped even
5804        // with a changed value.
5805        inst.notify_field_written_if_changed("VAL", None);
5806        assert!(inst.metadata_cache.lock().unwrap().is_some());
5807    }
5808
5809    #[test]
5810    fn cache_picks_up_new_value_after_invalidation() {
5811        let mut inst = ai_instance();
5812
5813        // First snapshot: degC
5814        let snap1 = inst.snapshot_for_field("VAL").unwrap();
5815        assert_eq!(snap1.display.unwrap().units, "degC");
5816
5817        // Mutate EGU and invalidate
5818        let _ = inst
5819            .record
5820            .put_field("EGU", EpicsValue::String("mV".into()));
5821        inst.notify_field_written("EGU");
5822
5823        // Second snapshot: mV (rebuilt)
5824        let snap2 = inst.snapshot_for_field("VAL").unwrap();
5825        assert_eq!(snap2.display.unwrap().units, "mV");
5826    }
5827
5828    /// R19-41: every snapshot carries the mask of which properties the
5829    /// channel SUPPLIES — C's `rset` slots (`dbAccess.c:336-430` clears the
5830    /// option bit of each NULL slot) narrowed to the addressed field. One
5831    /// case per gate boundary; the three record types are the ones measured
5832    /// against pvxs, which marks none of these leaves.
5833    #[test]
5834    fn property_support_masks_what_the_record_type_does_not_supply() {
5835        use crate::server::records::longout::LongoutRecord;
5836        use crate::server::records::stringout::StringoutRecord;
5837        use crate::server::records::waveform::WaveformRecord;
5838
5839        // ai VAL (DBF_DOUBLE): every numeric slot, no enum strings.
5840        let ai = ai_instance();
5841        let p = ai.snapshot_for_field("VAL").unwrap().properties;
5842        assert_eq!(p, PropertySupport::NUMERIC);
5843        assert_eq!(
5844            ai.snapshot_for_field("VAL").unwrap().precision(),
5845            Some(2),
5846            "an ai supplies get_precision and VAL is DBF_DOUBLE"
5847        );
5848
5849        // ai RVAL (DBF_LONG): the SAME rset, but C keeps DBR_PRECISION only
5850        // for DBF_FLOAT/DBF_DOUBLE (`dbAccess.c:386-395`).
5851        let rval = ai.snapshot_for_field("RVAL").unwrap();
5852        assert!(
5853            !rval.properties.precision && rval.precision().is_none(),
5854            "a non-float field supplies no precision even when the rset does"
5855        );
5856        assert!(
5857            rval.properties.units,
5858            "the other slots are unaffected by the field's type"
5859        );
5860
5861        // longout: `#define get_precision NULL`.
5862        let lo = RecordInstance::new("LO".to_string(), LongoutRecord::default());
5863        let lo = lo.snapshot_for_field("VAL").unwrap();
5864        assert!(!lo.properties.precision && lo.precision().is_none());
5865        assert!(lo.properties.units && lo.properties.graphic_double);
5866
5867        // stringout: no property slot at all.
5868        let so = RecordInstance::new("SO".to_string(), StringoutRecord::default());
5869        let so = so.snapshot_for_field("VAL").unwrap();
5870        assert_eq!(so.properties, PropertySupport::NONE);
5871        assert!(so.units().is_none(), "a stringout supplies no EGU");
5872
5873        // waveform: `#define get_alarm_double NULL`.
5874        let wf = RecordInstance::new("WF".to_string(), WaveformRecord::default());
5875        let wf = wf.snapshot_for_field("VAL").unwrap();
5876        assert!(
5877            !wf.properties.alarm_double && wf.alarm_limits().is_none(),
5878            "a waveform supplies no alarm limits — a GUI must not draw bands at zero"
5879        );
5880        assert!(wf.properties.units && wf.properties.graphic_double);
5881    }
5882
5883    #[test]
5884    fn make_monitor_snapshot_uses_cache() {
5885        let inst = ai_instance();
5886        assert!(inst.metadata_cache.lock().unwrap().is_none());
5887
5888        // make_monitor_snapshot should also populate the cache
5889        let snap = inst.make_monitor_snapshot("VAL", EpicsValue::Double(42.0));
5890        assert!(snap.display.is_some());
5891        assert!(inst.metadata_cache.lock().unwrap().is_some());
5892
5893        // Subsequent call hits cache
5894        let snap2 = inst.make_monitor_snapshot("VAL", EpicsValue::Double(43.0));
5895        let d1 = snap.display.unwrap();
5896        let d2 = snap2.display.unwrap();
5897        assert_eq!(d1.units, d2.units);
5898        assert_eq!(d1.precision, d2.precision);
5899    }
5900
5901    /// Stub record with a per-field metadata override on SPD only —
5902    /// models a C RSET whose get_units/get_graphic_double key on
5903    /// dbGetFieldIndex (e.g. motorRecord.cc:3156-3361).
5904    struct PerFieldMetaRecord;
5905
5906    impl Record for PerFieldMetaRecord {
5907        fn record_type(&self) -> &'static str {
5908            "ai" // record-level metadata populates from EGU/PREC/HOPR/LOPR
5909        }
5910        fn get_field(&self, name: &str) -> Option<EpicsValue> {
5911            match name {
5912                "VAL" | "SPD" => Some(EpicsValue::Double(1.0)),
5913                "EGU" => Some(EpicsValue::String("mm".into())),
5914                "PREC" => Some(EpicsValue::Short(3)),
5915                "HOPR" => Some(EpicsValue::Double(100.0)),
5916                "LOPR" => Some(EpicsValue::Double(-100.0)),
5917                _ => None,
5918            }
5919        }
5920        fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
5921            Err(CaError::FieldNotFound(name.to_string()))
5922        }
5923        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
5924            &[]
5925        }
5926        fn field_metadata_override(
5927            &self,
5928            field: &str,
5929        ) -> Option<crate::server::record::FieldMetadataOverride> {
5930            if field != "SPD" {
5931                return None;
5932            }
5933            Some(crate::server::record::FieldMetadataOverride {
5934                units: Some("mm/sec".into()),
5935                precision: Some(1),
5936                disp_limits: Some((5.0, 0.5)),
5937                ctrl_limits: Some((4.0, 1.0)),
5938                alarm_limits: Some((9.0, 8.0, -8.0, -9.0)),
5939            })
5940        }
5941    }
5942
5943    #[test]
5944    fn field_metadata_override_applies_on_get_and_monitor_paths() {
5945        let inst = RecordInstance::new("PFM".to_string(), PerFieldMetaRecord);
5946
5947        // VAL: no override — record-level metadata serves it.
5948        let snap = inst.snapshot_for_field("VAL").unwrap();
5949        let d = snap.display.unwrap();
5950        assert_eq!(d.units, "mm");
5951        assert_eq!(d.precision, 3);
5952        assert_eq!(d.upper_disp_limit, 100.0);
5953
5954        // SPD via the GET path: every member patched over the cache.
5955        let snap = inst.snapshot_for_field("SPD").unwrap();
5956        let d = snap.display.unwrap();
5957        assert_eq!(d.units, "mm/sec");
5958        assert_eq!(d.precision, 1);
5959        assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
5960        assert_eq!(
5961            (
5962                d.upper_alarm_limit,
5963                d.upper_warning_limit,
5964                d.lower_warning_limit,
5965                d.lower_alarm_limit
5966            ),
5967            (9.0, 8.0, -8.0, -9.0)
5968        );
5969        let c = snap.control.unwrap();
5970        assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
5971
5972        // SPD via the monitor path: identical override.
5973        let snap = inst.make_monitor_snapshot("SPD", EpicsValue::Double(2.0));
5974        let d = snap.display.unwrap();
5975        assert_eq!(d.units, "mm/sec");
5976        assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
5977        let c = snap.control.unwrap();
5978        assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
5979    }
5980
5981    /// Stub modelling the motor monitor() shape (C motorRecord.cc:
5982    /// 3468-3507): VAL is a setpoint, the MDEL/ADEL deadband tracks
5983    /// the RBV readback, which advances on every process.
5984    struct ReadbackDeadbandRecord {
5985        val: f64,
5986        rbv: f64,
5987        deadband: f64,
5988    }
5989
5990    impl Record for ReadbackDeadbandRecord {
5991        fn record_type(&self) -> &'static str {
5992            "ai"
5993        }
5994        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
5995            self.rbv += 30.0;
5996            Ok(crate::server::record::ProcessOutcome::complete())
5997        }
5998        fn get_field(&self, name: &str) -> Option<EpicsValue> {
5999            match name {
6000                "VAL" => Some(EpicsValue::Double(self.val)),
6001                "RBV" => Some(EpicsValue::Double(self.rbv)),
6002                "MDEL" | "ADEL" => Some(EpicsValue::Double(self.deadband)),
6003                _ => None,
6004            }
6005        }
6006        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
6007            match (name, value) {
6008                ("VAL", EpicsValue::Double(v)) => {
6009                    self.val = v;
6010                    Ok(())
6011                }
6012                ("MDEL", EpicsValue::Double(v)) => {
6013                    self.deadband = v;
6014                    Ok(())
6015                }
6016                _ => Err(CaError::FieldNotFound(name.to_string())),
6017            }
6018        }
6019        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6020            &[]
6021        }
6022        fn monitor_deadband_value(&self) -> Option<EpicsValue> {
6023            Some(EpicsValue::Double(self.rbv))
6024        }
6025        fn monitor_deadband_field(&self) -> &'static str {
6026            "RBV"
6027        }
6028    }
6029
6030    /// C motor monitor() parity: MDEL/ADEL throttle the deadband
6031    /// field's (RBV) delivery; VAL posts only when the setpoint
6032    /// actually changed — not on every readback poll.
6033    #[test]
6034    fn deadband_field_routes_readback_and_val_posts_only_on_change() {
6035        use crate::server::recgbl::EventMask;
6036        let mut inst = RecordInstance::new(
6037            "RDB".to_string(),
6038            ReadbackDeadbandRecord {
6039                val: 5.0,
6040                rbv: 0.0,
6041                deadband: 10.0,
6042            },
6043        );
6044        let _val_rx = inst
6045            .add_subscriber(
6046                "VAL",
6047                1,
6048                crate::types::DbFieldType::Double,
6049                EventMask::VALUE.bits(),
6050            )
6051            .expect("VAL subscriber");
6052        let _rbv_rx = inst
6053            .add_subscriber(
6054                "RBV",
6055                2,
6056                crate::types::DbFieldType::Double,
6057                EventMask::VALUE.bits(),
6058            )
6059            .expect("RBV subscriber");
6060        let names = |snap: &ProcessSnapshot| {
6061            snap.changed_fields
6062                .iter()
6063                .map(|(n, _, _)| n.clone())
6064                .collect::<Vec<_>>()
6065        };
6066
6067        // Cycle 1 (first publish): RBV fires via the deadband trigger
6068        // (MLST starts at the NaN never-posted sentinel). VAL must NOT
6069        // post: `add_subscriber` seeded `last_posted` with the current
6070        // value (the initial value already went out with EVENT_ADD), and
6071        // C monitor() posts VAL only when MARKED(M_VAL) — nothing marked
6072        // it.
6073        let (snap, _) = inst.process_local().unwrap();
6074        let n = names(&snap);
6075        assert!(n.contains(&"RBV".to_string()), "{n:?}");
6076        assert!(
6077            !n.contains(&"VAL".to_string()),
6078            "VAL unchanged since subscribe must not post: {n:?}"
6079        );
6080
6081        // Cycle 2: RBV moved past MDEL, VAL unchanged → RBV posted,
6082        // VAL not re-posted.
6083        let (snap, _) = inst.process_local().unwrap();
6084        let n = names(&snap);
6085        assert!(n.contains(&"RBV".to_string()), "RBV crossed MDEL: {n:?}");
6086        assert!(
6087            !n.contains(&"VAL".to_string()),
6088            "unchanged VAL must not post: {n:?}"
6089        );
6090
6091        // Cycle 3: widen the deadband — RBV moves within it → throttled.
6092        let _ = inst.record.put_field("MDEL", EpicsValue::Double(1000.0));
6093        let (snap, _) = inst.process_local().unwrap();
6094        let n = names(&snap);
6095        assert!(
6096            !n.contains(&"RBV".to_string()),
6097            "MDEL must throttle RBV: {n:?}"
6098        );
6099
6100        // Cycle 4: setpoint moves while RBV stays inside the deadband →
6101        // VAL posts via change detection, RBV stays throttled.
6102        let _ = inst.record.put_field("VAL", EpicsValue::Double(42.0));
6103        let (snap, _) = inst.process_local().unwrap();
6104        let n = names(&snap);
6105        assert!(
6106            n.contains(&"VAL".to_string()),
6107            "changed VAL must post: {n:?}"
6108        );
6109        assert!(
6110            !n.contains(&"RBV".to_string()),
6111            "MDEL must throttle RBV: {n:?}"
6112        );
6113    }
6114
6115    /// A subroutine-less aSub (empty SNAM — the record the PVA monitor
6116    /// oracle drives as `ORACLE:MONSCAN:ASUB`) mirrors C `do_sub`
6117    /// (aSubRecord.c:459-465): an empty SNAM returns 0 BEFORE the bad-sub
6118    /// check, and C `process` (`:224`) runs `prec->val = status = 0` every
6119    /// cycle. So a periodic scan forces VAL back to 0, and C `monitor()`
6120    /// (`:414`, `val != oval`) posts nothing — the driven `dbPut`s are the
6121    /// only VAL events.
6122    ///
6123    /// Before the fix the port's "no bound subroutine" branch returned
6124    /// `S_db_BadSub` and never wrote VAL, so a scanned aSub kept VAL at the
6125    /// last client put and the deadband gate re-posted it on every scan (the
6126    /// oracle's 7 updates where C posts 4). This pins both halves: `process`
6127    /// resets VAL to 0, and a scan of the reset value posts nothing.
6128    #[test]
6129    fn subroutineless_asub_process_resets_val_and_stops_scan_overposting() {
6130        use crate::server::recgbl::EventMask;
6131        use crate::server::records::asub_record::ASubRecord;
6132
6133        let mut inst = RecordInstance::new("ASUB".to_string(), ASubRecord::default());
6134        // The default record: no subroutine bound, SNAM empty.
6135        assert!(inst.subroutine.is_none());
6136        let _val_rx = inst
6137            .add_subscriber(
6138                "VAL",
6139                1,
6140                crate::types::DbFieldType::Long,
6141                EventMask::VALUE.bits(),
6142            )
6143            .expect("VAL subscriber");
6144        let posts_val =
6145            |snap: &ProcessSnapshot| snap.changed_fields.iter().any(|(n, _, _)| n == "VAL");
6146
6147        // A settling scan of the unchanged record: C `do_sub` returns 0 and
6148        // `process` leaves VAL at 0 (already 0), settling the monitor gate.
6149        let _ = inst.process_local().unwrap();
6150        assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
6151        // status 0 -> C `if (!status)` drives every OUT link (aSub's
6152        // `multi_output_links` gate reads the cycle status); a bad-sub status
6153        // would suppress all 21.
6154        assert_eq!(
6155            inst.record.multi_output_links().len(),
6156            21,
6157            "empty-SNAM do_sub status must be 0, not S_db_BadSub"
6158        );
6159
6160        // A client caput lands on VAL (DBF_LONG, not process-passive: it posts
6161        // but does not itself process, leaving VAL non-zero — exactly how the
6162        // oracle drives the scanned reproducer between scans).
6163        inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
6164
6165        // The periodic scan processes. C forces VAL back to 0 and posts
6166        // nothing (val == oval == 0). Before the fix VAL stayed 7 and the scan
6167        // re-posted it.
6168        let (snap, _) = inst.process_local().unwrap();
6169        assert_eq!(
6170            inst.record.get_field("VAL"),
6171            Some(EpicsValue::Long(0)),
6172            "a scan must reset VAL to the do_sub status (0)"
6173        );
6174        assert!(
6175            !posts_val(&snap),
6176            "a scan that resets VAL to 0 must not re-post it"
6177        );
6178
6179        // A second driven put + scan: the monitor marker stays at 0, so no
6180        // scan ever re-posts the reset value.
6181        inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
6182        let (snap, _) = inst.process_local().unwrap();
6183        assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
6184        assert!(
6185            !posts_val(&snap),
6186            "repeated scans must not re-post the reset VAL"
6187        );
6188    }
6189
6190    /// Record that names DIFF in `force_posted_fields` (the motor's C
6191    /// `process_motor_info` unconditional `MARK(M_DIFF)`) while keeping
6192    /// every value constant — a settled axis parked at a fixed non-zero
6193    /// following error. VAL is a control: not force-listed, so it must
6194    /// fall back to change-detection.
6195    struct ForcePostRecord {
6196        diff: f64,
6197        val: f64,
6198    }
6199
6200    impl Record for ForcePostRecord {
6201        fn record_type(&self) -> &'static str {
6202            "ai"
6203        }
6204        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
6205            // Values never change — the readback already matches; only the
6206            // unconditional MARK should keep DIFF flowing.
6207            Ok(crate::server::record::ProcessOutcome::complete())
6208        }
6209        fn get_field(&self, name: &str) -> Option<EpicsValue> {
6210            match name {
6211                "DIFF" => Some(EpicsValue::Double(self.diff)),
6212                "VAL" => Some(EpicsValue::Double(self.val)),
6213                _ => None,
6214            }
6215        }
6216        fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
6217            Err(CaError::FieldNotFound(name.to_string()))
6218        }
6219        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6220            &[]
6221        }
6222        fn force_posted_fields(&self) -> &'static [&'static str] {
6223            &["DIFF"]
6224        }
6225    }
6226
6227    /// C motorRecord parity: `process_motor_info` MARKs M_DIFF/M_RDIF every
6228    /// CALLBACK_DATA pass and `monitor()` posts them with `DBE_VAL_LOG`
6229    /// regardless of change, so a force-posted field re-posts on an
6230    /// otherwise-idle cycle while an unchanged non-force field does not.
6231    #[test]
6232    fn force_posted_field_reposts_unchanged_value_each_cycle() {
6233        use crate::server::recgbl::EventMask;
6234        let mut inst = RecordInstance::new(
6235            "FP".to_string(),
6236            ForcePostRecord {
6237                diff: 2.5,
6238                val: 1.0,
6239            },
6240        );
6241        let _diff_rx = inst
6242            .add_subscriber(
6243                "DIFF",
6244                1,
6245                crate::types::DbFieldType::Double,
6246                EventMask::VALUE.bits(),
6247            )
6248            .expect("DIFF subscriber");
6249        let _val_rx = inst
6250            .add_subscriber(
6251                "VAL",
6252                2,
6253                crate::types::DbFieldType::Double,
6254                EventMask::VALUE.bits(),
6255            )
6256            .expect("VAL subscriber");
6257        let names = |snap: &ProcessSnapshot| {
6258            snap.changed_fields
6259                .iter()
6260                .map(|(n, _, _)| n.clone())
6261                .collect::<Vec<_>>()
6262        };
6263
6264        // Cycle 1 (first publish): both DIFF and VAL post — last_posted is
6265        // empty so change-detection treats every subscribed field as new.
6266        let (snap1, _) = inst.process_local().unwrap();
6267        assert!(
6268            names(&snap1).contains(&"DIFF".to_string()),
6269            "DIFF posts on first publish: {:?}",
6270            names(&snap1)
6271        );
6272
6273        // Cycle 2: nothing changed. VAL (not force-listed) must NOT re-post;
6274        // DIFF (force-listed) MUST re-post — the C unconditional MARK +
6275        // DBE_VAL_LOG. This is the divergence MOT-1 closes.
6276        let (snap2, _) = inst.process_local().unwrap();
6277        assert!(
6278            names(&snap2).contains(&"DIFF".to_string()),
6279            "force-posted DIFF must re-post when unchanged: {:?}",
6280            names(&snap2)
6281        );
6282        assert!(
6283            !names(&snap2).contains(&"VAL".to_string()),
6284            "an unchanged non-force field must not re-post: {:?}",
6285            names(&snap2)
6286        );
6287        // The forced re-post carries DBE_VALUE|DBE_LOG (no alarm bits this
6288        // cycle), matching C `monitor_mask | DBE_VAL_LOG` with monitor_mask=0.
6289        let diff_mask = snap2
6290            .changed_fields
6291            .iter()
6292            .find(|(n, _, _)| n == "DIFF")
6293            .map(|(_, _, m)| *m)
6294            .expect("DIFF post present");
6295        assert_eq!(
6296            diff_mask.bits(),
6297            (EventMask::VALUE | EventMask::LOG).bits(),
6298            "forced re-post mask is DBE_VAL_LOG"
6299        );
6300    }
6301
6302    /// Record that names S1 in `log_swept_fields` (the scaler's idle
6303    /// `monitor()` DBE_LOG sweep) while keeping every value constant. S2
6304    /// is a control: subscribed but NOT swept, so an unchanged S2 must
6305    /// not re-post. Neither field is the primary `VAL`, so the default
6306    /// deadband field resolves to nothing and does not confound the test.
6307    struct LogSweepRecord {
6308        s1: i32,
6309        s2: i32,
6310    }
6311
6312    impl Record for LogSweepRecord {
6313        fn record_type(&self) -> &'static str {
6314            "scaler"
6315        }
6316        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
6317            // Counts never change — only the unconditional idle LOG sweep
6318            // should keep S1 flowing to a DBE_LOG (archiver) subscriber.
6319            Ok(crate::server::record::ProcessOutcome::complete())
6320        }
6321        fn get_field(&self, name: &str) -> Option<EpicsValue> {
6322            match name {
6323                "S1" => Some(EpicsValue::Long(self.s1)),
6324                "S2" => Some(EpicsValue::Long(self.s2)),
6325                _ => None,
6326            }
6327        }
6328        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
6329            match (name, value) {
6330                ("S1", EpicsValue::Long(v)) => {
6331                    self.s1 = v;
6332                    Ok(())
6333                }
6334                ("S2", EpicsValue::Long(v)) => {
6335                    self.s2 = v;
6336                    Ok(())
6337                }
6338                _ => Err(CaError::FieldNotFound(name.to_string())),
6339            }
6340        }
6341        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6342            &[]
6343        }
6344        fn log_swept_fields(&self) -> &'static [&'static str] {
6345            &["S1"]
6346        }
6347    }
6348
6349    /// C `scalerRecord.c::monitor():757-773` sweeps each active channel with a
6350    /// literal `DBE_LOG` on every cycle it runs, unconditionally — the sweep is
6351    /// INDEPENDENT of the change post, not an alternative to it (R12-62). So an
6352    /// UNCHANGED swept field posts `DBE_LOG` only, and a CHANGED swept field
6353    /// posts TWICE on that one cycle: once by change-detection, and once by the
6354    /// sweep with `DBE_LOG`. (In C's scaler those two are `updateCounts()`'s
6355    /// `DBE_VALUE` at `:582` and `monitor()`'s `DBE_LOG` at `:771`.) A
6356    /// non-swept field never re-posts when unchanged. `add_subscriber` seeds
6357    /// `last_posted` with the current value (the initial value goes out via
6358    /// EVENT_ADD), so a freshly subscribed unchanged field already takes the
6359    /// sweep path on cycle 1.
6360    #[test]
6361    fn log_swept_field_reposts_unchanged_with_log_mask_only() {
6362        use crate::server::recgbl::EventMask;
6363        let mut inst = RecordInstance::new("SW".to_string(), LogSweepRecord { s1: 7, s2: 9 });
6364        let _s1_rx = inst
6365            .add_subscriber(
6366                "S1",
6367                1,
6368                crate::types::DbFieldType::Long,
6369                EventMask::LOG.bits(),
6370            )
6371            .expect("S1 subscriber");
6372        let _s2_rx = inst
6373            .add_subscriber(
6374                "S2",
6375                2,
6376                crate::types::DbFieldType::Long,
6377                EventMask::VALUE.bits(),
6378            )
6379            .expect("S2 subscriber");
6380        let names = |snap: &ProcessSnapshot| {
6381            snap.changed_fields
6382                .iter()
6383                .map(|(n, _, _)| n.clone())
6384                .collect::<Vec<_>>()
6385        };
6386        let count_of = |snap: &ProcessSnapshot, f: &str| {
6387            snap.changed_fields
6388                .iter()
6389                .filter(|(n, _, _)| n == f)
6390                .count()
6391        };
6392        let mask_of = |snap: &ProcessSnapshot, f: &str| {
6393            snap.changed_fields
6394                .iter()
6395                .find(|(n, _, _)| n == f)
6396                .map(|(_, _, m)| *m)
6397        };
6398
6399        // Cycle 1: nothing changed since subscribe. S1 (swept) re-posts
6400        // with DBE_LOG ONLY; S2 (not swept) must NOT re-post.
6401        let (snap1, _) = inst.process_local().unwrap();
6402        assert!(
6403            names(&snap1).contains(&"S1".to_string()),
6404            "log-swept S1 must re-post when unchanged: {:?}",
6405            names(&snap1)
6406        );
6407        assert!(
6408            !names(&snap1).contains(&"S2".to_string()),
6409            "unchanged non-swept S2 must not re-post: {:?}",
6410            names(&snap1)
6411        );
6412        // DBE_LOG, plus the DBE_ALARM of this cycle's transition: a record
6413        // starts UDF/INVALID and its first process clears that, so cycle 1 IS an
6414        // alarm transition (CBUG-B19 — C's sweep drops the alarm bit; this
6415        // assertion used to require a bare DBE_LOG). No DBE_VALUE either way:
6416        // the counts have not moved.
6417        assert_eq!(
6418            mask_of(&snap1, "S1").unwrap().bits(),
6419            (EventMask::LOG | EventMask::ALARM).bits(),
6420            "idle sweep posts DBE_LOG + the alarm transition, never DBE_VALUE"
6421        );
6422
6423        // Cycle 2: S1's count changed. Change-detection delivers it, and the
6424        // sweep delivers it AGAIN with DBE_LOG — the two C `db_post_events`
6425        // calls of the count-completion cycle.
6426        inst.record.put_field("S1", EpicsValue::Long(8)).unwrap();
6427        let (snap2, _) = inst.process_local().unwrap();
6428        assert_eq!(
6429            count_of(&snap2, "S1"),
6430            2,
6431            "a changed swept field posts twice — change post + independent \
6432             DBE_LOG sweep: {:?}",
6433            snap2.changed_fields
6434        );
6435        let s1_masks: Vec<u16> = snap2
6436            .changed_fields
6437            .iter()
6438            .filter(|(n, _, _)| n == "S1")
6439            .map(|(_, _, m)| m.bits())
6440            .collect();
6441        assert_eq!(
6442            s1_masks,
6443            vec![
6444                (EventMask::VALUE | EventMask::LOG).bits(),
6445                EventMask::LOG.bits()
6446            ],
6447            "change post first (VALUE|LOG here — this stub is not a \
6448             value_only_change_fields record), then the sweep's literal DBE_LOG"
6449        );
6450
6451        // Cycle 3: unchanged again — back to the DBE_LOG-only sweep.
6452        let (snap3, _) = inst.process_local().unwrap();
6453        assert_eq!(
6454            mask_of(&snap3, "S1").unwrap().bits(),
6455            EventMask::LOG.bits(),
6456            "unchanged-again S1 returns to the DBE_LOG-only sweep"
6457        );
6458    }
6459
6460    /// A log-swept record that can raise an alarm on demand — the scaler's
6461    /// `do_alarm()` (scalerRecord.c:745-755) in miniature.
6462    struct AlarmingLogSweepRecord {
6463        s1: i32,
6464        alarm: bool,
6465    }
6466
6467    impl Record for AlarmingLogSweepRecord {
6468        fn record_type(&self) -> &'static str {
6469            "scaler"
6470        }
6471        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
6472            Ok(crate::server::record::ProcessOutcome::complete())
6473        }
6474        /// This fixture drives its alarm purely through `check_alarms`
6475        /// (`self.alarm`), so it must NOT also raise the central UDF alarm —
6476        /// otherwise the born `udf = 1` pins severity at INVALID every cycle
6477        /// and there is never a real NO_ALARM → INVALID transition to test.
6478        /// (Before `rec_gbl_check_udf` stopped fabricating a UDF message, the
6479        /// ALARM bit this test asserts came from that fabricated amsg
6480        /// flipping to "" — an artifact, not the severity transition the
6481        /// test name and comments describe.) With no UDF alarm, cycle 1
6482        /// genuinely clears the born UDF/INVALID to NO_ALARM, and the
6483        /// `self.alarm` cycle is a true severity transition.
6484        fn raises_udf_alarm(&self) -> bool {
6485            false
6486        }
6487        fn check_alarms(&mut self, common: &mut crate::server::record::CommonFields) {
6488            if self.alarm {
6489                crate::server::recgbl::rec_gbl_set_sevr(
6490                    common,
6491                    crate::server::recgbl::alarm_status::UDF_ALARM,
6492                    crate::server::record::AlarmSeverity::Invalid,
6493                );
6494            }
6495        }
6496        fn get_field(&self, name: &str) -> Option<EpicsValue> {
6497            match name {
6498                "S1" => Some(EpicsValue::Long(self.s1)),
6499                _ => None,
6500            }
6501        }
6502        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
6503            match (name, value) {
6504                ("S1", EpicsValue::Long(v)) => {
6505                    self.s1 = v;
6506                    Ok(())
6507                }
6508                _ => Err(CaError::FieldNotFound(name.to_string())),
6509            }
6510        }
6511        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6512            &[]
6513        }
6514        fn log_swept_fields(&self) -> &'static [&'static str] {
6515            &["S1"]
6516        }
6517        fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
6518            Some(self)
6519        }
6520    }
6521
6522    /// CBUG-B19 — the sweep post carries the alarm-transition bits.
6523    ///
6524    /// DEVIATION from C, deliberate. C's scaler `monitor()` computes
6525    /// `monitor_mask = recGblResetAlarms(pscal)` (scalerRecord.c:764), ORs
6526    /// `DBE_VALUE|DBE_LOG` into it (`:766`), and then posts every `Sn` with a
6527    /// LITERAL `DBE_LOG` (`:771`) — `monitor_mask` is assigned, OR-ed, and never
6528    /// read. The alarm bit that `recGblResetAlarms` returns is exactly what every
6529    /// other record ORs into its value posts, so C drops it: a client subscribed
6530    /// to `Sn` with DBE_ALARM receives NOTHING on a severity transition.
6531    ///
6532    /// The DBE_VALUE half of C's dead `|=` is deliberately not resurrected — the
6533    /// sweep is unconditional, so a VALUE bit here would fire a value event on
6534    /// every idle scan whether or not the counts moved. The first assertion pins
6535    /// that.
6536    #[test]
6537    fn b19_log_swept_field_carries_the_alarm_transition_bits() {
6538        use crate::server::recgbl::EventMask;
6539        let mut inst = RecordInstance::new(
6540            "SW".to_string(),
6541            AlarmingLogSweepRecord {
6542                s1: 7,
6543                alarm: false,
6544            },
6545        );
6546        let _s1_rx = inst
6547            .add_subscriber(
6548                "S1",
6549                1,
6550                crate::types::DbFieldType::Long,
6551                (EventMask::LOG | EventMask::ALARM).bits(),
6552            )
6553            .expect("S1 subscriber");
6554        let mask_of = |snap: &ProcessSnapshot, f: &str| {
6555            snap.changed_fields
6556                .iter()
6557                .find(|(n, _, _)| n == f)
6558                .map(|(_, _, m)| *m)
6559        };
6560
6561        // Cycle 1 clears the record's initial UDF/INVALID alarm, which is itself
6562        // a transition; cycle 2 is the quiet baseline. The sweep is then DBE_LOG
6563        // alone — in particular NOT DBE_VALUE, since the counts have not moved.
6564        let _ = inst.process_local().unwrap();
6565        let (snap1, _) = inst.process_local().unwrap();
6566        assert_eq!(
6567            mask_of(&snap1, "S1").unwrap().bits(),
6568            EventMask::LOG.bits(),
6569            "no alarm transition → the sweep is DBE_LOG only"
6570        );
6571
6572        // The alarm fires: severity moves NO_ALARM → INVALID, so this cycle's
6573        // posts carry DBE_ALARM. C posts DBE_LOG here and the alarm subscriber
6574        // learns nothing.
6575        if let Some(r) = inst
6576            .record
6577            .as_any_mut()
6578            .and_then(|a| a.downcast_mut::<AlarmingLogSweepRecord>())
6579        {
6580            r.alarm = true;
6581        }
6582        let (snap2, _) = inst.process_local().unwrap();
6583        assert_eq!(
6584            mask_of(&snap2, "S1").unwrap().bits(),
6585            (EventMask::LOG | EventMask::ALARM).bits(),
6586            "the severity transition must reach the swept field (C drops it)"
6587        );
6588
6589        // Severity stays INVALID: no transition, so no alarm bit — the sweep is
6590        // DBE_LOG again.
6591        let (snap3, _) = inst.process_local().unwrap();
6592        assert_eq!(
6593            mask_of(&snap3, "S1").unwrap().bits(),
6594            EventMask::LOG.bits(),
6595            "a steady severity is not a transition"
6596        );
6597    }
6598
6599    /// Stub record that simulates a record whose process() mutates an
6600    /// internal metadata field. Used to verify that the
6601    /// `Record::took_metadata_change()` hook actually triggers cache
6602    /// invalidation in `process_local()`.
6603    struct MutatingMetaRecord {
6604        val: f64,
6605        egu: String,
6606        took_change: bool,
6607    }
6608
6609    impl Record for MutatingMetaRecord {
6610        fn record_type(&self) -> &'static str {
6611            "ai" // pretend to be ai so populate_display_info populates EGU
6612        }
6613        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
6614            // Simulate dynamic metadata change inside processing
6615            self.egu = "kV".into();
6616            self.took_change = true;
6617            Ok(crate::server::record::ProcessOutcome::complete())
6618        }
6619        fn get_field(&self, name: &str) -> Option<EpicsValue> {
6620            match name {
6621                "VAL" => Some(EpicsValue::Double(self.val)),
6622                "EGU" => Some(EpicsValue::String(self.egu.clone().into())),
6623                "PREC" => Some(EpicsValue::Short(0)),
6624                "HOPR" => Some(EpicsValue::Double(0.0)),
6625                "LOPR" => Some(EpicsValue::Double(0.0)),
6626                _ => None,
6627            }
6628        }
6629        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
6630            match (name, value) {
6631                ("VAL", EpicsValue::Double(v)) => {
6632                    self.val = v;
6633                    Ok(())
6634                }
6635                ("EGU", EpicsValue::String(s)) => {
6636                    self.egu = s.as_str_lossy().into_owned();
6637                    Ok(())
6638                }
6639                _ => Err(CaError::FieldNotFound(name.to_string())),
6640            }
6641        }
6642        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6643            &[]
6644        }
6645        fn took_metadata_change(&mut self) -> bool {
6646            let was = self.took_change;
6647            self.took_change = false; // reset after reporting
6648            was
6649        }
6650    }
6651
6652    #[test]
6653    fn process_local_invalidates_cache_on_took_metadata_change() {
6654        let mut inst = RecordInstance::new(
6655            "MUT".to_string(),
6656            MutatingMetaRecord {
6657                val: 1.0,
6658                egu: "V".to_string(),
6659                took_change: false,
6660            },
6661        );
6662
6663        // Build the cache once with the original EGU
6664        let snap1 = inst.snapshot_for_field("VAL").unwrap();
6665        assert_eq!(snap1.display.unwrap().units, "V");
6666        assert!(inst.metadata_cache.lock().unwrap().is_some());
6667
6668        // Run process_local — the stub record sets took_change inside process()
6669        let _ = inst.process_local();
6670
6671        // Cache should now be invalidated (took_metadata_change returned true)
6672        assert!(
6673            inst.metadata_cache.lock().unwrap().is_none(),
6674            "process_local should invalidate cache when took_metadata_change is true"
6675        );
6676
6677        // Next snapshot picks up the new EGU
6678        let snap2 = inst.snapshot_for_field("VAL").unwrap();
6679        assert_eq!(snap2.display.unwrap().units, "kV");
6680    }
6681
6682    /// Stub record that does NOT mutate metadata fields. Verifies the
6683    /// default `took_metadata_change` returns false and the cache stays.
6684    struct StableMetaRecord {
6685        val: f64,
6686    }
6687    impl Record for StableMetaRecord {
6688        fn record_type(&self) -> &'static str {
6689            "ai"
6690        }
6691        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
6692            self.val += 1.0;
6693            Ok(crate::server::record::ProcessOutcome::complete())
6694        }
6695        fn get_field(&self, name: &str) -> Option<EpicsValue> {
6696            match name {
6697                "VAL" => Some(EpicsValue::Double(self.val)),
6698                "EGU" => Some(EpicsValue::String("V".into())),
6699                "PREC" => Some(EpicsValue::Short(0)),
6700                "HOPR" => Some(EpicsValue::Double(0.0)),
6701                "LOPR" => Some(EpicsValue::Double(0.0)),
6702                _ => None,
6703            }
6704        }
6705        fn put_field(&mut self, _: &str, _: EpicsValue) -> CaResult<()> {
6706            Ok(())
6707        }
6708        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
6709            &[]
6710        }
6711        // took_metadata_change uses default impl (returns false)
6712    }
6713
6714    #[test]
6715    fn process_local_keeps_cache_when_no_metadata_change() {
6716        let mut inst = RecordInstance::new("STABLE".to_string(), StableMetaRecord { val: 0.0 });
6717
6718        let _ = inst.snapshot_for_field("VAL");
6719        assert!(inst.metadata_cache.lock().unwrap().is_some());
6720
6721        // Run process_local several times — cache should remain intact
6722        let _ = inst.process_local();
6723        assert!(inst.metadata_cache.lock().unwrap().is_some());
6724        let _ = inst.process_local();
6725        assert!(inst.metadata_cache.lock().unwrap().is_some());
6726        let _ = inst.process_local();
6727        assert!(inst.metadata_cache.lock().unwrap().is_some());
6728    }
6729
6730    // ── Regression: DBE_PROPERTY event delivery boundaries ──────────────
6731
6732    /// motor `prop(YES)` fields (motorRecord.dbd 154/161/289/361/368)
6733    /// are property-class: a changed write must post DBE_PROPERTY
6734    /// (C dbAccess.c dbPut, `pfldDes->prop`). They feed the
6735    /// live-computed `field_metadata_override`, not the cache, but the
6736    /// posting gate is this same set.
6737    #[test]
6738    fn motor_prop_yes_fields_are_property_class() {
6739        for f in ["VBAS", "VMAX", "MRES", "DHLM", "DLLM"] {
6740            assert!(is_metadata_field(f), "{f} must be property-class");
6741        }
6742    }
6743
6744    /// Boundary 1: metadata field written with a CHANGED value, subscriber
6745    /// mask includes PROPERTY → subscriber receives an event.
6746    /// Mirrors C dbAccess.c:1396-1397 `db_post_events(precord,NULL,DBE_PROPERTY)`.
6747    #[test]
6748    fn r47_property_event_delivered_on_changed_metadata() {
6749        use crate::server::recgbl::EventMask;
6750        let mut inst = ai_instance();
6751        let mut rx = inst
6752            .add_subscriber(
6753                "VAL",
6754                1,
6755                crate::types::DbFieldType::Double,
6756                EventMask::PROPERTY.bits(),
6757            )
6758            .expect("subscriber added");
6759
6760        let prev = inst.record.get_field("EGU"); // "degC"
6761        let _ = inst
6762            .record
6763            .put_field("EGU", EpicsValue::String("kPa".into()));
6764        inst.notify_field_written_if_changed("EGU", prev.as_ref());
6765
6766        assert!(
6767            rx.try_recv().is_ok(),
6768            "PROPERTY subscriber must receive event when metadata field changes"
6769        );
6770    }
6771
6772    /// Boundary 2: same metadata field written with the SAME value → NO event.
6773    /// Matches C suppression at dbAccess.c:1379-1383 and the `prev != now` gate.
6774    #[test]
6775    fn r47_no_event_on_unchanged_metadata() {
6776        use crate::server::recgbl::EventMask;
6777        let mut inst = ai_instance();
6778        let mut rx = inst
6779            .add_subscriber(
6780                "VAL",
6781                1,
6782                crate::types::DbFieldType::Double,
6783                EventMask::PROPERTY.bits(),
6784            )
6785            .expect("subscriber added");
6786
6787        let prev = inst.record.get_field("EGU"); // "degC"
6788        // Write the same value — no change
6789        let _ = inst.record.put_field("EGU", prev.clone().unwrap());
6790        inst.notify_field_written_if_changed("EGU", prev.as_ref());
6791
6792        assert!(
6793            rx.try_recv().is_err(),
6794            "PROPERTY subscriber must NOT receive event when metadata value is unchanged"
6795        );
6796    }
6797
6798    /// Boundary 3: VALUE-only subscriber (no PROPERTY bit) receives NO event
6799    /// from a metadata write, even when the field value changed.
6800    #[test]
6801    fn r47_value_only_subscriber_no_event_on_metadata_write() {
6802        use crate::server::recgbl::EventMask;
6803        let mut inst = ai_instance();
6804        let mut rx = inst
6805            .add_subscriber(
6806                "VAL",
6807                1,
6808                crate::types::DbFieldType::Double,
6809                EventMask::VALUE.bits(),
6810            )
6811            .expect("subscriber added");
6812
6813        let prev = inst.record.get_field("EGU"); // "degC"
6814        let _ = inst
6815            .record
6816            .put_field("EGU", EpicsValue::String("kPa".into()));
6817        inst.notify_field_written_if_changed("EGU", prev.as_ref());
6818
6819        assert!(
6820            rx.try_recv().is_err(),
6821            "VALUE-only subscriber must NOT receive event from a metadata write"
6822        );
6823    }
6824
6825    /// Boundary 4 (took_metadata_change path): PROPERTY subscriber receives
6826    /// event after process_local() when the record reports a metadata change.
6827    #[test]
6828    fn r47_process_local_property_event_on_took_metadata_change() {
6829        use crate::server::recgbl::EventMask;
6830        let mut inst = RecordInstance::new(
6831            "MUT2".to_string(),
6832            MutatingMetaRecord {
6833                val: 1.0,
6834                egu: "V".to_string(),
6835                took_change: false,
6836            },
6837        );
6838        let mut rx = inst
6839            .add_subscriber(
6840                "VAL",
6841                1,
6842                crate::types::DbFieldType::Double,
6843                EventMask::PROPERTY.bits(),
6844            )
6845            .expect("subscriber added");
6846
6847        // process() sets took_change = true and updates egu to "kV"
6848        let _ = inst.process_local();
6849
6850        assert!(
6851            rx.try_recv().is_ok(),
6852            "PROPERTY subscriber must receive event after process_local reports took_metadata_change"
6853        );
6854    }
6855}
6856
6857#[cfg(test)]
6858mod aftc_filter_tests {
6859    //! Tests for the shared AFTC alarm-range filter
6860    //! (`records::alarm_filter::aftc_filter`) as driven by
6861    //! `evaluate_analog_alarm`. Pure-function tests: no record instance
6862    //! needed — the filter is a stateless transform of (raw_alarm, aftc,
6863    //! afvl_in, t_last, t_now). Algorithm provenance: 2009 EPICS
6864    //! Codeathon (epics-base `824d37811`), C `aiRecord.c:355-401`.
6865
6866    use crate::server::records::alarm_filter::aftc_filter;
6867    use std::time::{Duration, SystemTime};
6868
6869    fn at(secs: f64) -> SystemTime {
6870        SystemTime::UNIX_EPOCH + Duration::from_secs_f64(secs)
6871    }
6872
6873    #[test]
6874    fn disabled_when_aftc_le_zero() {
6875        // aftc=0 means filter disabled — pass-through.
6876        let (out, afvl) = aftc_filter(2, 0.0, 0.0, at(0.0), at(1.0));
6877        assert_eq!(out, 2);
6878        assert_eq!(afvl, 0.0);
6879    }
6880
6881    #[test]
6882    fn initial_sample_seeds_state_unchanged_alarm() {
6883        // afvl=0 means first sample after enable — alarm passes through
6884        // and accumulator seeds with the raw severity.
6885        let (out, afvl) = aftc_filter(2, 3.0, 0.0, at(0.0), at(0.5));
6886        assert_eq!(out, 2);
6887        assert_eq!(afvl, 2.0);
6888    }
6889
6890    #[test]
6891    fn raises_alarm_only_after_full_time_constant() {
6892        // Single-step heuristic: with `aftc = 3s` and `dt = 0.1s`, alpha
6893        // ≈ 0.967, so a one-shot raw_alarm=2 against afvl=0.0 should not
6894        // produce alarm=2 yet — the filter must hold off until the
6895        // accumulator crosses the threshold.
6896        // Seed with afvl=0.01 (tiny prior, simulating "almost no alarm
6897        // yet"); the filter must keep alarm at 0 after one short tick.
6898        let (out, afvl) = aftc_filter(2, 3.0, 0.01, at(0.0), at(0.1));
6899        assert_eq!(out, 0, "filter should suppress alarm rise on a 0.1s tick");
6900        assert!(afvl > 0.0 && afvl < 2.0);
6901    }
6902
6903    #[test]
6904    fn dt_zero_is_no_op() {
6905        // Two evaluations at the same instant produce no filter advance.
6906        let (out, afvl) = aftc_filter(2, 3.0, 1.5, at(0.0), at(0.0));
6907        assert_eq!(out, 1); // floor(|1.5|) = 1
6908        assert_eq!(afvl, 1.5);
6909    }
6910
6911    #[test]
6912    fn long_steady_state_converges_to_alarm() {
6913        // After many steps with raw_alarm=2 and dt much smaller than aftc,
6914        // the accumulator must converge towards 2.
6915        let aftc = 1.0;
6916        let mut afvl = 0.0;
6917        let mut last = at(0.0);
6918        let mut alarm = 0;
6919        for i in 1..=100 {
6920            let now = at(i as f64 * 0.05);
6921            let (out, new_afvl) = aftc_filter(2, aftc, afvl, last, now);
6922            alarm = out;
6923            afvl = new_afvl;
6924            last = now;
6925        }
6926        assert_eq!(
6927            alarm, 2,
6928            "after 5 s of steady raw=2 with aftc=1 s, output must reach 2"
6929        );
6930        assert!(afvl.abs() >= 1.99 && afvl.abs() <= 2.0);
6931    }
6932}
6933
6934#[cfg(test)]
6935mod check_deadband_tests {
6936    use super::check_deadband;
6937
6938    /// Sentinel: `oldval=NaN` means "no prior posting", always fire.
6939    #[test]
6940    fn nan_old_value_fires() {
6941        assert!(check_deadband(0.0, f64::NAN, 1.0));
6942        assert!(check_deadband(f64::NAN, f64::NAN, 1.0));
6943    }
6944
6945    /// C path: `delta > deadband` with both finite. delta within deadband
6946    /// must NOT fire.
6947    #[test]
6948    fn within_finite_deadband_does_not_fire() {
6949        assert!(!check_deadband(10.0, 10.5, 1.0));
6950        assert!(!check_deadband(10.0, 9.5, 1.0));
6951        // Boundary: `delta == deadband` is NOT strictly greater.
6952        assert!(!check_deadband(10.0, 11.0, 1.0));
6953    }
6954
6955    /// `delta > deadband` with both finite, beyond → fire.
6956    #[test]
6957    fn beyond_finite_deadband_fires() {
6958        assert!(check_deadband(10.0, 12.0, 1.0));
6959    }
6960
6961    /// Negative deadband acts as "always fire" (C `delta > deadband` is
6962    /// trivially true for any non-negative delta).
6963    #[test]
6964    fn negative_deadband_fires() {
6965        assert!(check_deadband(10.0, 10.0, -1.0));
6966    }
6967
6968    /// C parity bug fix (recGbl.c:355-358): exactly one of {newval,
6969    /// oldval} is NaN — fire. Rust port previously short-circuited only
6970    /// on `oldval=NaN`; `newval=NaN` with `oldval=finite` produced
6971    /// `(NaN - finite).abs() = NaN`, `NaN > deadband = false` →
6972    /// silently dropped the NaN transition. End effect: a record that
6973    /// went UDF (e.g. divide-by-zero in calc) never posted the change
6974    /// to monitors, leaving every camonitor seeing the last valid value.
6975    #[test]
6976    fn newval_nan_with_finite_oldval_fires() {
6977        assert!(check_deadband(f64::NAN, 10.0, 1.0));
6978    }
6979
6980    /// C path case 2 (recGbl.c:355): exactly one infinite, the other
6981    /// finite — fire.
6982    #[test]
6983    fn one_finite_one_infinite_fires() {
6984        assert!(check_deadband(f64::INFINITY, 10.0, 1.0));
6985        assert!(check_deadband(10.0, f64::INFINITY, 1.0));
6986        assert!(check_deadband(f64::NEG_INFINITY, 10.0, 1.0));
6987    }
6988
6989    /// C path case 3 (recGbl.c:360-362): both infinite with opposite
6990    /// signs — fire.
6991    #[test]
6992    fn opposite_signed_infinities_fire() {
6993        assert!(check_deadband(f64::INFINITY, f64::NEG_INFINITY, 1.0));
6994        assert!(check_deadband(f64::NEG_INFINITY, f64::INFINITY, 1.0));
6995    }
6996
6997    /// Same-signed infinity → no fire (C path leaves `delta = 0`,
6998    /// `0 > deadband` is false for any non-negative deadband).
6999    #[test]
7000    fn same_signed_infinity_does_not_fire() {
7001        assert!(!check_deadband(f64::INFINITY, f64::INFINITY, 1.0));
7002        assert!(!check_deadband(f64::NEG_INFINITY, f64::NEG_INFINITY, 1.0));
7003    }
7004}
7005
7006#[cfg(test)]
7007mod common_field_dbload_tests {
7008    use super::*;
7009    use crate::server::records::ai::AiRecord;
7010
7011    /// The db loader feeds every common field to `put_common_field` as an
7012    /// `EpicsValue::String`. Each numeric/menu common field directive must
7013    /// take effect at load — both the integer form (`field(PHAS, "1")`) and
7014    /// the menu-label form (`field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`) —
7015    /// rather than being silently dropped because the arm matched only its
7016    /// typed variant. One assertion per affected common-field arm.
7017    #[test]
7018    fn db_loaded_string_common_fields_take_effect() {
7019        let mut inst = RecordInstance::new("REC".to_string(), AiRecord::default());
7020        let put = |inst: &mut RecordInstance, f: &str, v: &str| {
7021            inst.put_common_field_db_load(f, EpicsValue::String(v.into()))
7022                .unwrap_or_else(|e| panic!("put_common_field_db_load({f}, {v:?}) failed: {e}"));
7023        };
7024
7025        // Integer-valued directives.
7026        put(&mut inst, "PHAS", "1");
7027        assert_eq!(inst.common.phas, 1, "field(PHAS, \"1\")");
7028        put(&mut inst, "TSE", "-2");
7029        assert_eq!(inst.common.tse, -2, "field(TSE, \"-2\")");
7030        put(&mut inst, "DISV", "1");
7031        assert_eq!(inst.common.disv, 1, "field(DISV, \"1\")");
7032        put(&mut inst, "DISA", "1");
7033        assert_eq!(inst.common.disa, 1, "field(DISA, \"1\")");
7034        put(&mut inst, "LCNT", "3");
7035        assert_eq!(inst.common.lcnt, 3, "field(LCNT, \"3\")");
7036        put(&mut inst, "DISP", "1");
7037        assert!(inst.common.disp != 0, "field(DISP, \"1\")");
7038        put(&mut inst, "UDF", "0");
7039        assert!(inst.common.udf == 0, "field(UDF, \"0\")");
7040
7041        // Menu-label directives (resolved via the one menu converter).
7042        put(&mut inst, "PRIO", "HIGH");
7043        assert_eq!(inst.common.prio, 2, "field(PRIO, \"HIGH\")");
7044        put(&mut inst, "DISS", "MAJOR");
7045        assert_eq!(
7046            inst.common.diss,
7047            AlarmSeverity::Major as i16,
7048            "field(DISS, \"MAJOR\")"
7049        );
7050        put(&mut inst, "UDFS", "NO_ALARM");
7051        assert_eq!(
7052            inst.common.udfs,
7053            AlarmSeverity::NoAlarm as i16,
7054            "field(UDFS, \"NO_ALARM\")"
7055        );
7056        put(&mut inst, "ACKT", "NO");
7057        assert!(!inst.common.ackt, "field(ACKT, \"NO\")");
7058
7059        // Numeric form of a menu field still works (field(PRIO, "0")).
7060        put(&mut inst, "PRIO", "0");
7061        assert_eq!(inst.common.prio, 0, "field(PRIO, \"0\")");
7062
7063        // A String-typed common field is untouched by the coercion.
7064        put(&mut inst, "DESC", "a description");
7065        assert_eq!(inst.common.desc.as_str_lossy().as_ref(), "a description");
7066    }
7067}
7068
7069#[cfg(test)]
7070mod declared_override_tests {
7071    use super::*;
7072    use crate::server::records::dfanout::DfanoutRecord;
7073
7074    /// A field `dfanout`'s `.dbd` DECLARES (HOPR/LOPR/PREC/EGU) but the
7075    /// `DfanoutRecord` struct models no storage for: a put must be ACCEPTED and
7076    /// stored (C `dbPut` writes it into record memory), and a later
7077    /// `resolve_field` must serve the written value — not the `.dbd` initial.
7078    #[test]
7079    fn declared_but_unmodeled_field_put_is_stored_and_served() {
7080        let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
7081
7082        // Untouched: reads its declared default (initial / type-zero), NOT an
7083        // error, and the override store is empty.
7084        assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(0.0)));
7085        assert!(inst.declared_overrides.is_empty());
7086
7087        // DBF_DOUBLE, DBF_SHORT and DBF_STRING declared metadata fields all
7088        // land, coerced to the declared type.
7089        inst.put_common_field("HOPR", EpicsValue::String("10".into()))
7090            .expect("caput dfanout.HOPR 10 must be accepted");
7091        inst.put_common_field("PREC", EpicsValue::String("3".into()))
7092            .expect("caput dfanout.PREC 3 must be accepted");
7093        inst.put_common_field("EGU", EpicsValue::String("volts".into()))
7094            .expect("caput dfanout.EGU volts must be accepted");
7095
7096        assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(10.0)));
7097        assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
7098        assert_eq!(
7099            inst.resolve_field("EGU"),
7100            Some(EpicsValue::String("volts".into()))
7101        );
7102        // Case-insensitive key: the lower-case read reaches the same slot.
7103        assert_eq!(inst.resolve_field("hopr"), Some(EpicsValue::Double(10.0)));
7104    }
7105
7106    /// The declared type's C range rules apply through the write-side coercion
7107    /// owner: `caput dfanout.PREC 99999` into a `DBF_SHORT` is REFUSED (C
7108    /// `epicsParseInt16` overflow → `S_db_badField`), and the field keeps its
7109    /// prior value — never wraps to a garbage `Short`.
7110    #[test]
7111    fn declared_override_honors_declared_type_range() {
7112        let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
7113        inst.put_common_field("PREC", EpicsValue::String("3".into()))
7114            .expect("in-range PREC accepted");
7115        assert!(
7116            inst.put_common_field("PREC", EpicsValue::String("99999".into()))
7117                .is_err(),
7118            "PREC 99999 overflows DBF_SHORT and must be refused"
7119        );
7120        assert!(
7121            inst.put_common_field("PREC", EpicsValue::String("abc".into()))
7122                .is_err(),
7123            "non-numeric PREC must be refused"
7124        );
7125        // The refused puts left the accepted value intact.
7126        assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
7127    }
7128
7129    /// An UNDECLARED field name is still `FieldNotFound` — the override store
7130    /// captures only fields with a real `dbFldDes`, so a misspelled field is
7131    /// refused exactly as C's `dbNameToAddr` refuses it.
7132    #[test]
7133    fn undeclared_field_is_still_not_found() {
7134        let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
7135        assert!(matches!(
7136            inst.put_common_field("XYZZY", EpicsValue::String("1".into())),
7137            Err(CaError::FieldNotFound(_))
7138        ));
7139        assert!(inst.declared_overrides.is_empty());
7140    }
7141
7142    /// A PARTIALLY modeled field — one the record SERVES via `get_field` but
7143    /// has no `put_field` arm for (`calcout.PVAL` → `self.pval`) — must NOT
7144    /// land in the override map: doing so would place the value where
7145    /// `resolve_field` (which reads `get_field` first) never sees it, a silent
7146    /// write loss. The override is only for fields the record serves nothing
7147    /// for; a partially modeled field's put is the record's own concern.
7148    #[test]
7149    fn partially_modeled_field_is_not_captured_by_override() {
7150        use crate::server::records::calcout::CalcoutRecord;
7151        let mut inst = RecordInstance::new("CO".to_string(), CalcoutRecord::default());
7152        // PVAL is served by the record (its own storage), so it is not stored
7153        // in the override map; the map stays empty and no ghost cell shadows
7154        // the record's read.
7155        let _ = inst.put_common_field("PVAL", EpicsValue::String("1".into()));
7156        assert!(
7157            inst.declared_overrides.is_empty(),
7158            "a field the record serves via get_field must not enter the override map"
7159        );
7160    }
7161}