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::runtime::sync::mpsc;
7
8use crate::error::{CaError, CaResult};
9use crate::server::pv::{MonitorEvent, Subscriber};
10use crate::server::snapshot::{ControlInfo, DisplayInfo, EnumInfo};
11use crate::types::{DbFieldType, EpicsValue, PvString};
12
13use super::alarm::{AlarmSeverity, AnalogAlarmConfig};
14use super::common_fields::CommonFields;
15use super::link::{ParsedLink, parse_link_v2, parse_output_link_v2};
16use super::record_trait::{
17    CommonFieldPutResult, ProcessSnapshot, Record, RecordProcessResult, SubroutineFn,
18};
19use super::scan::ScanType;
20
21/// Put-notify completion wait-set — the C `dbNotify.c` `processNotify`
22/// waitList analogue (`dbNotifyAdd` / `dbNotifyCompletion`).
23///
24/// A `ca_put_callback` / WRITE_NOTIFY completion must fire only after the
25/// originating (put-target) record AND every record reached through its
26/// FLNK / OUT / process-action dispatch chain (synchronous *or* async)
27/// has finished processing. A single wait-set owns the completion
28/// oneshot; only it fires, and only when the last chain member leaves.
29///
30/// Counting convention: [`Self::new`] arms `pending = 1` for the
31/// originating record (which always joins). Every additional PP target
32/// that will process under the active notify [`Self::enter`]s on join
33/// (C `dbNotifyAdd`), and every record [`Self::leave`]s when its
34/// processing completes (C `dbNotifyCompletion`). The oneshot fires on
35/// the `leave` that drops `pending` to zero.
36pub struct NotifyWaitSet {
37    pending: AtomicUsize,
38    tx: StdMutex<Option<crate::runtime::sync::oneshot::Sender<()>>>,
39}
40
41impl NotifyWaitSet {
42    /// Arm a wait-set whose `tx` fires when the chain settles. `pending`
43    /// starts at 1 for the originating record — its completion `leave`s
44    /// that implicit slot, so a put with no chain targets fires
45    /// immediately on the originating record's own completion.
46    pub fn new(tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
47        Arc::new(Self {
48            pending: AtomicUsize::new(1),
49            tx: StdMutex::new(Some(tx)),
50        })
51    }
52
53    /// A PP target joined the chain (C `dbNotifyAdd`). Balanced by exactly
54    /// one [`Self::leave`].
55    pub fn enter(&self) {
56        self.pending.fetch_add(1, Ordering::AcqRel);
57    }
58
59    /// A record finished its contribution (C `dbNotifyCompletion`). Fires
60    /// the completion oneshot on the `leave` that empties the set.
61    pub fn leave(&self) {
62        let prev = self.pending.fetch_sub(1, Ordering::AcqRel);
63        debug_assert!(prev >= 1, "NotifyWaitSet::leave underflow");
64        if prev == 1 {
65            if let Some(tx) = self.tx.lock().unwrap().take() {
66                let _ = tx.send(());
67            }
68        }
69    }
70
71    /// True once every chain member has left (the completion has fired).
72    /// Used by the put entry to decide synchronous (return `None`) vs
73    /// async-pending (return the receiver) completion.
74    pub fn completed(&self) -> bool {
75        self.pending.load(Ordering::Acquire) == 0
76    }
77}
78
79/// Cached metadata for a record.
80///
81/// Stores the result of `populate_display_info` / `populate_control_info` /
82/// `populate_enum_info` so subsequent `snapshot_for_field` /
83/// `make_monitor_snapshot` calls can skip rebuilding the metadata. The
84/// cache is invalidated whenever a metadata-class field is written
85/// (EGU, PREC, HOPR, LOPR, alarm limits, DRVH/DRVL, state strings).
86///
87/// In a CA-only IOC this is a CPU win; in a hybrid CA + PVA IOC where
88/// every snapshot needs full metadata for NTScalar serialization, the
89/// cache eliminates redundant per-event populate work.
90#[derive(Clone, Default)]
91pub(crate) struct MetadataSnapshot {
92    pub display: Option<DisplayInfo>,
93    pub control: Option<ControlInfo>,
94    pub enums: Option<EnumInfo>,
95}
96
97/// Returns true if this field is property-class — the C `prop(YES)`
98/// dbd attribute: writing a changed value posts `DBE_PROPERTY` to the
99/// record's subscribers AND invalidates the metadata cache. Field name
100/// is expected uppercase.
101///
102/// **Every field read by `populate_display_info`,
103/// `populate_control_info`, or `populate_enum_info` MUST be in this
104/// set** — otherwise the cache serves stale metadata until some other
105/// tracked field is written. The reverse does not hold: a field may be
106/// property-class without being a cache source (e.g. the motor fields
107/// below feed the live-computed `field_metadata_override`, never the
108/// cache — its invalidation on their write is harmless).
109///
110/// Currently uncovered (because they are not yet populated by any
111/// `populate_*` function): `DESC` (would map to `display.description`
112/// — populate hook missing), `Q:form` info tag (would map to
113/// `display.form`). Add to this set if/when those are wired up.
114fn is_metadata_field(name: &str) -> bool {
115    matches!(
116        name,
117        // Display info (analog + integer + motor) — `prop(YES)` in
118        // ai/ao/longin/longout DBDs.
119        "EGU" | "PREC" | "HOPR" | "LOPR" | "HLM" | "LLM"
120        // Alarm limits (used by both display and the analog_alarm config) —
121        // ai/ao/longin/longout `prop(YES)`.
122        | "HIHI" | "HIGH" | "LOW" | "LOLO"
123        // Alarm severities for the four limit thresholds —
124        // ai/ao/longin/longout `prop(YES)` per upstream DBDs
125        // (`aiRecord.dbd.pod` lines 357-388).
126        | "HHSV" | "HSV" | "LSV" | "LLSV"
127        // Output ctrl limits — ao/longout `prop(YES)`.
128        | "DRVH" | "DRVL"
129        // motor `prop(YES)` (`motorRecord.dbd` 154/161/289/361/368):
130        // VBAS/VMAX bound VELO's range, MRES the RVAL/RRBV raw range,
131        // DHLM/DLLM the DVAL/DRBV range — all served per field by
132        // `Record::field_metadata_override` (C get_graphic_double /
133        // get_control_double). HLM/LLM/EGU/PREC and the alarm limits
134        // are motor `prop(YES)` too, already listed above.
135        | "VBAS" | "VMAX" | "MRES" | "DHLM" | "DLLM"
136        // bi/bo/busy enum strings — `prop(YES)`.
137        | "ZNAM" | "ONAM"
138        // bi/bo state severities — `biRecord.dbd.pod` / `boRecord.dbd.pod`
139        // `prop(YES)` for ZSV/OSV/COSV (zero / one / change-of-state).
140        | "ZSV" | "OSV" | "COSV"
141        // mbbi/mbbo state strings (16 levels) — `prop(YES)`.
142        | "ZRST" | "ONST" | "TWST" | "THST" | "FRST" | "FVST" | "SXST" | "SVST"
143        | "EIST" | "NIST" | "TEST" | "ELST" | "TVST" | "TTST" | "FTST" | "FFST"
144    )
145}
146
147/// One alarm limit for a DBR_AL_DOUBLE response: the value when its
148/// severity threshold is enabled, `NaN` otherwise. Mirrors C
149/// `get_alarm_double`'s `prec->hhsv ? prec->hihi : epicsNAN`.
150fn gated(severity: AlarmSeverity, limit: f64) -> f64 {
151    if severity != AlarmSeverity::NoAlarm {
152        limit
153    } else {
154        f64::NAN
155    }
156}
157
158fn parse_alarm_severity(value: &EpicsValue) -> AlarmSeverity {
159    match value {
160        EpicsValue::Short(v) => AlarmSeverity::from_u16(*v as u16),
161        EpicsValue::String(s) => AlarmSeverity::from_u16(match s.as_str_lossy().as_ref() {
162            "NO_ALARM" => 0,
163            "MINOR" => 1,
164            "MAJOR" => 2,
165            "INVALID" => 3,
166            other => other.parse::<u16>().unwrap_or(0),
167        }),
168        other => AlarmSeverity::from_u16(other.to_f64().unwrap_or(0.0) as u16),
169    }
170}
171
172/// Coerce a db-loaded `String` for a numeric/menu **common** field to that
173/// field's canonical DBF type before [`RecordInstance::put_common_field`]
174/// dispatches on it.
175///
176/// The db loader applies a record's own fields with the typed
177/// `EpicsValue::parse(desc.dbf_type, value_str)` (`db_loader::apply_fields`),
178/// but a field absent from `field_list` is pushed to the common-field path as
179/// a raw `EpicsValue::String` — it has no `FieldDesc` to parse against. The
180/// numeric common-field arms in `put_common_field` match only their typed
181/// variant, so without this step a `.db` `field(PHAS, "1")`,
182/// `field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`, `field(DISA, "1")`, … is
183/// silently dropped at IOC load. Routing the String through the same
184/// `EpicsValue::parse` the record-field path uses handles the numeric *and*
185/// menu-label forms uniformly, so the arm receives the value it expects.
186///
187/// Only fields whose canonical type is numeric/menu are listed; the
188/// String-typed common fields (DESC, ASG, OUT, TSEL, …) and already-typed
189/// non-String writes pass through untouched, and an unparseable String is
190/// returned as-is so the arm drops it exactly as before. The runtime
191/// alarm-output fields (SEVR/STAT/NSEV/NSTA/ACKS) and debug flags
192/// (RPRO/TPRO/BKPT) are deliberately omitted: they are recomputed every
193/// process, not `.db` init directives, so coercing a loaded value would be
194/// overwritten immediately.
195fn coerce_common_field_string(name: &str, value: EpicsValue) -> EpicsValue {
196    let s = match &value {
197        EpicsValue::String(s) => s,
198        _ => return value,
199    };
200    // Canonical DBF type per numeric/menu common field, chosen to match the
201    // variant its `put_common_field` arm binds (e.g. ACKT/UDFS resolve their
202    // menu labels through the `Short` branch's `resolve_menu_string`).
203    let dbf = match name {
204        "TSE" | "PHAS" | "PRIO" | "DISV" | "DISA" | "DISS" | "LCNT" | "UDFS" | "ACKT" => {
205            DbFieldType::Short
206        }
207        "DISP" | "UDF" => DbFieldType::Char,
208        _ => return value,
209    };
210    match EpicsValue::parse(dbf, s.as_str_lossy().trim()) {
211        Ok(parsed) => parsed,
212        Err(_) => value,
213    }
214}
215
216/// A type-erased record instance stored in the database.
217pub struct RecordInstance {
218    pub name: String,
219    pub record: Box<dyn Record>,
220    pub common: CommonFields,
221    pub subscribers: HashMap<String, Vec<Subscriber>>,
222    // Link parse cache
223    pub parsed_inp: ParsedLink,
224    pub parsed_out: ParsedLink,
225    pub parsed_flnk: ParsedLink,
226    pub parsed_sdis: ParsedLink,
227    pub parsed_tsel: ParsedLink,
228    // Device support
229    pub device: Option<Box<dyn super::super::device_support::DeviceSupport>>,
230    // Subroutine (for sub records)
231    pub subroutine: Option<Arc<SubroutineFn>>,
232    // Re-entrancy guard
233    pub processing: AtomicBool,
234    // Put-notify wait-set this record currently belongs to (C
235    // `precord->ppn`). Set when the record joins an active put-notify
236    // (originating put target, or a FLNK/OUT PP target via `dbNotifyAdd`);
237    // taken + `leave`d when the record's processing completes. `None`
238    // outside any put-notify. See [`NotifyWaitSet`].
239    pub notify: Option<Arc<NotifyWaitSet>>,
240    // Last posted values for subscribed fields (generic change detection)
241    pub last_posted: HashMap<String, EpicsValue>,
242    /// Set by `check_deadband_ext` for waveform/aai/aao when their
243    /// content hash changed this cycle (C `monitor()` On Change mode,
244    /// waveformRecord.c:310-319). The snapshot builders read it to post
245    /// `HASH` with a literal `DBE_VALUE` event, independent of the VAL
246    /// post mask. False for every record without the MPST/APST/HASH
247    /// mechanism.
248    pub(crate) array_hash_changed: bool,
249    /// Generation counter for ReprocessAfter timer cancellation.
250    /// Bumped each process cycle. Spawned timers check this to avoid
251    /// stale re-processes from accumulated timers.
252    pub reprocess_generation: Arc<std::sync::atomic::AtomicU64>,
253    /// Per-record info tags from `info("key", "value")` directives in
254    /// the .db file (epics-base info(...) grammar). Consumers include
255    /// asyn (`asyn:READBACK`), record-as-PV bridge tags
256    /// (`Q:group`, `Q:form`), and IOC-specific extensions. Empty for
257    /// records loaded without info(...) clauses.
258    pub info: HashMap<String, String>,
259    /// Cached metadata (display/control/enums) — `None` means stale or
260    /// not yet built. Populated lazily by `snapshot_for_field` /
261    /// `make_monitor_snapshot` and invalidated by `invalidate_metadata_cache`
262    /// whenever a metadata-class field (EGU/PREC/HOPR/LOPR/limit/state)
263    /// is written.
264    ///
265    /// Wrapped in `std::sync::Mutex` for interior mutability — the
266    /// containing `RecordInstance` is shared via `Arc<RwLock<...>>` from
267    /// `PvDatabase`, and snapshot construction holds a read lock; the
268    /// inner Mutex lets us still mutate the cache from a `&self` method.
269    ///
270    /// # Cache invariant (CONTRACT)
271    ///
272    /// The cache is **only correct under the following contract**: every
273    /// code path that mutates a metadata-class field (the set defined in
274    /// the file-private `is_metadata_field` predicate) MUST call
275    /// [`RecordInstance::notify_field_written`] (or
276    /// [`RecordInstance::invalidate_metadata_cache`] directly) afterward.
277    ///
278    /// All current write paths in `field_io.rs` already do this. If you
279    /// add a new code path that:
280    ///
281    /// - calls `instance.record.put_field(...)` directly, OR
282    /// - mutates record fields from inside `Record::process()`,
283    ///   `Record::on_put`, or `Record::special` and that mutation could
284    ///   touch a metadata-class field, OR
285    /// - lets a `Box<dyn Record>` implementation expose its own
286    ///   mutation methods that change metadata fields,
287    ///
288    /// then call `instance.notify_field_written(field_name)` to keep the
289    /// cache consistent. Forgetting will produce a stale snapshot —
290    /// monitors will continue to see the old EGU/PREC/limits until the
291    /// next legitimate metadata-field write triggers invalidation.
292    ///
293    /// # Symmetric note for `populate_*` extensions
294    ///
295    /// If a future change adds a new field to `populate_display_info`,
296    /// `populate_control_info`, or `populate_enum_info` (e.g. populating
297    /// `display.form` from a record's `Q:form` info tag, or
298    /// `display.description` from DESC), the new source field name MUST
299    /// also be added to `is_metadata_field` so writes to it invalidate
300    /// the cache.
301    pub(crate) metadata_cache: StdMutex<Option<MetadataSnapshot>>,
302}
303
304impl RecordInstance {
305    pub fn new(name: String, record: impl Record) -> Self {
306        Self::new_boxed(name, Box::new(record))
307    }
308
309    pub fn new_boxed(name: String, record: Box<dyn Record>) -> Self {
310        let rtype = record.record_type();
311        let analog_alarm = match rtype {
312            // C parity: every record type whose dbd carries
313            // HIHI/HIGH/LOW/LOLO/HHSV/HSV/LSV/LLSV gets an analog-alarm
314            // config slot. Previously calc / calcout were missing —
315            // their put_field for those fields silently no-op'd
316            // because `self.common.analog_alarm` was None at the
317            // mutation site. Confirmed via
318            // calcRecord.dbd.pod:716-744 (HIHI..LLSV) and
319            // calcoutRecord.dbd.pod:1103+ (same).
320            "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout" => {
321                Some(AnalogAlarmConfig::default())
322            }
323            _ => None,
324        };
325        let mut common = CommonFields::default();
326        common.analog_alarm = analog_alarm;
327
328        Self {
329            name,
330            record,
331            common,
332            subscribers: HashMap::new(),
333            parsed_inp: ParsedLink::None,
334            parsed_out: ParsedLink::None,
335            parsed_flnk: ParsedLink::None,
336            parsed_sdis: ParsedLink::None,
337            parsed_tsel: ParsedLink::None,
338            device: None,
339            subroutine: None,
340            processing: AtomicBool::new(false),
341            notify: None,
342            last_posted: HashMap::new(),
343            array_hash_changed: false,
344            reprocess_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
345            info: HashMap::new(),
346            metadata_cache: StdMutex::new(None),
347        }
348    }
349
350    /// Set a single `info("key", "value")` tag on this record. Last
351    /// write wins. Used by the .db loader (`info(...)` directive) and
352    /// `dbpf`-style tools.
353    pub fn set_info(&mut self, key: impl Into<String>, value: impl Into<String>) {
354        self.info.insert(key.into(), value.into());
355    }
356
357    /// Look up a single info tag. Returns `None` when the record has
358    /// no tag with that key.
359    pub fn get_info(&self, key: &str) -> Option<&str> {
360        self.info.get(key).map(|s| s.as_str())
361    }
362
363    /// Invalidate the metadata cache. Called after writing any
364    /// metadata-class field (EGU, PREC, HOPR/LOPR, alarm limits,
365    /// DRVH/DRVL, enum strings). The next snapshot will rebuild the
366    /// cache from the new values.
367    pub fn invalidate_metadata_cache(&self) {
368        if let Ok(mut guard) = self.metadata_cache.lock() {
369            *guard = None;
370        }
371    }
372
373    /// Hook called by the database after a field is written. If the
374    /// field is in the metadata-class set, the cache is invalidated so
375    /// the next snapshot picks up the new value.
376    ///
377    /// Field name is automatically uppercased.
378    pub fn notify_field_written(&self, field: &str) {
379        let upper = field.to_ascii_uppercase();
380        if is_metadata_field(&upper) {
381            self.invalidate_metadata_cache();
382        }
383    }
384
385    /// Like [`notify_field_written`] but skips the invalidation when
386    /// the put did not actually change the field's value. Mirrors
387    /// epics-base `faac1df1` — `DBE_PROPERTY` events fire only on
388    /// real changes, not on idempotent writes (the C path compares
389    /// `paddr->pfield` against the converted payload before setting
390    /// the `propertyUpdate` flag).
391    ///
392    /// `prev` is the value captured BEFORE the put. Callers that
393    /// don't need the change-detection (e.g. internal writers that
394    /// know the field is non-metadata) can keep using
395    /// [`notify_field_written`].
396    // must post EventMask::PROPERTY to all field subscribers when metadata changes
397    pub fn notify_field_written_if_changed(&self, field: &str, prev: Option<&EpicsValue>) {
398        let upper = field.to_ascii_uppercase();
399        if !is_metadata_field(&upper) {
400            return;
401        }
402        let now = self.record.get_field(&upper);
403        if prev != now.as_ref() {
404            self.invalidate_metadata_cache();
405            // mirror C dbAccess.c:1396-1397 db_post_events(precord, NULL, DBE_PROPERTY).
406            // Collect keys first to avoid a re-entrant immutable borrow on subscribers.
407            let fields: Vec<String> = self.subscribers.keys().cloned().collect();
408            for f in fields {
409                self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
410            }
411        }
412    }
413
414    /// Returns the cached MetadataSnapshot, building and storing it on
415    /// the first call (or after invalidation). Used by both
416    /// `snapshot_for_field` and `make_monitor_snapshot` so the populate
417    /// cost is paid at most once per metadata-stable interval.
418    fn cached_metadata(&self) -> MetadataSnapshot {
419        // Fast path: cache hit
420        if let Ok(guard) = self.metadata_cache.lock()
421            && let Some(cached) = guard.as_ref()
422        {
423            return cached.clone();
424        }
425
426        // Cache miss: build a fresh metadata snapshot
427        let mut tmp = super::super::snapshot::Snapshot::new(
428            EpicsValue::Double(0.0),
429            0,
430            0,
431            std::time::SystemTime::UNIX_EPOCH,
432        );
433        self.populate_display_info(&mut tmp);
434        self.populate_control_info(&mut tmp);
435        self.populate_enum_info(&mut tmp);
436
437        let meta = MetadataSnapshot {
438            display: tmp.display,
439            control: tmp.control,
440            enums: tmp.enums,
441        };
442
443        // Store back; ignore poisoning (cache is best-effort).
444        if let Ok(mut guard) = self.metadata_cache.lock() {
445            *guard = Some(meta.clone());
446        }
447        meta
448    }
449
450    /// Check if the record is currently processing (PACT equivalent).
451    pub fn is_processing(&self) -> bool {
452        self.processing.load(std::sync::atomic::Ordering::Acquire)
453    }
454
455    /// Unified field resolution: record fields → common fields → virtual fields.
456    pub fn resolve_field(&self, name: &str) -> Option<EpicsValue> {
457        let name = name.to_ascii_uppercase();
458        self.record
459            .get_field(&name)
460            .or_else(|| self.get_common_field(&name))
461            .or_else(|| self.get_virtual_field(&name))
462    }
463
464    /// Resolve a field for EPICS `$` long-string (character-array) access.
465    ///
466    /// The `$` channel-name modifier (C `dbChannel.c:486-505`) re-views a
467    /// field as a `DBR_CHAR` array: a `DBF_STRING` field becomes a char
468    /// array of `field_size` elements, a link field a char array of
469    /// `PVLINK_STRINGSZ`, and every other field type is rejected with
470    /// `S_dbLib_fieldNotFound`. pvxs serves that char view as a
471    /// `form = "String"` long-string `NTScalar` — it reads the `DBR_CHAR`
472    /// bytes and NUL-terminates them back into a string
473    /// (`ioc/iocsource.cpp:133-136`, `ioc/channel.cpp:62-74`).
474    ///
475    /// Both `DBF_STRING` fields and link fields resolve to an
476    /// [`EpicsValue::String`] in this database (a link resolves to its
477    /// textual form, see [`Self::get_common_field`]), so a field is
478    /// `$`-eligible exactly when it resolves to a string value. Returns
479    /// that string value for an eligible field, or `None` for a field the
480    /// `$` modifier cannot view as a char array (the
481    /// `S_dbLib_fieldNotFound` case) — the single owner of the
482    /// dbChannel `$`-eligibility rule for the channel-resolution layer.
483    pub fn resolve_string_view_field(&self, name: &str) -> Option<EpicsValue> {
484        match self.resolve_field(name)? {
485            v @ EpicsValue::String(_) => Some(v),
486            _ => None,
487        }
488    }
489
490    /// Choice table for a field served as `DBR_ENUM` from a `DBF_MENU`:
491    /// the record's own record-specific menu
492    /// ([`Record::menu_field_choices`](super::record_trait::Record::menu_field_choices)),
493    /// else a shared menu keyed by field name
494    /// ([`shared_menu_choices`](super::menu_choices::shared_menu_choices)).
495    fn menu_choices_for(&self, field: &str) -> Option<&'static [&'static str]> {
496        self.record
497            .menu_field_choices(field)
498            .or_else(|| super::menu_choices::shared_menu_choices(field))
499    }
500
501    /// Promote a `DBF_MENU` field's value to its `DBR_ENUM` client form: a
502    /// menu index stored as a short becomes [`EpicsValue::Enum`], so the
503    /// wire type a client sees is `DBR_ENUM` (CA) / `NTEnum` (PVA),
504    /// matching C dbStaticLib serving `DBF_MENU` as `DBR_ENUM`. The
505    /// menu index is held internally as `DbFieldType::Short`, so only that
506    /// representation is promoted; a same-named field that is not a menu
507    /// index here (e.g. `scalcout.OSV`, a string) is returned unchanged.
508    /// Idempotent for a value already delivered as `Enum` (`.SCAN`/`SSCN`,
509    /// the record-specific `SELM`).
510    fn promote_menu_value(&self, field: &str, value: EpicsValue) -> EpicsValue {
511        if self.menu_choices_for(field).is_some() {
512            if let EpicsValue::Short(idx) = value {
513                return EpicsValue::Enum(idx as u16);
514            }
515        }
516        value
517    }
518
519    /// The client-facing value of `field`: the resolved value with a
520    /// `DBF_MENU` field promoted to its `DBR_ENUM` form (see
521    /// [`Self::promote_menu_value`]), so a wire type derived directly from
522    /// the value matches the GET/MONITOR data. Used by the CA create-
523    /// channel path, which reads the native type from the value rather
524    /// than from [`Self::snapshot_for_field`].
525    pub fn client_field_value(&self, field: &str) -> Option<EpicsValue> {
526        let value = self.resolve_field(field)?;
527        Some(self.promote_menu_value(field, value))
528    }
529
530    /// Attach the `DBF_MENU` → `DBR_ENUM` representation to a built
531    /// snapshot: promote the value to [`EpicsValue::Enum`] and attach the
532    /// menu's `menu()` choice labels so the CA/PVA enum encoders present
533    /// them. The single owner of "menu field -> (enum value, choice
534    /// table)" for both the GET ([`Self::snapshot_for_field`]) and MONITOR
535    /// ([`Self::make_monitor_snapshot`]) snapshot builders, so the wire
536    /// form is identical on every delivery path. A same-named non-menu
537    /// field (whose value is not a menu index) keeps its plain value and
538    /// gets no choice table.
539    fn attach_menu_enum(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
540        let Some(choices) = self.menu_choices_for(field) else {
541            return;
542        };
543        snap.value = self.promote_menu_value(field, snap.value.clone());
544        if matches!(snap.value, EpicsValue::Enum(_)) {
545            snap.enums = Some(super::super::snapshot::EnumInfo {
546                strings: choices.iter().map(|s| PvString::from(*s)).collect(),
547            });
548        }
549    }
550
551    /// Build a Snapshot with full metadata for the given field.
552    pub fn snapshot_for_field(&self, field: &str) -> Option<super::super::snapshot::Snapshot> {
553        let value = self.resolve_field(field)?;
554        let mut snap = super::super::snapshot::Snapshot::new(
555            value,
556            self.common.stat,
557            self.common.sevr as u16,
558            self.common.time,
559        );
560        // Default the served `timeStamp.userTag` to the record's `utag`,
561        // mirroring pvxs `iocsource.cpp:245` (`auto utag = meta.utag;`).
562        // The 64-bit `epicsUTag` narrows to the int32 NT wire field by
563        // truncating to the low 32 bits — pvxs assigns the same uint64
564        // straight into the `Int32` `timeStamp.userTag`. The `Q:time:tag`
565        // nsec-LSB split below overrides this when configured, matching
566        // pvxs `if(info.nsecMask) utag = meta.time.nsec & info.nsecMask;`
567        // (:247).
568        snap.user_tag = self.common.utag as i32;
569
570        // Pull display/control/enums from the metadata cache (build on
571        // first call, hit thereafter until invalidated by a metadata-class
572        // field write).
573        let meta = self.cached_metadata();
574        snap.display = meta.display;
575        snap.control = meta.control;
576        snap.enums = meta.enums;
577
578        // Per-field RSET metadata (C get_units/get_precision/
579        // get_graphic_double/get_control_double/get_alarm_double key on
580        // dbGetFieldIndex) patches the record-level cache for this field.
581        self.apply_field_metadata_override(field, &mut snap);
582
583        // Common-field enum mapping (e.g. .SCAN choices) is field-specific
584        // and not part of the per-record cache.
585        self.populate_common_enum_info(field, &mut snap);
586
587        // DBF_MENU field (a shared menu such as `OMSL`/`HHSV`/`SIMM`/... or
588        // a record-specific menu such as `sel.SELM`): carry the menu index
589        // as DBR_ENUM and attach its `menu()` choice labels. See
590        // `attach_menu_enum`. This overrides any record VAL enum table
591        // copied from the metadata cache above, because a menu field
592        // carries its own menu's choices, not the record's VAL state
593        // strings.
594        self.attach_menu_enum(field, &mut snap);
595
596        // apply `info(Q:time:tag, "nsec:lsb:N")` — pvxs
597        // typeutils.cpp:79 splits the low N bits of the timestamp's
598        // nanoseconds into `timeStamp.userTag` and clears those bits
599        // from `nanoseconds`. Standard pvxs convention is `nsec:lsb:N`
600        // with N in 1..=30; values outside that range are ignored to
601        // match pvxs's bounds clamp. The split is applied to both
602        // `snap.timestamp` and `snap.user_tag` so downstream encoders
603        // (NTScalar `timeStamp`, QSRV groups via `+nsecmask`) all see
604        // the same shape.
605        if let Some(n) = self.parse_qtime_tag_nsec_lsb() {
606            crate::server::snapshot::apply_nsec_lsb_split(&mut snap, n);
607        }
608
609        Some(snap)
610    }
611
612    /// Parse `info(Q:time:tag, "nsec:lsb:N")` and return `N`.
613    /// Returns `None` when the info tag is absent or malformed (pvxs
614    /// silently ignores bad values; we match that by returning None
615    /// so the timestamp is emitted unchanged).
616    fn parse_qtime_tag_nsec_lsb(&self) -> Option<u8> {
617        let raw = self.get_info("Q:time:tag")?;
618        // Accept `nsec:lsb:N` with arbitrary whitespace and case.
619        let mut parts = raw.split(':');
620        let key = parts.next()?.trim();
621        let suffix = parts.next()?.trim();
622        let n = parts.next()?.trim();
623        if !key.eq_ignore_ascii_case("nsec") || !suffix.eq_ignore_ascii_case("lsb") {
624            return None;
625        }
626        let n: u32 = n.parse().ok()?;
627        // pvxs clamps to [1, 30]; values outside leave the timestamp
628        // alone (the userTag is meaningful only with a non-trivial
629        // mask, and >30 would consume all nanoseconds).
630        if (1..=30).contains(&n) {
631            Some(n as u8)
632        } else {
633            None
634        }
635    }
636
637    /// Populate DisplayInfo from record fields if applicable.
638    fn populate_display_info(&self, snap: &mut super::super::snapshot::Snapshot) {
639        let rtype = self.record.record_type();
640        match rtype {
641            "ai" | "ao" | "calc" | "calcout" => {
642                let egu = self
643                    .record
644                    .get_field("EGU")
645                    .and_then(|v| {
646                        if let EpicsValue::String(s) = v {
647                            Some(s)
648                        } else {
649                            None
650                        }
651                    })
652                    .unwrap_or_default();
653                let prec = self
654                    .record
655                    .get_field("PREC")
656                    .and_then(|v| v.to_f64())
657                    .unwrap_or(0.0) as i16;
658                let hopr = self
659                    .record
660                    .get_field("HOPR")
661                    .and_then(|v| v.to_f64())
662                    .unwrap_or(0.0);
663                let lopr = self
664                    .record
665                    .get_field("LOPR")
666                    .and_then(|v| v.to_f64())
667                    .unwrap_or(0.0);
668                let (hihi, high, low, lolo) = self.alarm_limits();
669                snap.display = Some(super::super::snapshot::DisplayInfo {
670                    units: egu,
671                    precision: prec,
672                    upper_disp_limit: hopr,
673                    lower_disp_limit: lopr,
674                    upper_alarm_limit: hihi,
675                    upper_warning_limit: high,
676                    lower_warning_limit: low,
677                    lower_alarm_limit: lolo,
678                    ..Default::default()
679                });
680            }
681            "longin" | "longout" | "int64in" | "int64out" => {
682                let egu = self
683                    .record
684                    .get_field("EGU")
685                    .and_then(|v| {
686                        if let EpicsValue::String(s) = v {
687                            Some(s)
688                        } else {
689                            None
690                        }
691                    })
692                    .unwrap_or_default();
693                let hopr = self
694                    .record
695                    .get_field("HOPR")
696                    .and_then(|v| v.to_f64())
697                    .unwrap_or(0.0);
698                let lopr = self
699                    .record
700                    .get_field("LOPR")
701                    .and_then(|v| v.to_f64())
702                    .unwrap_or(0.0);
703                // longin/longout severity-gate (get_alarm_double);
704                // int64in/int64out send the limits verbatim (C is
705                // unconditional for those two record types only).
706                let (hihi, high, low, lolo) = match rtype {
707                    "int64in" | "int64out" => self.alarm_limits_unchecked(),
708                    _ => self.alarm_limits(),
709                };
710                snap.display = Some(super::super::snapshot::DisplayInfo {
711                    units: egu,
712                    precision: 0,
713                    upper_disp_limit: hopr,
714                    lower_disp_limit: lopr,
715                    upper_alarm_limit: hihi,
716                    upper_warning_limit: high,
717                    lower_warning_limit: low,
718                    lower_alarm_limit: lolo,
719                    ..Default::default()
720                });
721            }
722            // waveform/aai/aao — HOPR/LOPR/PREC/EGU for VAL display limits.
723            // (waveformRecord.c:251-252,239; aaiRecord.c:280-281,268; aaoRecord.c:283-284)
724            "waveform" | "aai" | "aao" => {
725                let egu = self
726                    .record
727                    .get_field("EGU")
728                    .and_then(|v| {
729                        if let EpicsValue::String(s) = v {
730                            Some(s)
731                        } else {
732                            None
733                        }
734                    })
735                    .unwrap_or_default();
736                let prec = self
737                    .record
738                    .get_field("PREC")
739                    .and_then(|v| v.to_f64())
740                    .unwrap_or(0.0) as i16;
741                let hopr = self
742                    .record
743                    .get_field("HOPR")
744                    .and_then(|v| v.to_f64())
745                    .unwrap_or(0.0);
746                let lopr = self
747                    .record
748                    .get_field("LOPR")
749                    .and_then(|v| v.to_f64())
750                    .unwrap_or(0.0);
751                snap.display = Some(super::super::snapshot::DisplayInfo {
752                    units: egu,
753                    precision: prec,
754                    upper_disp_limit: hopr,
755                    lower_disp_limit: lopr,
756                    upper_alarm_limit: 0.0,
757                    upper_warning_limit: 0.0,
758                    lower_warning_limit: 0.0,
759                    lower_alarm_limit: 0.0,
760                    ..Default::default()
761                });
762            }
763            // compress — HOPR/LOPR/PREC/EGU for VAL display limits.
764            // (compressRecord.c:478-479,464,455)
765            "compress" => {
766                let egu = self
767                    .record
768                    .get_field("EGU")
769                    .and_then(|v| {
770                        if let EpicsValue::String(s) = v {
771                            Some(s)
772                        } else {
773                            None
774                        }
775                    })
776                    .unwrap_or_default();
777                let prec = self
778                    .record
779                    .get_field("PREC")
780                    .and_then(|v| v.to_f64())
781                    .unwrap_or(0.0) as i16;
782                let hopr = self
783                    .record
784                    .get_field("HOPR")
785                    .and_then(|v| v.to_f64())
786                    .unwrap_or(0.0);
787                let lopr = self
788                    .record
789                    .get_field("LOPR")
790                    .and_then(|v| v.to_f64())
791                    .unwrap_or(0.0);
792                snap.display = Some(super::super::snapshot::DisplayInfo {
793                    units: egu,
794                    precision: prec,
795                    upper_disp_limit: hopr,
796                    lower_disp_limit: lopr,
797                    upper_alarm_limit: 0.0,
798                    upper_warning_limit: 0.0,
799                    lower_warning_limit: 0.0,
800                    lower_alarm_limit: 0.0,
801                    ..Default::default()
802                });
803            }
804            "motor" => {
805                let egu = self
806                    .record
807                    .get_field("EGU")
808                    .and_then(|v| {
809                        if let EpicsValue::String(s) = v {
810                            Some(s)
811                        } else {
812                            None
813                        }
814                    })
815                    .unwrap_or_default();
816                let prec = self
817                    .record
818                    .get_field("PREC")
819                    .and_then(|v| v.to_f64())
820                    .unwrap_or(0.0) as i16;
821                let hlm = self
822                    .record
823                    .get_field("HLM")
824                    .and_then(|v| v.to_f64())
825                    .unwrap_or(0.0);
826                let llm = self
827                    .record
828                    .get_field("LLM")
829                    .and_then(|v| v.to_f64())
830                    .unwrap_or(0.0);
831                snap.display = Some(super::super::snapshot::DisplayInfo {
832                    units: egu,
833                    precision: prec,
834                    upper_disp_limit: hlm,
835                    lower_disp_limit: llm,
836                    upper_alarm_limit: 0.0,
837                    upper_warning_limit: 0.0,
838                    lower_warning_limit: 0.0,
839                    lower_alarm_limit: 0.0,
840                    ..Default::default()
841                });
842            }
843            _ => {}
844        }
845    }
846
847    /// Populate ControlInfo from record fields if applicable.
848    fn populate_control_info(&self, snap: &mut super::super::snapshot::Snapshot) {
849        let rtype = self.record.record_type();
850        match rtype {
851            // ao unconditionally uses DRVH/DRVL (aoRecord.c:356-357).
852            "ao" => {
853                let upper = self
854                    .record
855                    .get_field("DRVH")
856                    .and_then(|v| v.to_f64())
857                    .unwrap_or(0.0);
858                let lower = self
859                    .record
860                    .get_field("DRVL")
861                    .and_then(|v| v.to_f64())
862                    .unwrap_or(0.0);
863                snap.control = Some(super::super::snapshot::ControlInfo {
864                    upper_ctrl_limit: upper,
865                    lower_ctrl_limit: lower,
866                });
867            }
868            // longout/int64out use DRVH/DRVL only when drvh > drvl, else HOPR/LOPR
869            // (longoutRecord.c:282-287, int64outRecord.c:265-270).
870            "longout" | "int64out" => {
871                let drvh = self
872                    .record
873                    .get_field("DRVH")
874                    .and_then(|v| v.to_f64())
875                    .unwrap_or(0.0);
876                let drvl = self
877                    .record
878                    .get_field("DRVL")
879                    .and_then(|v| v.to_f64())
880                    .unwrap_or(0.0);
881                let (upper, lower) = if drvh > drvl {
882                    (drvh, drvl)
883                } else {
884                    let hopr = self
885                        .record
886                        .get_field("HOPR")
887                        .and_then(|v| v.to_f64())
888                        .unwrap_or(0.0);
889                    let lopr = self
890                        .record
891                        .get_field("LOPR")
892                        .and_then(|v| v.to_f64())
893                        .unwrap_or(0.0);
894                    (hopr, lopr)
895                };
896                snap.control = Some(super::super::snapshot::ControlInfo {
897                    upper_ctrl_limit: upper,
898                    lower_ctrl_limit: lower,
899                });
900            }
901            "motor" => {
902                // Motor records use HLM/LLM as control limits
903                let hlm = self
904                    .record
905                    .get_field("HLM")
906                    .and_then(|v| v.to_f64())
907                    .unwrap_or(0.0);
908                let llm = self
909                    .record
910                    .get_field("LLM")
911                    .and_then(|v| v.to_f64())
912                    .unwrap_or(0.0);
913                snap.control = Some(super::super::snapshot::ControlInfo {
914                    upper_ctrl_limit: hlm,
915                    lower_ctrl_limit: llm,
916                });
917            }
918            // int64in uses HOPR/LOPR as control limits (int64inRecord.c:226-227)
919            "ai" | "int64in" | "longin" | "calc" | "calcout" => {
920                // Input records use HOPR/LOPR as control limits
921                let hopr = self
922                    .record
923                    .get_field("HOPR")
924                    .and_then(|v| v.to_f64())
925                    .unwrap_or(0.0);
926                let lopr = self
927                    .record
928                    .get_field("LOPR")
929                    .and_then(|v| v.to_f64())
930                    .unwrap_or(0.0);
931                snap.control = Some(super::super::snapshot::ControlInfo {
932                    upper_ctrl_limit: hopr,
933                    lower_ctrl_limit: lopr,
934                });
935            }
936            // Array records map their VAL control limits to HOPR/LOPR, exactly
937            // like the display limits above (waveformRecord.c get_control_double
938            // VAL case; aaiRecord.c:293-303; aaoRecord.c; compressRecord.c:487-501).
939            // Without this arm an array DBR_CTRL collapses the control range to
940            // 0/0 while the scalar records expose it.
941            "waveform" | "aai" | "aao" | "compress" => {
942                let hopr = self
943                    .record
944                    .get_field("HOPR")
945                    .and_then(|v| v.to_f64())
946                    .unwrap_or(0.0);
947                let lopr = self
948                    .record
949                    .get_field("LOPR")
950                    .and_then(|v| v.to_f64())
951                    .unwrap_or(0.0);
952                snap.control = Some(super::super::snapshot::ControlInfo {
953                    upper_ctrl_limit: hopr,
954                    lower_ctrl_limit: lopr,
955                });
956            }
957            _ => {}
958        }
959    }
960
961    /// Populate EnumInfo from record fields if applicable.
962    fn populate_enum_info(&self, snap: &mut super::super::snapshot::Snapshot) {
963        let rtype = self.record.record_type();
964        match rtype {
965            // bi/bo/busy — C trims no_str to 1 when ZNAM set and ONAM empty (boRecord.c:342-352).
966            "bi" | "bo" | "busy" => {
967                let znam = self
968                    .record
969                    .get_field("ZNAM")
970                    .and_then(|v| {
971                        if let EpicsValue::String(s) = v {
972                            Some(s)
973                        } else {
974                            None
975                        }
976                    })
977                    .unwrap_or_default();
978                let onam = self
979                    .record
980                    .get_field("ONAM")
981                    .and_then(|v| {
982                        if let EpicsValue::String(s) = v {
983                            Some(s)
984                        } else {
985                            None
986                        }
987                    })
988                    .unwrap_or_default();
989                let no_str_1 = !znam.is_empty() && onam.is_empty();
990                let mut strings = vec![znam, onam];
991                if no_str_1 {
992                    strings.truncate(1);
993                }
994                snap.enums = Some(super::super::snapshot::EnumInfo { strings });
995            }
996            // mbbi/mbbo — C uses highwater mark: last non-empty index + 1 (mbbiRecord.c:262-269).
997            "mbbi" | "mbbo" => {
998                let state_fields = [
999                    "ZRST", "ONST", "TWST", "THST", "FRST", "FVST", "SXST", "SVST", "EIST", "NIST",
1000                    "TEST", "ELST", "TVST", "TTST", "FTST", "FFST",
1001                ];
1002                let mut strings: Vec<PvString> = state_fields
1003                    .iter()
1004                    .map(|f| {
1005                        self.record
1006                            .get_field(f)
1007                            .and_then(|v| {
1008                                if let EpicsValue::String(s) = v {
1009                                    Some(s)
1010                                } else {
1011                                    None
1012                                }
1013                            })
1014                            .unwrap_or_default()
1015                    })
1016                    .collect();
1017                let no_str = strings
1018                    .iter()
1019                    .rposition(|s| !s.is_empty())
1020                    .map(|i| i + 1)
1021                    .unwrap_or(0);
1022                strings.truncate(no_str);
1023                snap.enums = Some(super::super::snapshot::EnumInfo { strings });
1024            }
1025            _ => {}
1026        }
1027    }
1028
1029    /// Populate enum strings for common fields accessed via CA (e.g. .SCAN).
1030    fn populate_common_enum_info(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
1031        match field {
1032            "SCAN" => {
1033                snap.enums = Some(super::super::snapshot::EnumInfo {
1034                    strings: vec![
1035                        "Passive".into(),
1036                        "Event".into(),
1037                        "I/O Intr".into(),
1038                        "10 second".into(),
1039                        "5 second".into(),
1040                        "2 second".into(),
1041                        "1 second".into(),
1042                        ".5 second".into(),
1043                        ".2 second".into(),
1044                        ".1 second".into(),
1045                    ],
1046                });
1047            }
1048            _ => {}
1049        }
1050    }
1051
1052    /// Extract analog alarm limits from CommonFields.
1053    // DBR_GR_*/DBR_CTRL_* alarm limits MUST be severity-gated — C
1054    // get_alarm_double returns `prec->hhsv ? prec->hihi : epicsNAN`
1055    // (aiRecord.c:295-298 and ao/longin/longout/calc/calcout). int64in/
1056    // int64out are the sole exception (unconditional, int64inRecord.c:239-243)
1057    // and use `alarm_limits_unchecked()`. NaN encodes byte-exact for every
1058    // DBR variant: f64/f32 keep NaN, integer casts make `NaN as iN == 0`,
1059    // matching dbAccess.c:300-326 (`finite(ald)?cast:0`).
1060    fn alarm_limits(&self) -> (f64, f64, f64, f64) {
1061        match self.common.analog_alarm {
1062            // Each limit is reported only when its severity is enabled,
1063            // exactly as C `get_alarm_double` (`x ? limit : epicsNAN`).
1064            Some(ref aa) => (
1065                gated(aa.hhsv, aa.hihi),
1066                gated(aa.hsv, aa.high),
1067                gated(aa.lsv, aa.low),
1068                gated(aa.llsv, aa.lolo),
1069            ),
1070            // No analog-alarm config ⇒ all severities are NO_ALARM in C,
1071            // so every limit is NaN (not 0).
1072            None => (f64::NAN, f64::NAN, f64::NAN, f64::NAN),
1073        }
1074    }
1075
1076    // int64in/int64out are the one analog family whose C `get_alarm_double`
1077    // is UNCONDITIONAL (int64inRecord.c:239-243, int64outRecord.c:283-287):
1078    // the limits are sent verbatim regardless of HHSV/HSV/LSV/LLSV. Keep a
1079    // separate accessor so the gated `alarm_limits()` cannot leak into this
1080    // path.
1081    fn alarm_limits_unchecked(&self) -> (f64, f64, f64, f64) {
1082        if let Some(ref aa) = self.common.analog_alarm {
1083            (aa.hihi, aa.high, aa.low, aa.lolo)
1084        } else {
1085            (0.0, 0.0, 0.0, 0.0)
1086        }
1087    }
1088
1089    /// Get a common field value.
1090    pub fn get_common_field(&self, name: &str) -> Option<EpicsValue> {
1091        match name {
1092            "SEVR" => Some(EpicsValue::Short(self.common.sevr as i16)),
1093            "STAT" => Some(EpicsValue::Short(self.common.stat as i16)),
1094            "NSEV" => Some(EpicsValue::Short(self.common.nsev as i16)),
1095            "NSTA" => Some(EpicsValue::Short(self.common.nsta as i16)),
1096            // epics-base PR #568 / #566 — alarm message string.
1097            "AMSG" => Some(EpicsValue::String(self.common.amsg.clone().into())),
1098            "NAMSG" => Some(EpicsValue::String(self.common.namsg.clone().into())),
1099            "ACKS" => Some(EpicsValue::Short(self.common.acks as i16)),
1100            "ACKT" => Some(EpicsValue::Char(if self.common.ackt { 1 } else { 0 })),
1101            "UDF" => Some(EpicsValue::Char(if self.common.udf { 1 } else { 0 })),
1102            "UDFS" => Some(EpicsValue::Short(self.common.udfs as i16)),
1103            "SCAN" => Some(EpicsValue::Enum(self.common.scan as u16)),
1104            "SSCN" => Some(EpicsValue::Enum(self.common.sscn as u16)),
1105            "PINI" => Some(EpicsValue::Char(if self.common.pini { 1 } else { 0 })),
1106            "TPRO" => Some(EpicsValue::Char(if self.common.tpro { 1 } else { 0 })),
1107            "BKPT" => Some(EpicsValue::Char(self.common.bkpt)),
1108            "FLNK" => Some(EpicsValue::String(self.common.flnk.clone().into())),
1109            "INP" => Some(EpicsValue::String(self.common.inp.clone().into())),
1110            "OUT" => Some(EpicsValue::String(self.common.out.clone().into())),
1111            "DTYP" => Some(EpicsValue::String(self.common.dtyp.clone().into())),
1112            "TSE" => Some(EpicsValue::Short(self.common.tse)),
1113            "TSEL" => Some(EpicsValue::String(self.common.tsel.clone().into())),
1114            // C `UTAG` is DBF_UINT64 — exposed natively as the unsigned
1115            // 64-bit value variant so values above i64::MAX round-trip.
1116            "UTAG" => Some(EpicsValue::UInt64(self.common.utag)),
1117            "ASG" => Some(EpicsValue::String(self.common.asg.clone().into())),
1118            "ASL" => Some(EpicsValue::Char(self.common.asl)),
1119            "DESC" => Some(EpicsValue::String(self.common.desc.clone())),
1120            "PHAS" => Some(EpicsValue::Short(self.common.phas)),
1121            "EVNT" => Some(EpicsValue::String(self.common.evnt.clone().into())),
1122            "PRIO" => Some(EpicsValue::Short(self.common.prio)),
1123            "DISV" => Some(EpicsValue::Short(self.common.disv)),
1124            "DISA" => Some(EpicsValue::Short(self.common.disa)),
1125            "SDIS" => Some(EpicsValue::String(self.common.sdis.clone().into())),
1126            "DISS" => Some(EpicsValue::Short(self.common.diss as i16)),
1127            "HYST" => Some(EpicsValue::Double(self.common.hyst)),
1128            "LCNT" => Some(EpicsValue::Short(self.common.lcnt)),
1129            "DISP" => Some(EpicsValue::Char(if self.common.disp { 1 } else { 0 })),
1130            "PUTF" => Some(EpicsValue::Char(if self.common.putf { 1 } else { 0 })),
1131            "RPRO" => Some(EpicsValue::Char(if self.common.rpro { 1 } else { 0 })),
1132            "PACT" => Some(EpicsValue::Char(
1133                if self.processing.load(std::sync::atomic::Ordering::Acquire) {
1134                    1
1135                } else {
1136                    0
1137                },
1138            )),
1139            "PROC" => Some(EpicsValue::Char(0)), // Always 0 (trigger-only)
1140            // Analog alarm fields
1141            "HIHI" => self
1142                .common
1143                .analog_alarm
1144                .as_ref()
1145                .map(|a| EpicsValue::Double(a.hihi)),
1146            "HIGH" => self
1147                .common
1148                .analog_alarm
1149                .as_ref()
1150                .map(|a| EpicsValue::Double(a.high)),
1151            "LOW" => self
1152                .common
1153                .analog_alarm
1154                .as_ref()
1155                .map(|a| EpicsValue::Double(a.low)),
1156            "LOLO" => self
1157                .common
1158                .analog_alarm
1159                .as_ref()
1160                .map(|a| EpicsValue::Double(a.lolo)),
1161            "HHSV" => self
1162                .common
1163                .analog_alarm
1164                .as_ref()
1165                .map(|a| EpicsValue::Short(a.hhsv as i16)),
1166            "HSV" => self
1167                .common
1168                .analog_alarm
1169                .as_ref()
1170                .map(|a| EpicsValue::Short(a.hsv as i16)),
1171            "LSV" => self
1172                .common
1173                .analog_alarm
1174                .as_ref()
1175                .map(|a| EpicsValue::Short(a.lsv as i16)),
1176            "LLSV" => self
1177                .common
1178                .analog_alarm
1179                .as_ref()
1180                .map(|a| EpicsValue::Short(a.llsv as i16)),
1181            // swait OUTN is aliased to common.out
1182            "OUTN" => {
1183                if self.record.record_type() == "swait" {
1184                    Some(EpicsValue::String(self.common.out.clone().into()))
1185                } else {
1186                    None
1187                }
1188            }
1189            _ => None,
1190        }
1191    }
1192
1193    /// Set a common field value. Returns what scan index changes are needed.
1194    pub fn put_common_field(
1195        &mut self,
1196        name: &str,
1197        value: EpicsValue,
1198    ) -> CaResult<CommonFieldPutResult> {
1199        let name = name.to_ascii_uppercase();
1200        self.record.validate_put(&name, &value)?;
1201        self.record.special(&name, false)?;
1202        // The db loader hands every common field to this path as a raw
1203        // `EpicsValue::String` (no per-field `FieldDesc` to parse against).
1204        // Coerce it to the field's canonical numeric/menu type up front so the
1205        // typed arms below apply a `field(PHAS, "1")` / `field(PRIO, "HIGH")`
1206        // directive instead of silently dropping it at IOC load. String-typed
1207        // and already-typed values pass through unchanged.
1208        let value = coerce_common_field_string(&name, value);
1209        match name.as_str() {
1210            "SEVR" => {
1211                if let EpicsValue::Short(v) = value {
1212                    self.common.sevr = AlarmSeverity::from_u16(v as u16);
1213                }
1214            }
1215            "STAT" => {
1216                if let EpicsValue::Short(v) = value {
1217                    self.common.stat = v as u16;
1218                }
1219            }
1220            "NSEV" => {
1221                if let EpicsValue::Short(v) = value {
1222                    self.common.nsev = AlarmSeverity::from_u16(v as u16);
1223                }
1224            }
1225            "NSTA" => {
1226                if let EpicsValue::Short(v) = value {
1227                    self.common.nsta = v as u16;
1228                }
1229            }
1230            "AMSG" => {
1231                if let EpicsValue::String(s) = value {
1232                    self.common.amsg = s.as_str_lossy().into_owned();
1233                }
1234            }
1235            "NAMSG" => {
1236                if let EpicsValue::String(s) = value {
1237                    self.common.namsg = s.as_str_lossy().into_owned();
1238                }
1239            }
1240            "ACKS" => {
1241                if let EpicsValue::Short(v) = value {
1242                    let sev = AlarmSeverity::from_u16(v as u16);
1243                    // C `dbAccess.c:1309` putAcks:
1244                    //   `if (*psev >= precord->acks) precord->acks = 0;`
1245                    // The written severity is compared against the
1246                    // STORED unacknowledged severity `acks` — NOT the
1247                    // current `sevr`. An operator acknowledging an
1248                    // alarm at the severity that was latched into ACKS
1249                    // must clear it even after `sevr` has since
1250                    // dropped; comparing against `sevr` instead would
1251                    // leave a stale unacknowledged alarm stuck.
1252                    if sev >= self.common.acks {
1253                        self.common.acks = AlarmSeverity::NoAlarm;
1254                    }
1255                }
1256            }
1257            "ACKT" => {
1258                let new_ackt = match value {
1259                    EpicsValue::Char(v) => v != 0,
1260                    EpicsValue::Short(v) => v != 0,
1261                    _ => return Ok(CommonFieldPutResult::NoChange),
1262                };
1263                self.common.ackt = new_ackt;
1264                // C `dbAccess.c:1294-1297` putAckt: when ACKT is set
1265                // false (transient acknowledgement disabled) and the
1266                // stored unacknowledged severity is higher than the
1267                // current `sevr`, lower `acks` down to `sevr` — a
1268                // transient alarm that has already cleared should not
1269                // keep a sticky higher-severity ACKS once transient
1270                // acknowledgement is turned off.
1271                if !new_ackt && self.common.acks > self.common.sevr {
1272                    self.common.acks = self.common.sevr;
1273                }
1274            }
1275            "UDF" => {
1276                if let EpicsValue::Char(v) = value {
1277                    self.common.udf = v != 0;
1278                }
1279            }
1280            "UDFS" => {
1281                if let EpicsValue::Short(v) = value {
1282                    self.common.udfs = AlarmSeverity::from_u16(v as u16);
1283                }
1284            }
1285            "SCAN" => {
1286                let old_scan = self.common.scan;
1287                let new_scan = match &value {
1288                    EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
1289                    EpicsValue::Enum(v) => ScanType::from_u16(*v),
1290                    EpicsValue::String(s) => ScanType::from_str(s.as_str_lossy().as_ref())?,
1291                    _ => return Ok(CommonFieldPutResult::NoChange),
1292                };
1293                self.common.scan = new_scan;
1294                if old_scan != new_scan {
1295                    let phas = self.common.phas;
1296                    self.record.on_put(&name);
1297                    let _ = self.record.special(&name, true);
1298                    return Ok(CommonFieldPutResult::ScanChanged {
1299                        old_scan,
1300                        new_scan,
1301                        phas,
1302                    });
1303                }
1304            }
1305            "SSCN" => {
1306                let new_sscn = match &value {
1307                    EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
1308                    EpicsValue::Enum(v) => ScanType::from_u16(*v),
1309                    EpicsValue::String(s) => ScanType::from_str(s.as_str_lossy().as_ref())?,
1310                    _ => return Ok(CommonFieldPutResult::NoChange),
1311                };
1312                self.common.sscn = new_sscn;
1313            }
1314            "PINI" => {
1315                if let EpicsValue::Char(v) = value {
1316                    self.common.pini = v != 0;
1317                } else if let EpicsValue::String(s) = &value {
1318                    self.common.pini = s == "YES" || s == "1" || s == "true";
1319                }
1320            }
1321            "TPRO" => {
1322                if let EpicsValue::Char(v) = value {
1323                    self.common.tpro = v != 0;
1324                }
1325            }
1326            "BKPT" => {
1327                if let EpicsValue::Char(v) = value {
1328                    self.common.bkpt = v;
1329                }
1330            }
1331            "FLNK" => {
1332                if let EpicsValue::String(s) = value {
1333                    self.common.flnk = s.as_str_lossy().into_owned();
1334                    self.parsed_flnk = parse_link_v2(&self.common.flnk);
1335                }
1336            }
1337            "INP" => {
1338                if let EpicsValue::String(s) = value {
1339                    self.common.inp = s.as_str_lossy().into_owned();
1340                    self.parsed_inp = parse_link_v2(&self.common.inp);
1341                }
1342            }
1343            "OUT" => {
1344                if let EpicsValue::String(s) = value {
1345                    let s = s.as_str_lossy();
1346                    // C parity (acd1aef): CP/CPP modifiers on output links are
1347                    // meaningless (they request "process on change" semantics
1348                    // that only apply to input links). dbParseLink in C strips
1349                    // them and emits an errlogPrintf warning naming the source
1350                    // record and field. Mirror the diagnostic here.
1351                    let trimmed = s.trim_end();
1352                    if trimmed.ends_with(" CP") || trimmed.ends_with(" CPP") {
1353                        tracing::warn!(
1354                            target: "epics_base_rs::record",
1355                            record = %name,
1356                            field = "OUT",
1357                            "CP/CPP modifier ignored on output link"
1358                        );
1359                    }
1360                    self.common.out = s.into_owned();
1361                    // C `dbDbPutValue` (dbDbLink.c:386-389): an OUT
1362                    // link processes its target only on an explicit
1363                    // ` PP` token (or a `.PROC` destination). A bare
1364                    // OUT link is NPP — `parse_output_link_v2`
1365                    // downgrades the modifier-less `ProcessPassive`
1366                    // default that `parse_link_v2` would otherwise
1367                    // apply.
1368                    self.parsed_out = parse_output_link_v2(&self.common.out);
1369                    // C `longoutRecord.c::special` (PR #6c573b4 part 2)
1370                    // and similar OOCH-style hooks need `after=true`
1371                    // to fire after the link has actually moved. The
1372                    // earlier `validate_put` + `special(name, false)`
1373                    // pair only covered the before-side.
1374                    let _ = self.record.special(&name, true);
1375                }
1376            }
1377            "DTYP" => {
1378                if let EpicsValue::String(s) = value {
1379                    self.common.dtyp = s.as_str_lossy().into_owned();
1380                }
1381            }
1382            "TSE" => {
1383                if let EpicsValue::Short(v) = value {
1384                    self.common.tse = v;
1385                }
1386            }
1387            "TSEL" => {
1388                if let EpicsValue::String(s) = value {
1389                    self.common.tsel = s.as_str_lossy().into_owned();
1390                    self.parsed_tsel = parse_link_v2(&self.common.tsel);
1391                }
1392            }
1393            "UTAG" => {
1394                // C UTAG is DBF_UINT64 — accept any integer-shaped value and
1395                // store the unsigned 64-bit tag. The db loader feeds every
1396                // common field as EpicsValue::String, so parse field(UTAG, "N")
1397                // rather than dropping it silently at IOC load; a CA write to
1398                // this u64 field crosses as DBR_DOUBLE (CA has no uint64 wire
1399                // type), so accept Double too.
1400                match value {
1401                    EpicsValue::UInt64(v) => self.common.utag = v,
1402                    EpicsValue::Int64(v) => self.common.utag = v as u64,
1403                    EpicsValue::Long(v) => self.common.utag = v as u64,
1404                    EpicsValue::Short(v) => self.common.utag = v as u64,
1405                    EpicsValue::Enum(v) => self.common.utag = v as u64,
1406                    EpicsValue::Char(v) => self.common.utag = v as u64,
1407                    EpicsValue::Double(v) => self.common.utag = v as u64,
1408                    EpicsValue::String(s) => {
1409                        if let Ok(EpicsValue::UInt64(v)) =
1410                            EpicsValue::parse(DbFieldType::UInt64, s.as_str_lossy().trim())
1411                        {
1412                            self.common.utag = v;
1413                        }
1414                    }
1415                    _ => {}
1416                }
1417            }
1418            "ASG" => {
1419                if let EpicsValue::String(s) = value {
1420                    self.common.asg = s.as_str_lossy().into_owned();
1421                }
1422            }
1423            "ASL" => {
1424                // C dbCommon.ASL is `epicsUInt32` in the .dbd but
1425                // only ever 0 or 1; accept Char / Short / Long for
1426                // the common put paths and clamp to {0, 1}.
1427                // db_loader feeds every common field as
1428                // `EpicsValue::String`; also accept that so a
1429                // `.db` `field(ASL, "1")` directive isn't silently
1430                // ignored at IOC load.
1431                let n: i64 = match value {
1432                    EpicsValue::Char(v) => v as i64,
1433                    EpicsValue::Short(v) => v as i64,
1434                    EpicsValue::Long(v) => v as i64,
1435                    EpicsValue::Int64(v) => v,
1436                    EpicsValue::String(s) => s.as_str_lossy().trim().parse().unwrap_or(0),
1437                    _ => return Ok(CommonFieldPutResult::NoChange),
1438                };
1439                self.common.asl = if n != 0 { 1 } else { 0 };
1440            }
1441            "DESC" => {
1442                if let EpicsValue::String(s) = value {
1443                    // DBF_STRING data field — store the bytes verbatim so a
1444                    // non-UTF-8 DESC round-trips unchanged.
1445                    self.common.desc = s;
1446                }
1447            }
1448            "PHAS" => {
1449                if let EpicsValue::Short(v) = value {
1450                    let old_phas = self.common.phas;
1451                    self.common.phas = v;
1452                    if old_phas != v && self.common.scan != ScanType::Passive {
1453                        let scan = self.common.scan;
1454                        self.record.on_put(&name);
1455                        let _ = self.record.special(&name, true);
1456                        return Ok(CommonFieldPutResult::PhasChanged {
1457                            scan,
1458                            old_phas,
1459                            new_phas: v,
1460                        });
1461                    }
1462                }
1463            }
1464            "EVNT" => {
1465                // C `EVNT` is DBF_STRING (event name). Accept a
1466                // string directly; accept a numeric value too for
1467                // backward compatibility (numeric events / a calc
1468                // record driving EVNT) by formatting it as a string.
1469                match value {
1470                    EpicsValue::String(s) => self.common.evnt = s.as_str_lossy().into_owned(),
1471                    EpicsValue::Short(v) => self.common.evnt = v.to_string(),
1472                    EpicsValue::Long(v) => self.common.evnt = v.to_string(),
1473                    EpicsValue::Enum(v) => self.common.evnt = v.to_string(),
1474                    EpicsValue::Double(v) => {
1475                        // Match C `eventNameToHandle`: a double with
1476                        // an integer part is treated as that integer.
1477                        self.common.evnt = (v as i64).to_string();
1478                    }
1479                    _ => {}
1480                }
1481            }
1482            "PRIO" => {
1483                if let EpicsValue::Short(v) = value {
1484                    self.common.prio = v;
1485                }
1486            }
1487            "DISV" => {
1488                if let EpicsValue::Short(v) = value {
1489                    self.common.disv = v;
1490                }
1491            }
1492            "DISA" => {
1493                if let EpicsValue::Short(v) = value {
1494                    self.common.disa = v;
1495                }
1496            }
1497            "SDIS" => {
1498                if let EpicsValue::String(s) = value {
1499                    self.common.sdis = s.as_str_lossy().into_owned();
1500                    self.parsed_sdis = parse_link_v2(&self.common.sdis);
1501                }
1502            }
1503            "DISS" => {
1504                if let EpicsValue::Short(v) = value {
1505                    self.common.diss = AlarmSeverity::from_u16(v as u16);
1506                }
1507            }
1508            "HYST" => {
1509                if let EpicsValue::Double(v) = value {
1510                    self.common.hyst = v;
1511                }
1512            }
1513            "LCNT" => {
1514                if let EpicsValue::Short(v) = value {
1515                    self.common.lcnt = v;
1516                }
1517            }
1518            "DISP" => match value {
1519                EpicsValue::Char(v) => self.common.disp = v != 0,
1520                EpicsValue::Short(v) => self.common.disp = v != 0,
1521                _ => {}
1522            },
1523            "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
1524            "RPRO" => {
1525                if let EpicsValue::Char(v) = value {
1526                    self.common.rpro = v != 0;
1527                }
1528            }
1529            "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
1530            "PROC" => { /* Trigger handled by put_record_field_from_ca; no-op here */ }
1531            // Analog alarm fields — accept Double, Long, or String (DB-load path sends String)
1532            "HIHI" => {
1533                if let Some(a) = &mut self.common.analog_alarm {
1534                    if let Some(v) = value.to_f64().or_else(|| {
1535                        if let EpicsValue::String(s) = &value {
1536                            s.as_str_lossy().parse::<f64>().ok()
1537                        } else {
1538                            None
1539                        }
1540                    }) {
1541                        a.hihi = v;
1542                    }
1543                }
1544            }
1545            "HIGH" => {
1546                if let Some(a) = &mut self.common.analog_alarm {
1547                    if let Some(v) = value.to_f64().or_else(|| {
1548                        if let EpicsValue::String(s) = &value {
1549                            s.as_str_lossy().parse::<f64>().ok()
1550                        } else {
1551                            None
1552                        }
1553                    }) {
1554                        a.high = v;
1555                    }
1556                }
1557            }
1558            "LOW" => {
1559                if let Some(a) = &mut self.common.analog_alarm {
1560                    if let Some(v) = value.to_f64().or_else(|| {
1561                        if let EpicsValue::String(s) = &value {
1562                            s.as_str_lossy().parse::<f64>().ok()
1563                        } else {
1564                            None
1565                        }
1566                    }) {
1567                        a.low = v;
1568                    }
1569                }
1570            }
1571            "LOLO" => {
1572                if let Some(a) = &mut self.common.analog_alarm {
1573                    if let Some(v) = value.to_f64().or_else(|| {
1574                        if let EpicsValue::String(s) = &value {
1575                            s.as_str_lossy().parse::<f64>().ok()
1576                        } else {
1577                            None
1578                        }
1579                    }) {
1580                        a.lolo = v;
1581                    }
1582                }
1583            }
1584            "HHSV" => {
1585                if let Some(a) = &mut self.common.analog_alarm {
1586                    a.hhsv = parse_alarm_severity(&value);
1587                }
1588            }
1589            "HSV" => {
1590                if let Some(a) = &mut self.common.analog_alarm {
1591                    a.hsv = parse_alarm_severity(&value);
1592                }
1593            }
1594            "LSV" => {
1595                if let Some(a) = &mut self.common.analog_alarm {
1596                    a.lsv = parse_alarm_severity(&value);
1597                }
1598            }
1599            "LLSV" => {
1600                if let Some(a) = &mut self.common.analog_alarm {
1601                    a.llsv = parse_alarm_severity(&value);
1602                }
1603            }
1604            // swait-specific: OUTN is the output link name for swait records.
1605            // Mirrors to common.out so the processing framework dispatches it.
1606            "OUTN" => {
1607                if self.record.record_type() == "swait" {
1608                    if let EpicsValue::String(s) = value {
1609                        self.common.out = s.as_str_lossy().into_owned();
1610                        // Bare OUT link is NPP — see the "OUT" arm.
1611                        self.parsed_out = parse_output_link_v2(&self.common.out);
1612                    }
1613                }
1614            }
1615            _ => {}
1616        }
1617        self.record.on_put(&name);
1618        let _ = self.record.special(&name, true);
1619        Ok(CommonFieldPutResult::NoChange)
1620    }
1621
1622    /// Get virtual fields (NAME, RTYP).
1623    pub fn get_virtual_field(&self, name: &str) -> Option<EpicsValue> {
1624        match name {
1625            "NAME" => Some(EpicsValue::String(self.name.clone().into())),
1626            "RTYP" => Some(EpicsValue::String(
1627                self.record.record_type().to_string().into(),
1628            )),
1629            _ => None,
1630        }
1631    }
1632
1633    /// Evaluate alarms based on record type and current value.
1634    /// Uses rec_gbl_set_sevr to accumulate into nsta/nsev.
1635    pub fn evaluate_alarms(&mut self) {
1636        use crate::server::recgbl::{self, alarm_status};
1637
1638        // Check UDF first
1639        recgbl::rec_gbl_check_udf(&mut self.common);
1640
1641        // Check CALC_ALARM for calc/calcout records
1642        let rtype = self.record.record_type();
1643        if rtype == "calc" || rtype == "calcout" || rtype == "scalcout" {
1644            // calc_alarm is exposed as a boolean field - check it
1645            if let Some(EpicsValue::Char(1)) = self.record.get_field("CALC_ALARM") {
1646                recgbl::rec_gbl_set_sevr_msg(
1647                    &mut self.common,
1648                    alarm_status::CALC_ALARM,
1649                    crate::server::record::AlarmSeverity::Invalid,
1650                    "CALC expression evaluation failed",
1651                );
1652            }
1653        }
1654
1655        match rtype {
1656            "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout" => {
1657                if let Some(ref alarm_cfg) = self.common.analog_alarm.clone() {
1658                    let val = match self.record.val() {
1659                        Some(EpicsValue::Double(v)) => v,
1660                        Some(EpicsValue::Long(v)) => v as f64,
1661                        Some(EpicsValue::Int64(v)) => v as f64,
1662                        _ => return,
1663                    };
1664                    self.evaluate_analog_alarm(val, alarm_cfg);
1665                }
1666            }
1667            // bi / bo / busy / mbbi / mbbo STATE+COS (and mbbo SOFT)
1668            // alarm evaluation now lives in each record's
1669            // `Record::check_alarms` hook (C `checkAlarms`). Keeping an
1670            // arm here would double-raise.
1671            _ => {} // no-op for other types
1672        }
1673    }
1674
1675    fn evaluate_analog_alarm(&mut self, val: f64, cfg: &AnalogAlarmConfig) {
1676        use crate::server::recgbl::{self, alarm_status};
1677
1678        // C `checkAlarms` returns immediately on a UDF cycle: it raises
1679        // `UDF_ALARM`/`UDFS` (already done by `rec_gbl_check_udf` in
1680        // `evaluate_alarms`), zeroes `AFVL` on the AFTC-capable records, and
1681        // returns BEFORE the range check — so `LALM` is left untouched and
1682        // `AFVL` is not filtered this cycle. The identical guard appears in
1683        // every record that shares this arm (`aiRecord.c:319-323`,
1684        // `aoRecord.c:383-386`, `longinRecord.c:274-278`,
1685        // `longoutRecord.c:317-320`, `int64inRecord.c:267-271`,
1686        // `int64outRecord.c:298-301`, `calcRecord.c:300-304`,
1687        // `calcoutRecord.c:563-566`). AFTC-capable records (ai/longin/
1688        // int64in/calc) carry `AFVL` and zero it (`prec->afvl = 0`); the
1689        // out records (ao/longout/int64out/calcout) have no `AFVL` and just
1690        // return. Running the range check here would drift `LALM` to `val`
1691        // (NaN on an undefined cycle) and filter `AFVL` — both observable.
1692        if self.common.udf {
1693            if matches!(
1694                self.record.record_type(),
1695                "calc" | "ai" | "longin" | "int64in"
1696            ) && self.record.get_field("AFVL").and_then(|v| v.to_f64()) != Some(0.0)
1697            {
1698                let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
1699            }
1700            return;
1701        }
1702
1703        let hyst = self.common.hyst;
1704        let lalm = self
1705            .record
1706            .get_field("LALM")
1707            .and_then(|v| v.to_f64())
1708            .unwrap_or(val);
1709
1710        // C-style per-level hysteresis: alarm fires if val passes the level,
1711        // OR if we were already at that alarm level (lalm == alev) and val
1712        // hasn't retreated past the hysteresis margin.
1713        //
1714        // `alarm_range` is the C-style integer level: 1=Lolo, 2=Low,
1715        // 3=Normal, 4=High, 5=Hihi. Required for the calc-record AFTC
1716        // filter (`calcRecord.c::checkAlarms:339-381`) which filters
1717        // on the range level (not on severity) and re-maps back.
1718        let (mut new_sevr, mut new_stat, mut alev, mut alarm_range) = if cfg.hhsv
1719            != AlarmSeverity::NoAlarm
1720            && (val >= cfg.hihi || (lalm == cfg.hihi && val >= cfg.hihi - hyst))
1721        {
1722            (cfg.hhsv, alarm_status::HIHI_ALARM, cfg.hihi, 5u16)
1723        } else if cfg.llsv != AlarmSeverity::NoAlarm
1724            && (val <= cfg.lolo || (lalm == cfg.lolo && val <= cfg.lolo + hyst))
1725        {
1726            (cfg.llsv, alarm_status::LOLO_ALARM, cfg.lolo, 1u16)
1727        } else if cfg.hsv != AlarmSeverity::NoAlarm
1728            && (val >= cfg.high || (lalm == cfg.high && val >= cfg.high - hyst))
1729        {
1730            (cfg.hsv, alarm_status::HIGH_ALARM, cfg.high, 4u16)
1731        } else if cfg.lsv != AlarmSeverity::NoAlarm
1732            && (val <= cfg.low || (lalm == cfg.low && val <= cfg.low + hyst))
1733        {
1734            (cfg.lsv, alarm_status::LOW_ALARM, cfg.low, 2u16)
1735        } else {
1736            (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val, 3u16)
1737        };
1738
1739        // C parity: the alarm-range AFTC low-pass filter
1740        // (`{ai,longin,int64in,calc}Record.c::checkAlarms`) smooths the
1741        // integer `alarmRange` and re-maps. Only records that carry the
1742        // AFTC/AFVL fields run it — `ao`/`longout`/`int64out`/`calcout`
1743        // have no AFTC field (confirmed via the respective `.dbd.pod`),
1744        // so they are excluded.
1745        let aftc_capable = matches!(
1746            self.record.record_type(),
1747            "calc" | "ai" | "longin" | "int64in"
1748        );
1749        if aftc_capable {
1750            let aftc = self
1751                .record
1752                .get_field("AFTC")
1753                .and_then(|v| v.to_f64())
1754                .unwrap_or(0.0);
1755            let afvl = self
1756                .record
1757                .get_field("AFVL")
1758                .and_then(|v| v.to_f64())
1759                .unwrap_or(0.0);
1760            if aftc > 0.0 {
1761                let now = crate::runtime::general_time::get_current();
1762                let (filtered_range, new_afvl) = crate::server::records::alarm_filter::aftc_filter(
1763                    alarm_range,
1764                    aftc,
1765                    afvl,
1766                    self.common.time,
1767                    now,
1768                );
1769                let _ = self.record.put_field("AFVL", EpicsValue::Double(new_afvl));
1770                if filtered_range != alarm_range {
1771                    // Re-map filtered range back to (sevr, stat, alev).
1772                    let (mapped_sevr, mapped_stat, mapped_alev) = match filtered_range {
1773                        5 => (cfg.hhsv, alarm_status::HIHI_ALARM, cfg.hihi),
1774                        4 => (cfg.hsv, alarm_status::HIGH_ALARM, cfg.high),
1775                        2 => (cfg.lsv, alarm_status::LOW_ALARM, cfg.low),
1776                        1 => (cfg.llsv, alarm_status::LOLO_ALARM, cfg.lolo),
1777                        _ => (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, val),
1778                    };
1779                    new_sevr = mapped_sevr;
1780                    new_stat = mapped_stat;
1781                    alev = mapped_alev;
1782                    alarm_range = filtered_range;
1783                }
1784            } else {
1785                // aftc <= 0 disables the filter. C `checkAlarms`
1786                // (e.g. aiRecord.c:356,402) initialises the local
1787                // `afvl = 0` and unconditionally stores `prec->afvl =
1788                // afvl` at the end, so a disabled filter drives AFVL to
1789                // 0. Mirror that here so a stale accumulator from a prior
1790                // `aftc > 0` run cannot mis-seed the filter if AFTC is
1791                // re-enabled later.
1792                if afvl != 0.0 {
1793                    let _ = self.record.put_field("AFVL", EpicsValue::Double(0.0));
1794                }
1795            }
1796        }
1797        let _ = alarm_range; // suppress unused-var on non-calc paths
1798
1799        if new_sevr != AlarmSeverity::NoAlarm {
1800            recgbl::rec_gbl_set_sevr(&mut self.common, new_stat, new_sevr);
1801            // C sets LALM to the alarm threshold level, not the current value
1802            let _ = self.record.put_field("LALM", EpicsValue::Double(alev));
1803        } else {
1804            // No alarm condition: reset LALM to current value (like C)
1805            let _ = self.record.put_field("LALM", EpicsValue::Double(val));
1806        }
1807    }
1808
1809    /// Invoke the registered subroutine (`sub`/`aSub` `SNAM`) if one is
1810    /// bound, before the record's `process()` body runs.
1811    ///
1812    /// C `subRecord.c::do_sub` / `aSubRecord.c::do_sub` call the named
1813    /// subroutine on EVERY `process()`. The function registry lives on the
1814    /// framework (`RecordInstance::subroutine`), not on the record, so the
1815    /// record's own `process()` is a no-op for these two types and the
1816    /// framework must drive the call. This is the SINGLE owner of that call
1817    /// for every dispatch path: the main engine
1818    /// (`process_record_with_links_inner`, the SCAN / event / CA-put-to-PP /
1819    /// FLNK path) and the by-name `process_local` (`db.process_record`,
1820    /// QSRV group / foreign-call path) both route through here, so a
1821    /// `sub`/`aSub` runs identically regardless of how it is processed.
1822    /// Previously only `process_local` invoked the subroutine, so on the
1823    /// main engine path `VAL`/`VALA..VALU`/`OUTA..OUTU` never updated.
1824    pub(crate) fn run_registered_subroutine(&mut self) -> CaResult<()> {
1825        if let Some(ref sub_fn) = self.subroutine {
1826            sub_fn(&mut *self.record)?;
1827        }
1828        Ok(())
1829    }
1830
1831    /// Basic process: process record, evaluate alarms, timestamp, build snapshot.
1832    /// This does NOT handle links — see process_with_context in database.rs.
1833    ///
1834    /// Returns the value/log snapshot plus a list of alarm-field posts
1835    /// (`SEVR`/`STAT`/`AMSG`/`ACKS`) with their individual C event masks.
1836    /// `SEVR` is posted `DBE_VALUE` only; `STAT`/`AMSG` carry `DBE_ALARM`
1837    /// (sevr/amsg change) and/or `DBE_VALUE` (stat change). The caller
1838    /// must fire these via `notify_field` so a `DBE_VALUE`-only `.SEVR`
1839    /// subscriber is not missed on an alarm-only change and a
1840    /// `DBE_ALARM`-only subscriber is not wrongly notified — C parity
1841    /// with `recGblResetAlarms` (recGbl.c:201-220), matching the
1842    /// `processing.rs` link path.
1843    pub fn process_local(
1844        &mut self,
1845    ) -> CaResult<(
1846        ProcessSnapshot,
1847        Vec<(&'static str, crate::server::recgbl::EventMask)>,
1848    )> {
1849        use crate::server::recgbl::{self, EventMask};
1850        const LCNT_ALARM_THRESHOLD: i16 = 10;
1851
1852        if self
1853            .processing
1854            .swap(true, std::sync::atomic::Ordering::AcqRel)
1855        {
1856            // C `dbProcess` PACT-active guard (dbAccess.c:544-557):
1857            //
1858            //   if ((precord->stat == SCAN_ALARM) ||
1859            //       (precord->lcnt++ < MAX_LOCK) ||
1860            //       (precord->sevr >= INVALID_ALARM)) goto all_done;
1861            //   recGblSetSevrMsg(precord, SCAN_ALARM, INVALID_ALARM,
1862            //                    "Async in progress");
1863            //
1864            // The alarm fires EXACTLY ONCE — on the attempt whose
1865            // pre-increment lcnt equals MAX_LOCK — and is then blocked
1866            // by the stat == SCAN_ALARM / sevr >= INVALID bails, the
1867            // same shape as the link path
1868            // (`process_record_with_links_inner`). The pre-fix guard
1869            // here used post-increment `lcnt >= threshold` with no
1870            // already-raised bail, so every reentrant attempt past the
1871            // threshold re-posted the unchanged SEVR/STAT/VAL (and the
1872            // first fire came one attempt early); it also wrote
1873            // sevr/stat directly, skipping `recGblSetSevrMsg` +
1874            // `recGblResetAlarms` — losing the "Async in progress"
1875            // AMSG and the acks bookkeeping the reset performs.
1876            let already_scan_alarm = self.common.stat == recgbl::alarm_status::SCAN_ALARM;
1877            let already_invalid = self.common.sevr >= AlarmSeverity::Invalid;
1878            let lcnt_before = self.common.lcnt;
1879            self.common.lcnt = lcnt_before.saturating_add(1);
1880            if already_scan_alarm || lcnt_before < LCNT_ALARM_THRESHOLD || already_invalid {
1881                return Ok((
1882                    ProcessSnapshot {
1883                        changed_fields: Vec::new(),
1884                    },
1885                    Vec::new(),
1886                ));
1887            }
1888            recgbl::rec_gbl_set_sevr_msg(
1889                &mut self.common,
1890                recgbl::alarm_status::SCAN_ALARM,
1891                AlarmSeverity::Invalid,
1892                "Async in progress",
1893            );
1894            let _ = recgbl::rec_gbl_reset_alarms(&mut self.common);
1895            // Per-field C masks (recGbl.c:201-220): this guard only
1896            // runs on a fresh SCAN_ALARM/INVALID raise, so sevr AND
1897            // stat both moved — SEVR posts DBE_VALUE, STAT/AMSG post
1898            // the shared `stat_mask` = DBE_ALARM|DBE_VALUE, VAL posts
1899            // DBE_VALUE|DBE_LOG plus `val_mask` = DBE_ALARM.
1900            let stat_mask = EventMask::ALARM | EventMask::VALUE;
1901            let mut changed_fields = Vec::new();
1902            if let Some(val) = self.record.val() {
1903                changed_fields.push((
1904                    "VAL".to_string(),
1905                    val,
1906                    EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
1907                ));
1908            }
1909            changed_fields.push((
1910                "SEVR".to_string(),
1911                EpicsValue::Short(self.common.sevr as i16),
1912                EventMask::VALUE,
1913            ));
1914            changed_fields.push((
1915                "STAT".to_string(),
1916                EpicsValue::Short(self.common.stat as i16),
1917                stat_mask,
1918            ));
1919            // AMSG carries "Async in progress" alongside the STAT
1920            // transition (C recGbl.c posts STAT and AMSG together
1921            // when any alarm field moved).
1922            changed_fields.push((
1923                "AMSG".to_string(),
1924                EpicsValue::String(self.common.amsg.clone().into()),
1925                stat_mask,
1926            ));
1927            return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
1928        }
1929        self.common.lcnt = 0;
1930        // RAII guard that resets `self.processing` to false on drop —
1931        // both for the normal exit path and for any `?` early return.
1932        // The guard holds a raw pointer rather than a reference because
1933        // we still need `self` mutably while the guard is alive (the
1934        // record body below mutates other `self` fields).
1935        struct ProcessGuard(*const AtomicBool);
1936        // SAFETY: AtomicBool is Sync; raw pointers don't auto-derive
1937        // Send. We hand-roll Send because the ptr targets a field of
1938        // `self`, which the caller already proves can be borrowed
1939        // through this code path. The pointer is only ever read for an
1940        // atomic store, never written, dereferenced for raw access, or
1941        // escaped from this scope.
1942        unsafe impl Send for ProcessGuard {}
1943        impl Drop for ProcessGuard {
1944            fn drop(&mut self) {
1945                // SAFETY: `self.0` was constructed from
1946                // `&self.processing as *const AtomicBool` below, where
1947                // `self` is the live RecordInstance whose lifetime
1948                // strictly outlives `_guard`. RecordInstance is
1949                // !Unpin-equivalent in practice (we never move it
1950                // while held in the database's `Arc<RwLock<_>>`), so
1951                // the pointer remains valid until Drop runs.
1952                unsafe { &*self.0 }.store(false, std::sync::atomic::Ordering::Release);
1953            }
1954        }
1955        let _guard = ProcessGuard(&self.processing as *const AtomicBool);
1956
1957        // Call subroutine if registered (for sub/aSub records). Single owner
1958        // shared with the main engine path — see `run_registered_subroutine`.
1959        self.run_registered_subroutine()?;
1960        // Soft-Channel input records must skip the RVAL->VAL convert
1961        // (C `devAiSoft.c` `read_ai` returns 2 = "don't convert" for
1962        // every Soft-Channel input record, incl. one with a constant /
1963        // unset INP). Without this, `process_local` on a soft input
1964        // with a preset VAL — e.g. NaN — would run `convert()` and
1965        // clobber it, after which the UDF check below would see a
1966        // defined value and wrongly clear UDF. The
1967        // `processing.rs` link path already does this; `process_local`
1968        // is the separate foreign-call path (`db.process_record`) and
1969        // needs the same skip. "Raw Soft Channel" has a distinct DTYP
1970        // so it is excluded by `is_soft` and still runs convert.
1971        //
1972        // Gated on `soft_channel_skips_convert()` — identical to the
1973        // `processing.rs` link path — so this only suppresses the
1974        // `RVAL → VAL` convert step. `set_device_did_compute` is an
1975        // overloaded hook: `ai/bi/mbbi/mbbi_direct` read it as
1976        // "skip convert" (override true), but `epid` reads it as
1977        // "skip the whole built-in PID compute" (keeps default false).
1978        // Without this gate, a Soft-Channel `epid` driven through
1979        // `process_local` (`db.process_record`, e.g. QSRV group proc
1980        // members) would skip `do_pid()` entirely — the regression
1981        // d1032fe5 fixed on the `processing.rs` path only.
1982        {
1983            let is_soft = self.common.dtyp.is_empty() || self.common.dtyp == "Soft Channel";
1984            let is_output = self.record.can_device_write();
1985            if is_soft && !is_output && self.record.soft_channel_skips_convert() {
1986                self.record.set_device_did_compute(true);
1987            }
1988        }
1989        // Push framework-owned common state (UDF/PHAS/TSE/TSEL) so the
1990        // record's process() can see it — same as the processing.rs link
1991        // path. `process_local` is the foreign-call path
1992        // (`db.process_record`); without this a record driven through it
1993        // (e.g. QSRV group-process members) would not see UDF/TSE.
1994        {
1995            let ctx = self.common.process_context();
1996            self.record.set_process_context(&ctx);
1997        }
1998        let outcome = self.record.process()?;
1999        let process_result = outcome.result;
2000        // Note: process_local() does not execute ProcessActions — those are
2001        // handled by the full process_record_with_links() path in processing.rs.
2002
2003        // If the record reports it modified a metadata-class field during
2004        // process(), invalidate the metadata cache so the next snapshot
2005        // rebuilds from the new values. Default impl returns false, so
2006        // most records pay zero cost here.
2007        if self.record.took_metadata_change() {
2008            self.invalidate_metadata_cache();
2009            // mirror C db_post_events(precord, NULL, DBE_PROPERTY) after record processing.
2010            let fields: Vec<String> = self.subscribers.keys().cloned().collect();
2011            for f in fields {
2012                self.notify_field_with_origin(&f, crate::server::recgbl::EventMask::PROPERTY, 0);
2013            }
2014        }
2015
2016        if process_result == RecordProcessResult::AsyncPending {
2017            // Async: PACT stays set, no further processing this cycle
2018            // Don't clear processing flag (guard won't run — we leak it intentionally)
2019            std::mem::forget(_guard);
2020            return Ok((
2021                ProcessSnapshot {
2022                    changed_fields: Vec::new(),
2023                },
2024                Vec::new(),
2025            ));
2026        }
2027        if let RecordProcessResult::AsyncPendingNotify(fields) = process_result {
2028            // Intermediate notification (e.g. DMOV=0 at move start).
2029            // Unlike AsyncPending, we DO release the processing flag so
2030            // subsequent I/O Intr cycles can continue processing normally.
2031            self.common.time = crate::runtime::general_time::get_current();
2032            // Filter out fields that haven't actually changed, and update
2033            // MLST/last_posted for those that have. Each intermediate
2034            // post carries DBE_VALUE|DBE_LOG — C motor's mid-move
2035            // `db_post_events` calls use `DBE_VAL_LOG`
2036            // (motorRecord.cc:2606 DMOV, and every other do_work post);
2037            // no alarm transition ran on this pending pass.
2038            let mut changed_fields = Vec::new();
2039            for (name, val) in fields {
2040                let changed = match self.last_posted.get(&name) {
2041                    Some(prev) => prev != &val,
2042                    None => true,
2043                };
2044                if changed {
2045                    if name == "VAL" {
2046                        if let Some(f) = val.to_f64() {
2047                            self.put_coerced("MLST", f);
2048                            self.common.mlst = Some(f);
2049                        }
2050                    }
2051                    self.last_posted.insert(name.clone(), val.clone());
2052                    changed_fields.push((name, val, EventMask::VALUE | EventMask::LOG));
2053                }
2054            }
2055            // _guard drops here, clearing the processing flag
2056            return Ok((ProcessSnapshot { changed_fields }, Vec::new()));
2057        }
2058
2059        // UDF update before alarm evaluation — C parity (see
2060        // `processing.rs`). A NaN / undefined value keeps UDF true so
2061        // `recGblCheckUDF` raises UDF_ALARM this cycle instead of the
2062        // record reporting a stale/garbage value with no alarm.
2063        if self.record.clears_udf() {
2064            self.common.udf = self.record.value_is_undefined();
2065        }
2066        // Per-record alarm hook (C `checkAlarms()`).
2067        self.record.check_alarms(&mut self.common);
2068
2069        // Evaluate alarms (accumulates into nsta/nsev)
2070        self.evaluate_alarms();
2071
2072        // Transfer nsta/nsev → sevr/stat, detect alarm change
2073        let alarm_result = recgbl::rec_gbl_reset_alarms(&mut self.common);
2074
2075        self.common.time = crate::runtime::general_time::get_current();
2076        // UDF already updated above — do not clear unconditionally.
2077
2078        // Deadband check for VAL monitor filtering
2079        let (include_val, include_archive) = self.check_deadband_ext();
2080        // C `recGblResetAlarms` `val_mask = DBE_ALARM`
2081        // (recGbl.c:194/203/212): every monitored-value post this cycle
2082        // carries DBE_ALARM when the severity/status OR the alarm
2083        // message moved — same parity rule as the `processing.rs`
2084        // paths.
2085        let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
2086            EventMask::ALARM
2087        } else {
2088            EventMask::NONE
2089        };
2090
2091        // Build snapshot
2092        let mut changed_fields = Vec::new();
2093        // Same deadband-field routing and per-field mask as the
2094        // `processing.rs` paths: the tracked field posts the classes
2095        // that actually fired (MDEL → DBE_VALUE, ADEL → DBE_LOG, alarm
2096        // movement → DBE_ALARM); a non-primary deadband field (motor
2097        // RBV — C motor `monitor()`, motorRecord.cc:3468-3507) leaves
2098        // VAL to the generic change-detection loop below.
2099        let deadband_field = self.record.monitor_deadband_field();
2100        // Fields whose change post carries DBE_VALUE only (LOG stripped) —
2101        // C `db_post_events(field, DBE_VALUE)` literal (e.g. scaler VAL,
2102        // scalerRecord.c:478). Consulted in the deadband post here and the
2103        // generic change loop below.
2104        let value_only = self.record.value_only_change_fields();
2105        let deadband_mask = {
2106            let mut m = alarm_bits;
2107            if include_val {
2108                m |= EventMask::VALUE;
2109            }
2110            // A value-only field's archive (ADEL) LOG bit is dropped — C
2111            // posts it with a literal DBE_VALUE on a value change, never
2112            // DBE_LOG (the LOG sweep is the idle `monitor()` path).
2113            if include_archive && !value_only.contains(&deadband_field) {
2114                m |= EventMask::LOG;
2115            }
2116            m
2117        };
2118        if !deadband_mask.is_empty() {
2119            let dval = if deadband_field == "VAL" {
2120                self.record.val()
2121            } else {
2122                self.resolve_field(deadband_field)
2123            };
2124            if let Some(val) = dval {
2125                changed_fields.push((deadband_field.to_string(), val, deadband_mask));
2126            }
2127        }
2128        // C `recGblResetAlarms` (recGbl.c:201-220) posts each alarm
2129        // field with its OWN per-field mask, not one record-wide mask:
2130        //   * SEVR — DBE_VALUE, ONLY on a sevr change.
2131        //   * STAT — DBE_ALARM (sevr change) | DBE_VALUE (stat change).
2132        //   * ACKS — DBE_VALUE, only when an alarm field moved.
2133        // Pushing SEVR/STAT into `changed_fields` collapses them onto
2134        // the single record-wide `event_mask` (which carries ALARM on
2135        // `alarm_changed`): a DBE_VALUE-only `.SEVR` subscriber would
2136        // miss a stat-only-driven sevr change, and a DBE_ALARM-only
2137        // `.SEVR` subscriber would be wrongly notified. Post them via
2138        // `notify_field` with their individual masks instead — exactly
2139        // as the `processing.rs` link path does.
2140        let sevr_changed = self.common.sevr != alarm_result.prev_sevr;
2141        let stat_changed = self.common.stat != alarm_result.prev_stat;
2142        let stat_mask = {
2143            let mut m = EventMask::NONE;
2144            // C `recGblResetAlarms` carries DBE_ALARM on the STAT/AMSG
2145            // posts whenever the severity OR the alarm message moved —
2146            // not on a severity change alone. Aligning with the
2147            // `processing.rs` link path (and `complete_async_record`).
2148            if sevr_changed || alarm_result.amsg_changed {
2149                m |= EventMask::ALARM;
2150            }
2151            if stat_changed {
2152                m |= EventMask::VALUE;
2153            }
2154            m
2155        };
2156        let mut alarm_posts: Vec<(&'static str, EventMask)> = Vec::new();
2157        if sevr_changed {
2158            alarm_posts.push(("SEVR", EventMask::VALUE));
2159        }
2160        if !stat_mask.is_empty() {
2161            alarm_posts.push(("STAT", stat_mask));
2162            // AMSG shares STAT's mask — C posts it alongside STAT when
2163            // any alarm field moved.
2164            alarm_posts.push(("AMSG", stat_mask));
2165        }
2166        // C parity (recGbl.c:216): ACKS is posted (DBE_VALUE) only when
2167        // an alarm field moved (`stat_mask != 0`) AND it was raised.
2168        if alarm_result.acks_changed && !stat_mask.is_empty() {
2169            alarm_posts.push(("ACKS", EventMask::VALUE));
2170        }
2171
2172        // Add subscribed fields that actually changed since last notification.
2173        // Exclude {deadband-field}/SEVR/STAT/AMSG/UDF — all five are already
2174        // emitted by this path (the deadband field, default VAL, in
2175        // `changed_fields`, SEVR/STAT/AMSG via `alarm_posts`, UDF via the
2176        // explicit UDF push below). Mirrors the two `processing.rs`
2177        // snapshot gates, which exclude the same five; excluding only the
2178        // first three would double-post AMSG and UDF. Each carries
2179        // DBE_VALUE|DBE_LOG plus the cycle's alarm bits — the C
2180        // convention for change-detected auxiliary posts
2181        // (`monitor_mask | DBE_VALUE | DBE_LOG`, calcRecord.c:420,
2182        // subRecord.c:400; motor `DBE_VAL_LOG` for marked fields,
2183        // motorRecord.cc:3522-3645).
2184        //
2185        // On a cycle whose alarm transition fired, fields named by
2186        // `alarm_cycle_monitored_fields` post even when unchanged, with
2187        // the alarm bits alone — C motor `monitor()`
2188        // (motorRecord.cc:3513-3645) posts every listed field once
2189        // `monitor_mask != 0`, so a `DBE_ALARM`-only subscriber
2190        // observes the alarm moment on any of them.
2191        let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
2192        let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
2193            &[]
2194        } else {
2195            self.record.alarm_cycle_monitored_fields()
2196        };
2197        // Fields the record force-posts every cycle it recomputed them
2198        // (C unconditional MARK + DBE_VAL_LOG), even when unchanged —
2199        // see `Record::force_posted_fields`. Empty for most records.
2200        let force_fields = self.record.force_posted_fields();
2201        // Fields the record re-posts with DBE_LOG only every cycle it
2202        // names them, regardless of change — see `Record::log_swept_fields`
2203        // (the scaler's idle S1..Snch sweep). Empty for most records.
2204        let log_swept = self.record.log_swept_fields();
2205        // Secondary value fields posted with VAL's monitor_mask, gated
2206        // inside C's `if (monitor_mask)` (ai RVAL, aiRecord.c:460-465) —
2207        // see `Record::fields_posted_with_value_mask`. Empty for most.
2208        let value_masked = self.record.fields_posted_with_value_mask();
2209        // Fields the record posts itself via an event-driven path (HASH on
2210        // a content-hash change, waveformRecord.c:317-319) — excluded from
2211        // generic change-detection so they are neither double-posted nor
2212        // spuriously posted. See `Record::event_posted_fields`.
2213        let event_posted = self.record.event_posted_fields();
2214        let mut sub_updates: Vec<(String, EpicsValue, EventMask)> = Vec::new();
2215        for (field, subs) in &self.subscribers {
2216            if !subs.is_empty()
2217                && field != deadband_field
2218                && field != "SEVR"
2219                && field != "STAT"
2220                && field != "AMSG"
2221                && field != "UDF"
2222                && !event_posted.contains(&field.as_str())
2223            {
2224                if let Some(val) = self.resolve_field(field) {
2225                    let changed = match self.last_posted.get(field) {
2226                        Some(prev) => prev != &val,
2227                        None => true,
2228                    };
2229                    if value_masked.contains(&field.as_str()) {
2230                        // C posts this secondary value field with VAL's own
2231                        // monitor_mask, nested in `if (monitor_mask)` (ai
2232                        // RVAL, aiRecord.c:460-465): only when VAL is posted
2233                        // this cycle (deadband_mask non-empty) and the field
2234                        // changed — never a forced DBE_VALUE|DBE_LOG.
2235                        if changed && !deadband_mask.is_empty() {
2236                            sub_updates.push((field.clone(), val, deadband_mask));
2237                        }
2238                    } else if changed {
2239                        // A value-only field posts DBE_VALUE (+ this
2240                        // cycle's alarm bits) without the LOG bit —
2241                        // `aux_mask` minus LOG is exactly
2242                        // `alarm_bits | DBE_VALUE`.
2243                        let mask = if value_only.contains(&field.as_str()) {
2244                            alarm_bits | EventMask::VALUE
2245                        } else {
2246                            aux_mask
2247                        };
2248                        sub_updates.push((field.clone(), val, mask));
2249                    } else if force_fields.contains(&field.as_str()) {
2250                        // C `monitor()` posts a re-marked field with
2251                        // `monitor_mask | DBE_VAL_LOG` even when unchanged.
2252                        sub_updates.push((field.clone(), val, aux_mask));
2253                    } else if alarm_fanout.contains(&field.as_str()) {
2254                        sub_updates.push((field.clone(), val, alarm_bits));
2255                    } else if log_swept.contains(&field.as_str()) {
2256                        // C scalerRecord.c:770-787 `monitor()`: every idle
2257                        // process re-posts each S1..Snch with a literal
2258                        // DBE_LOG regardless of change. A value-only field
2259                        // (e.g. Sn) posts DBE_VALUE only on a counting
2260                        // change, so the DBE_LOG subscriber is served here
2261                        // by the idle sweep — and Sn does not change on an
2262                        // idle cycle, so changed/unchanged stay disjoint
2263                        // (no double post).
2264                        sub_updates.push((field.clone(), val, EventMask::LOG));
2265                    }
2266                }
2267            }
2268        }
2269        if !sub_updates.is_empty() {
2270            for (field, val, _) in &sub_updates {
2271                self.last_posted.insert(field.clone(), val.clone());
2272            }
2273            changed_fields.extend(sub_updates);
2274        }
2275        // C waveform/aai/aao `monitor()` posts HASH with a literal
2276        // `DBE_VALUE` only on a content-hash change (waveformRecord.c:
2277        // 317-319), independent of the VAL post mask. `array_hash_changed`
2278        // was set by `check_deadband_ext` this cycle.
2279        if self.array_hash_changed {
2280            if let Some(h) = self.resolve_field("HASH") {
2281                changed_fields.push(("HASH".to_string(), h, EventMask::VALUE));
2282            }
2283        }
2284
2285        // Post UDF on the snapshot whenever any monitor event fires this
2286        // cycle, carrying the union of the cycle's posted classes —
2287        // mirrors the two `processing.rs` UDF pushes. C
2288        // `recGblResetAlarms` / `recGblCheckUDF` (recGbl.c) keep UDF
2289        // current every process cycle, and `db_post_events` delivers
2290        // `.UDF` alongside the record-wide post. `process_local` is the
2291        // foreign-process path (`db.process_record`, e.g. QSRV
2292        // group-process members); without this push a UDF change here is
2293        // never delivered to `.UDF` subscribers — the `sub_updates` loop
2294        // above deliberately excludes UDF to avoid a double-post, so the
2295        // push must be here.
2296        let cycle_mask = changed_fields
2297            .iter()
2298            .fold(EventMask::NONE, |m, (_, _, fm)| m | *fm);
2299        if !cycle_mask.is_empty() {
2300            changed_fields.push((
2301                "UDF".to_string(),
2302                EpicsValue::Char(if self.common.udf { 1 } else { 0 }),
2303                cycle_mask,
2304            ));
2305        }
2306
2307        Ok((ProcessSnapshot { changed_fields }, alarm_posts))
2308    }
2309
2310    /// Put a f64 value into a record field, coercing to the field's native type.
2311    pub(crate) fn put_coerced(&mut self, field: &str, val: f64) {
2312        use crate::types::EpicsValue;
2313        let target_type = self
2314            .record
2315            .get_field(field)
2316            .map(|v| v.db_field_type())
2317            .unwrap_or(crate::types::DbFieldType::Double);
2318        let coerced = EpicsValue::Double(val).convert_to(target_type);
2319        let _ = self.record.put_field(field, coerced);
2320    }
2321
2322    /// Check MDEL/ADEL deadbands for VAL monitor/archive filtering.
2323    /// Returns `(monitor_trigger, archive_trigger)`.
2324    ///
2325    /// Updates `MLST`/`ALST` (record-owned) and the `CommonFields`
2326    /// `mlst/alst` shadow when a trigger fires. Records without
2327    /// MDEL/ADEL (e.g. motor) default to deadband=0 (any actual
2328    /// change triggers).
2329    ///
2330    /// Delegates per-axis deadband comparison to the free function
2331    /// [`check_deadband`] below — see that function's docstring for
2332    /// the four-quadrant NaN/infinity rule mirroring C
2333    /// `recGblCheckDeadband` (recGbl.c:345-370).
2334    ///
2335    /// **C-parity design note**: the Rust port uses `NaN` as the
2336    /// "never posted" sentinel for `MLST`/`ALST`. C achieves the
2337    /// same first-publish guarantee by allocating MLST/ALST in
2338    /// BSS-zeroed storage with a value of 0.0 that the C code is
2339    /// allowed to match against — but the first observed value is
2340    /// not necessarily 0.0, and the C rule "MLST==0 means never
2341    /// posted" relies on the deadband comparison `abs(val - 0.0)`
2342    /// firing on any non-zero first value. NaN is strictly more
2343    /// correct for the Rust port because a legitimate first
2344    /// `val=0.0` still fires on `NaN.is_nan() → true`. This
2345    /// sentinel-as-design is intentional, documented inside
2346    /// [`check_deadband`] (the `oldval.is_nan() → return true` short
2347    /// circuit). It is NOT a deviation inherited from an earlier
2348    /// silent compromise — `record_tests.rs::deadband_*` pins both
2349    /// the NaN-sentinel behaviour and the C four-quadrant transitions.
2350    pub fn check_deadband_ext(&mut self) -> (bool, bool) {
2351        // C waveform/aai/aao `monitor()` (waveformRecord.c:291-326) replaces
2352        // the analog MDEL/ADEL deadband with the MPST/APST "Always vs On
2353        // Change" mechanism: the record hashes its array content and posts
2354        // `DBE_VALUE`/`DBE_LOG` either always or only when the hash changed,
2355        // and posts `HASH` (`DBE_VALUE`) on a hash change. The record owns
2356        // the hash compute + `HASH` update; `array_hash_changed` carries the
2357        // event to the snapshot builders, which post `HASH` (the field is
2358        // excluded from the generic change-detection loop via
2359        // `event_posted_fields`).
2360        if let Some(post) = self.record.array_monitor_post() {
2361            self.array_hash_changed = post.hash_changed;
2362            return (post.post_value, post.post_archive);
2363        }
2364        self.array_hash_changed = false;
2365
2366        // The deadband is evaluated against `monitor_deadband_value()`,
2367        // not `val()` directly: a record whose monitored quantity is
2368        // not its primary value (e.g. the motor record, VAL=setpoint /
2369        // RBV=readback — C `monitor()` deadbands RBV) overrides that
2370        // hook. Default is `val()`, so other records are unaffected.
2371        let val = match self
2372            .record
2373            .monitor_deadband_value()
2374            .and_then(|v| v.to_f64())
2375        {
2376            Some(v) => v,
2377            None => return (true, true),
2378        };
2379
2380        let mdel = self
2381            .record
2382            .get_field("MDEL")
2383            .and_then(|v| v.to_f64())
2384            .unwrap_or(0.0);
2385        let adel = self
2386            .record
2387            .get_field("ADEL")
2388            .and_then(|v| v.to_f64())
2389            .unwrap_or(0.0);
2390
2391        // Use record's MLST/ALST fields if available, otherwise fall back to CommonFields
2392        let mlst = self
2393            .record
2394            .get_field("MLST")
2395            .and_then(|v| v.to_f64())
2396            .or(self.common.mlst)
2397            .unwrap_or(f64::NAN);
2398        let alst = self
2399            .record
2400            .get_field("ALST")
2401            .and_then(|v| v.to_f64())
2402            .or(self.common.alst)
2403            .unwrap_or(f64::NAN);
2404
2405        let monitor_trigger = check_deadband(val, mlst, mdel);
2406        let archive_trigger = check_deadband(val, alst, adel);
2407
2408        if archive_trigger {
2409            self.put_coerced("ALST", val);
2410            self.common.alst = Some(val);
2411        }
2412        if monitor_trigger {
2413            self.put_coerced("MLST", val);
2414            self.common.mlst = Some(val);
2415        }
2416
2417        (monitor_trigger, archive_trigger)
2418    }
2419
2420    /// Build a Snapshot for a given value, populated with the record's display metadata.
2421    /// Uses the metadata cache so the populate cost is paid at most once
2422    /// per metadata-stable interval (cf. `cached_metadata`).
2423    fn make_monitor_snapshot(
2424        &self,
2425        field: &str,
2426        value: EpicsValue,
2427    ) -> super::super::snapshot::Snapshot {
2428        let mut snap = super::super::snapshot::Snapshot::new(
2429            value,
2430            self.common.stat,
2431            self.common.sevr as u16,
2432            self.common.time,
2433        );
2434        // Carry the record's `utag` into the monitor update's
2435        // `timeStamp.userTag`, same as the GET path
2436        // (`snapshot_for_field`) and pvxs `iocsource.cpp:245`. Narrows
2437        // the 64-bit `epicsUTag` to the int32 wire field by low-32-bit
2438        // truncation.
2439        snap.user_tag = self.common.utag as i32;
2440        let meta = self.cached_metadata();
2441        snap.display = meta.display;
2442        snap.control = meta.control;
2443        snap.enums = meta.enums;
2444        // Per-field RSET metadata, same as the GET path
2445        // (`snapshot_for_field`) — a monitor update for VELO must carry
2446        // VELO's limits, not the record-level VAL limits.
2447        self.apply_field_metadata_override(field, &mut snap);
2448        // A monitored DBF_MENU field carries the same DBR_ENUM value and
2449        // choice labels as the GET path, so a `camonitor`/`pvmonitor`
2450        // update shows the menu label, not a bare index.
2451        self.attach_menu_enum(field, &mut snap);
2452        snap
2453    }
2454
2455    /// Apply a record's per-field metadata override (C RSET
2456    /// `get_units`/`get_precision`/`get_graphic_double`/
2457    /// `get_control_double`/`get_alarm_double`, all keyed by field)
2458    /// over the cached record-level metadata. Shared by the GET and
2459    /// monitor snapshot builders. Computed live on every call — never
2460    /// cached — so overrides derived from fields outside the
2461    /// `is_metadata_field` set cannot go stale.
2462    fn apply_field_metadata_override(
2463        &self,
2464        field: &str,
2465        snap: &mut super::super::snapshot::Snapshot,
2466    ) {
2467        let Some(ov) = self.record.field_metadata_override(field) else {
2468            return;
2469        };
2470        if ov.units.is_some()
2471            || ov.precision.is_some()
2472            || ov.disp_limits.is_some()
2473            || ov.alarm_limits.is_some()
2474        {
2475            let d = snap.display.get_or_insert_with(Default::default);
2476            if let Some(units) = ov.units {
2477                d.units = units;
2478            }
2479            if let Some(precision) = ov.precision {
2480                d.precision = precision;
2481            }
2482            if let Some((upper, lower)) = ov.disp_limits {
2483                d.upper_disp_limit = upper;
2484                d.lower_disp_limit = lower;
2485            }
2486            if let Some((hihi, high, low, lolo)) = ov.alarm_limits {
2487                d.upper_alarm_limit = hihi;
2488                d.upper_warning_limit = high;
2489                d.lower_warning_limit = low;
2490                d.lower_alarm_limit = lolo;
2491            }
2492        }
2493        if let Some((upper, lower)) = ov.ctrl_limits {
2494            let c = snap.control.get_or_insert_with(Default::default);
2495            c.upper_ctrl_limit = upper;
2496            c.lower_ctrl_limit = lower;
2497        }
2498    }
2499
2500    /// Notify subscribers from a snapshot (call outside lock).
2501    /// Each entry carries its own posting mask: only subscribers whose
2502    /// mask intersects that field's mask are notified, and the
2503    /// delivered [`MonitorEvent`] reports exactly that field's classes
2504    /// (C `db_post_events(prec, &field, mask)` per-field granularity).
2505    pub fn notify_from_snapshot(&self, snapshot: &ProcessSnapshot) {
2506        use crate::server::database::filters::FilteredMonitorEvent;
2507        use crate::server::recgbl::EventMask;
2508
2509        for (field, value, posting_mask) in &snapshot.changed_fields {
2510            let posting_mask = *posting_mask;
2511            if let Some(subs) = self.subscribers.get(field) {
2512                // Build a full snapshot once per field (with display metadata)
2513                let mon_snap = self.make_monitor_snapshot(field, value.clone());
2514                for sub in subs {
2515                    // Paused subscriber (`db_event_disable`): suppress at
2516                    // the source — no delivery, no coalesce.
2517                    if !sub.active {
2518                        continue;
2519                    }
2520                    let sub_mask = EventMask::from_bits(sub.mask);
2521                    // Only send when posting mask intersects subscriber mask.
2522                    // Empty posting mask means nothing changed — skip.
2523                    if !posting_mask.is_empty() && sub_mask.intersects(posting_mask) {
2524                        let event = MonitorEvent {
2525                            snapshot: mon_snap.clone(),
2526                            origin: 0,
2527                            mask: posting_mask,
2528                        };
2529                        // Server-side filter chain (3.15.7). Empty chain
2530                        // is identity, so no behaviour change for the
2531                        // common no-filter case.
2532                        let filtered = if sub.filters.is_empty() {
2533                            Some(event)
2534                        } else {
2535                            sub.filters
2536                                .apply(FilteredMonitorEvent::new(event))
2537                                .map(|fe| fe.event)
2538                        };
2539                        let Some(event) = filtered else {
2540                            continue;
2541                        };
2542                        if sub.tx.try_send(event.clone()).is_err() {
2543                            // route the coalesce overwrite through
2544                            // the single owner so a record-field monitor
2545                            // value lost to a slow consumer is counted in
2546                            // `dropped_monitor_events()`, exactly like a
2547                            // `ProcessVariable` overflow.
2548                            sub.coalesce_overflow(event);
2549                        }
2550                    }
2551                }
2552            }
2553        }
2554    }
2555
2556    /// Notify subscribers of a specific field, filtering by event mask.
2557    pub fn notify_field(&self, field: &str, mask: crate::server::recgbl::EventMask) {
2558        self.notify_field_with_origin(field, mask, 0);
2559    }
2560
2561    /// C `db_post_events(precord, NULL, DBE_ALARM)`: post a record-wide
2562    /// alarm event. Delivers to every subscriber on any field whose mask
2563    /// includes DBE_ALARM, each carrying its own monitored field's current
2564    /// value (the per-field `notify_field` already filters by mask
2565    /// intersection). Used by the alarm-acknowledge (ACKT/ACKS) put path so
2566    /// an alarm-mask monitor on any field observes the acknowledgement.
2567    pub fn notify_record_alarm(&self) {
2568        let fields: Vec<String> = self.subscribers.keys().cloned().collect();
2569        for field in fields {
2570            self.notify_field(&field, crate::server::recgbl::EventMask::ALARM);
2571        }
2572    }
2573
2574    /// Notify subscribers with an origin tag for self-write filtering.
2575    pub fn notify_field_with_origin(
2576        &self,
2577        field: &str,
2578        mask: crate::server::recgbl::EventMask,
2579        origin: u64,
2580    ) {
2581        use crate::server::database::filters::FilteredMonitorEvent;
2582        if let Some(subs) = self.subscribers.get(field) {
2583            if let Some(value) = self.resolve_field(field) {
2584                let mon_snap = self.make_monitor_snapshot(field, value);
2585                for sub in subs {
2586                    // Paused subscriber (`db_event_disable`): suppress at
2587                    // the source — no delivery, no coalesce.
2588                    if !sub.active {
2589                        continue;
2590                    }
2591                    let sub_mask = crate::server::recgbl::EventMask::from_bits(sub.mask);
2592                    if mask.is_empty() || sub_mask.intersects(mask) {
2593                        let event = MonitorEvent {
2594                            snapshot: mon_snap.clone(),
2595                            origin,
2596                            mask,
2597                        };
2598                        // Server-side filter chain (3.15.7). Empty
2599                        // chain (the default for every subscriber
2600                        // until a `.{filter:opts}` PV-name suffix
2601                        // parser wires one in) is the identity, so
2602                        // existing subscribers see no behaviour
2603                        // change. A filter returning `None` silences
2604                        // this event for this subscriber only.
2605                        let filtered = if sub.filters.is_empty() {
2606                            Some(event)
2607                        } else {
2608                            sub.filters
2609                                .apply(FilteredMonitorEvent::new(event))
2610                                .map(|fe| fe.event)
2611                        };
2612                        let Some(event) = filtered else {
2613                            continue;
2614                        };
2615                        if sub.tx.try_send(event.clone()).is_err() {
2616                            // same single coalesce-overflow owner
2617                            // as the snapshot path — record-field loss to
2618                            // a slow consumer must be counted, not silently
2619                            // overwritten.
2620                            sub.coalesce_overflow(event);
2621                        }
2622                    }
2623                }
2624            }
2625        }
2626    }
2627
2628    /// Add a subscriber for a specific field. Returns `None` when the
2629    /// per-field subscriber cap (`EPICS_CAS_MAX_SUBSCRIBERS_PER_PV`)
2630    /// is reached. the parallel cap on `ProcessVariable`
2631    /// defends against a misbehaving client opening many
2632    /// MONITOR ops against one shared PV; the same defence is needed
2633    /// for record fields, which the CA server's
2634    /// `ChannelTarget::RecordField` path lands on.
2635    pub fn add_subscriber(
2636        &mut self,
2637        field: &str,
2638        sid: u32,
2639        data_type: DbFieldType,
2640        mask: u16,
2641    ) -> Option<mpsc::Receiver<MonitorEvent>> {
2642        let cap = crate::server::pv::max_subscribers_per_pv();
2643        let field_str = field.to_string();
2644        let bucket = self.subscribers.entry(field_str.clone()).or_default();
2645        // Reap dead Senders before
2646        // counting against the cap. A record field whose value
2647        // never changes (e.g. a quasi-static catalog field) never
2648        // triggers `notify_field_with_origin`'s retain-filter, so
2649        // a long-lived subscribe-disconnect storm could pin the
2650        // bucket at `cap` worth of closed Senders and lock out
2651        // genuine new subscribers.
2652        bucket.retain(|s| !s.tx.is_closed());
2653        if bucket.len() >= cap {
2654            tracing::warn!(
2655                record = %self.name,
2656                field = %field_str,
2657                live = bucket.len(),
2658                cap,
2659                "record field subscriber cap reached, refusing add_subscriber"
2660            );
2661            return None;
2662        }
2663        let (tx, rx) = mpsc::channel(64);
2664        bucket.push(Subscriber {
2665            sid,
2666            data_type,
2667            mask,
2668            tx,
2669            coalesced: std::sync::Arc::new(std::sync::Mutex::new(None)),
2670            filters: crate::server::database::filters::FilterChain::new(),
2671            active: true,
2672        });
2673        // Initialize last_posted with current value so the first process cycle
2674        // doesn't treat it as "changed" (the initial value is already sent
2675        // to the client as part of EVENT_ADD response).
2676        if !self.last_posted.contains_key(&field_str) {
2677            if let Some(val) = self.resolve_field(&field_str) {
2678                self.last_posted.insert(field_str, val);
2679            }
2680        }
2681        Some(rx)
2682    }
2683
2684    /// Attach a filter to the most recently added subscriber for
2685    /// `field`. Returns `false` when no subscriber exists yet on that
2686    /// field (call `add_subscriber` first). The CA / PVA channel-name
2687    /// parsers will use this once `.{filter:opts}` syntax is wired.
2688    /// Tests can also use it directly to compose filter chains.
2689    pub fn attach_filter_to_last_subscriber(
2690        &mut self,
2691        field: &str,
2692        filter: std::sync::Arc<dyn crate::server::database::filters::SubscriptionFilter>,
2693    ) -> bool {
2694        if let Some(bucket) = self.subscribers.get_mut(field) {
2695            if let Some(sub) = bucket.last_mut() {
2696                sub.filters.push(filter);
2697                return true;
2698            }
2699        }
2700        false
2701    }
2702
2703    /// Remove a subscriber by subscription ID from all fields.
2704    pub fn remove_subscriber(&mut self, sid: u32) {
2705        for subs in self.subscribers.values_mut() {
2706            subs.retain(|s| s.sid != sid);
2707        }
2708    }
2709
2710    /// Pause / resume one subscriber's event flow at the source
2711    /// (`db_event_disable` / `db_event_enable`). `active == false`
2712    /// suppresses every subsequent post to this subscriber AND drops any
2713    /// pending coalesced overflow, so a resumed monitor restarts from the
2714    /// source-side edge rather than replaying a value captured while it
2715    /// was paused. No-op if no subscriber has this `sid`. The caller holds
2716    /// the record write lock, so this is exclusive with the read-locked
2717    /// post paths that consult `Subscriber::active`.
2718    pub fn set_subscriber_active(&mut self, sid: u32, active: bool) {
2719        for subs in self.subscribers.values_mut() {
2720            for sub in subs.iter_mut() {
2721                if sub.sid == sid {
2722                    sub.active = active;
2723                    if !active && let Ok(mut slot) = sub.coalesced.lock() {
2724                        *slot = None;
2725                    }
2726                }
2727            }
2728        }
2729    }
2730
2731    /// Take any pending coalesced overflow event for `sid` across all
2732    /// fields. Drops-oldest semantics: if the per-subscriber mpsc filled
2733    /// while the consumer was slow, the newest event was stashed in the
2734    /// coalesce slot and is returned here.
2735    pub fn pop_coalesced(&self, sid: u32) -> Option<MonitorEvent> {
2736        for subs in self.subscribers.values() {
2737            for sub in subs {
2738                if sub.sid == sid {
2739                    if let Ok(mut slot) = sub.coalesced.lock() {
2740                        if let Some(ev) = slot.take() {
2741                            return Some(ev);
2742                        }
2743                    }
2744                }
2745            }
2746        }
2747        None
2748    }
2749
2750    /// Clean up closed subscriber channels.
2751    pub fn cleanup_subscribers(&mut self) {
2752        for subs in self.subscribers.values_mut() {
2753            subs.retain(|s| !s.tx.is_closed());
2754        }
2755    }
2756}
2757
2758/// C `recGblCheckDeadband` parity (recGbl.c:345-370). The four branches
2759/// the C path enumerates:
2760///
2761/// 1. Both `newval` and `oldval` finite: `delta = |old - new|`, fire when
2762///    `delta > deadband`.
2763/// 2. Exactly one of {newval, oldval} is NaN, the other not — OR exactly
2764///    one is +/-inf, the other not: `delta = +inf`, always fires.
2765/// 3. Both infinite with opposite signs: `delta = +inf`, always fires.
2766/// 4. Otherwise (e.g. both NaN, both same-signed infinity): no fire.
2767///
2768/// `oldval = NaN` is treated as "never posted" and fires (matches the
2769/// `mlst.is_nan() → trigger` short-circuit the Rust port already had).
2770/// `deadband < 0` fires unconditionally (matches `delta > deadband`
2771/// with a negative deadband — same effect on every numeric value).
2772pub(crate) fn check_deadband(newval: f64, oldval: f64, deadband: f64) -> bool {
2773    // Fire unconditionally when no prior posting has happened. C achieves
2774    // the same effect through the field being default-initialised to a
2775    // sentinel; Rust uses NaN-as-sentinel.
2776    if oldval.is_nan() {
2777        return true;
2778    }
2779    // Negative deadband short-circuits — any value passes.
2780    if deadband < 0.0 {
2781        return true;
2782    }
2783    let new_finite = newval.is_finite();
2784    let old_finite = oldval.is_finite();
2785    if new_finite && old_finite {
2786        return (newval - oldval).abs() > deadband;
2787    }
2788    // From here on, at least one of the two is not finite. We've already
2789    // ruled out oldval=NaN above, so any newval=NaN here is the "newval
2790    // went NaN while oldval was finite/inf" case — must fire (C case 2).
2791    if newval.is_nan() {
2792        return true;
2793    }
2794    // Exactly one infinite, the other finite: C case 2 → fire.
2795    if new_finite != old_finite {
2796        return true;
2797    }
2798    // Both infinite. Opposite signs → fire (C case 3); same sign → no
2799    // fire (C path leaves delta=0 and the `delta > deadband` check fails
2800    // for any non-negative deadband).
2801    newval != oldval
2802}
2803
2804#[cfg(test)]
2805mod metadata_cache_tests {
2806    use super::*;
2807    use crate::server::records::ai::AiRecord;
2808
2809    /// Helper: build an AiRecord wrapped in a RecordInstance with EGU/PREC/HOPR/LOPR set.
2810    fn ai_instance() -> RecordInstance {
2811        let mut rec = AiRecord::default();
2812        let _ = rec.put_field("EGU", EpicsValue::String("degC".into()));
2813        let _ = rec.put_field("PREC", EpicsValue::Short(2));
2814        let _ = rec.put_field("HOPR", EpicsValue::Double(100.0));
2815        let _ = rec.put_field("LOPR", EpicsValue::Double(0.0));
2816        let _ = rec.put_field("VAL", EpicsValue::Double(25.0));
2817        RecordInstance::new("TEMP".to_string(), rec)
2818    }
2819
2820    /// a record-field monitor whose bounded queue is full and
2821    /// whose coalesce slot already holds an unobserved value must count
2822    /// the displaced value in the shared `dropped_monitor_events()`
2823    /// counter — the same accounting a `ProcessVariable` overflow uses.
2824    /// Before the fix the record-field path overwrote the slot without
2825    /// counting, hiding slow-consumer loss on the path most CA/PVA
2826    /// database monitors use. The counter is process-global, so the
2827    /// assertion is a strict monotonic increase (robust under parallel
2828    /// tests); the revert-verify runs this test in isolation.
2829    #[test]
2830    fn bfr10_record_field_overflow_counts_dropped_event() {
2831        use crate::server::pv::dropped_monitor_events;
2832        use crate::server::recgbl::EventMask;
2833        let mut inst = ai_instance();
2834        // Keep `rx` alive (do NOT drain) so the bounded 64-deep queue
2835        // fills, then the coalesce slot, before overflow replacement.
2836        let _rx = inst
2837            .add_subscriber(
2838                "VAL",
2839                1,
2840                crate::types::DbFieldType::Double,
2841                EventMask::VALUE.bits(),
2842            )
2843            .expect("subscriber added");
2844        let before = dropped_monitor_events();
2845        // 64 sends fill the queue; the 65th fills the (empty) coalesce
2846        // slot; each send after that overwrites an UNOBSERVED slot value
2847        // and must be counted as a dropped monitor event.
2848        for _ in 0..70 {
2849            inst.notify_field_with_origin("VAL", EventMask::VALUE, 0);
2850        }
2851        let after = dropped_monitor_events();
2852        assert!(
2853            after > before,
2854            "record-field overflow onto an occupied coalesce slot must \
2855             record a dropped monitor event (before={before}, after={after})"
2856        );
2857    }
2858
2859    #[test]
2860    fn metadata_field_set_check() {
2861        // Sanity check that the metadata field set is recognized.
2862        assert!(is_metadata_field("EGU"));
2863        assert!(is_metadata_field("PREC"));
2864        assert!(is_metadata_field("HOPR"));
2865        assert!(is_metadata_field("LOPR"));
2866        assert!(is_metadata_field("HIHI"));
2867        assert!(is_metadata_field("DRVH"));
2868        assert!(is_metadata_field("ZNAM"));
2869        assert!(is_metadata_field("ZRST"));
2870        assert!(is_metadata_field("FFST"));
2871
2872        // Non-metadata fields should NOT invalidate the cache
2873        assert!(!is_metadata_field("VAL"));
2874        assert!(!is_metadata_field("DESC"));
2875        assert!(!is_metadata_field("SCAN"));
2876        assert!(!is_metadata_field("PHAS"));
2877    }
2878
2879    #[test]
2880    fn cache_starts_empty_then_populates_on_first_snapshot() {
2881        let inst = ai_instance();
2882
2883        // Cache starts empty
2884        assert!(inst.metadata_cache.lock().unwrap().is_none());
2885
2886        // First snapshot triggers populate + cache store
2887        let snap = inst.snapshot_for_field("VAL").unwrap();
2888        let display = snap.display.expect("ai snapshot must have display");
2889        assert_eq!(display.units, "degC");
2890        assert_eq!(display.precision, 2);
2891        assert_eq!(display.upper_disp_limit, 100.0);
2892        assert_eq!(display.lower_disp_limit, 0.0);
2893
2894        // Cache is now populated
2895        assert!(inst.metadata_cache.lock().unwrap().is_some());
2896    }
2897
2898    /// the served `timeStamp.userTag` defaults to the record's `utag`
2899    /// (pvxs `iocsource.cpp:245`), on both the GET (`snapshot_for_field`)
2900    /// and MONITOR (`make_monitor_snapshot`) paths. Pre-fix both hard-set
2901    /// it to 0, dropping the record's tag. A bit-31 utag also pins the
2902    /// `u64 -> i32` narrowing: the low 32 bits' pattern is preserved
2903    /// (no clamp), matching pvxs assigning `epicsUTag` into the `Int32`
2904    /// wire field.
2905    #[test]
2906    fn snapshot_serves_record_utag_as_timestamp_usertag() {
2907        let mut inst = ai_instance();
2908        // no `info(Q:time:tag, ...)` on this record, so the nsec-LSB
2909        // override never fires and the utag default is what is served.
2910        inst.common.utag = 0x9000_0000;
2911        let want = 0x9000_0000u32 as i32;
2912
2913        let get = inst.snapshot_for_field("VAL").unwrap();
2914        assert_eq!(
2915            get.user_tag, want,
2916            "GET path must serve the record's utag as timeStamp.userTag"
2917        );
2918
2919        let mon = inst.make_monitor_snapshot("VAL", EpicsValue::Double(1.0));
2920        assert_eq!(
2921            mon.user_tag, want,
2922            "MONITOR path must carry the record's utag too"
2923        );
2924    }
2925
2926    #[test]
2927    fn cache_hit_returns_same_metadata() {
2928        let inst = ai_instance();
2929
2930        // Prime the cache
2931        let snap1 = inst.snapshot_for_field("VAL").unwrap();
2932        let display1 = snap1.display.unwrap();
2933
2934        // Subsequent snapshots return the same cached metadata
2935        let snap2 = inst.snapshot_for_field("VAL").unwrap();
2936        let display2 = snap2.display.unwrap();
2937
2938        assert_eq!(display1.units, display2.units);
2939        assert_eq!(display1.precision, display2.precision);
2940        assert_eq!(display1.upper_disp_limit, display2.upper_disp_limit);
2941        assert_eq!(display1.lower_disp_limit, display2.lower_disp_limit);
2942    }
2943
2944    #[test]
2945    fn invalidate_clears_cache() {
2946        let inst = ai_instance();
2947        let _ = inst.snapshot_for_field("VAL");
2948        assert!(inst.metadata_cache.lock().unwrap().is_some());
2949
2950        inst.invalidate_metadata_cache();
2951        assert!(inst.metadata_cache.lock().unwrap().is_none());
2952    }
2953
2954    #[test]
2955    fn notify_field_written_invalidates_for_metadata_field() {
2956        let inst = ai_instance();
2957        let _ = inst.snapshot_for_field("VAL");
2958        assert!(inst.metadata_cache.lock().unwrap().is_some());
2959
2960        // Writing a metadata field should invalidate
2961        inst.notify_field_written("EGU");
2962        assert!(inst.metadata_cache.lock().unwrap().is_none());
2963    }
2964
2965    #[test]
2966    fn notify_field_written_skips_non_metadata_field() {
2967        let inst = ai_instance();
2968        let _ = inst.snapshot_for_field("VAL");
2969        assert!(inst.metadata_cache.lock().unwrap().is_some());
2970
2971        // Writing a value field should NOT invalidate the cache
2972        inst.notify_field_written("VAL");
2973        assert!(inst.metadata_cache.lock().unwrap().is_some());
2974
2975        // Same for DESC
2976        inst.notify_field_written("DESC");
2977        assert!(inst.metadata_cache.lock().unwrap().is_some());
2978    }
2979
2980    #[test]
2981    fn notify_field_written_is_case_insensitive() {
2982        let inst = ai_instance();
2983        let _ = inst.snapshot_for_field("VAL");
2984        assert!(inst.metadata_cache.lock().unwrap().is_some());
2985
2986        // Lowercase metadata field name should still trigger invalidation
2987        inst.notify_field_written("egu");
2988        assert!(inst.metadata_cache.lock().unwrap().is_none());
2989    }
2990
2991    /// epics-base faac1df1 — `notify_field_written_if_changed` must
2992    /// SKIP the cache invalidation when the metadata field's value
2993    /// didn't actually change. Otherwise a stream of idempotent puts
2994    /// from a CSS panel binds DBE_PROPERTY subscribers to bogus
2995    /// "property changed" events on every cycle.
2996    #[test]
2997    fn notify_field_written_if_changed_skips_when_unchanged() {
2998        let mut inst = ai_instance();
2999        let _ = inst.snapshot_for_field("VAL");
3000        assert!(inst.metadata_cache.lock().unwrap().is_some());
3001
3002        // Capture prev, do a no-op put, then notify — cache must remain.
3003        let prev = inst.record.get_field("EGU");
3004        let _ = inst.record.put_field("EGU", prev.clone().unwrap());
3005        inst.notify_field_written_if_changed("EGU", prev.as_ref());
3006        assert!(
3007            inst.metadata_cache.lock().unwrap().is_some(),
3008            "no-op put must not invalidate the metadata cache"
3009        );
3010    }
3011
3012    /// And when the value DID change, the cache must invalidate.
3013    #[test]
3014    fn notify_field_written_if_changed_invalidates_on_real_change() {
3015        let mut inst = ai_instance();
3016        let _ = inst.snapshot_for_field("VAL");
3017        assert!(inst.metadata_cache.lock().unwrap().is_some());
3018
3019        let prev = inst.record.get_field("EGU");
3020        let _ = inst
3021            .record
3022            .put_field("EGU", EpicsValue::String("kPa".into()));
3023        inst.notify_field_written_if_changed("EGU", prev.as_ref());
3024        assert!(
3025            inst.metadata_cache.lock().unwrap().is_none(),
3026            "real metadata change must invalidate cache"
3027        );
3028    }
3029
3030    /// Non-metadata fields don't carry property semantics — the
3031    /// `if_changed` variant must never invalidate for them, matching
3032    /// the existing `notify_field_written` short-circuit.
3033    #[test]
3034    fn notify_field_written_if_changed_skips_non_metadata_field() {
3035        let inst = ai_instance();
3036        let _ = inst.snapshot_for_field("VAL");
3037        assert!(inst.metadata_cache.lock().unwrap().is_some());
3038        // VAL is not in is_metadata_field set — must be skipped even
3039        // with a changed value.
3040        inst.notify_field_written_if_changed("VAL", None);
3041        assert!(inst.metadata_cache.lock().unwrap().is_some());
3042    }
3043
3044    #[test]
3045    fn cache_picks_up_new_value_after_invalidation() {
3046        let mut inst = ai_instance();
3047
3048        // First snapshot: degC
3049        let snap1 = inst.snapshot_for_field("VAL").unwrap();
3050        assert_eq!(snap1.display.unwrap().units, "degC");
3051
3052        // Mutate EGU and invalidate
3053        let _ = inst
3054            .record
3055            .put_field("EGU", EpicsValue::String("mV".into()));
3056        inst.notify_field_written("EGU");
3057
3058        // Second snapshot: mV (rebuilt)
3059        let snap2 = inst.snapshot_for_field("VAL").unwrap();
3060        assert_eq!(snap2.display.unwrap().units, "mV");
3061    }
3062
3063    #[test]
3064    fn make_monitor_snapshot_uses_cache() {
3065        let inst = ai_instance();
3066        assert!(inst.metadata_cache.lock().unwrap().is_none());
3067
3068        // make_monitor_snapshot should also populate the cache
3069        let snap = inst.make_monitor_snapshot("VAL", EpicsValue::Double(42.0));
3070        assert!(snap.display.is_some());
3071        assert!(inst.metadata_cache.lock().unwrap().is_some());
3072
3073        // Subsequent call hits cache
3074        let snap2 = inst.make_monitor_snapshot("VAL", EpicsValue::Double(43.0));
3075        let d1 = snap.display.unwrap();
3076        let d2 = snap2.display.unwrap();
3077        assert_eq!(d1.units, d2.units);
3078        assert_eq!(d1.precision, d2.precision);
3079    }
3080
3081    /// Stub record with a per-field metadata override on SPD only —
3082    /// models a C RSET whose get_units/get_graphic_double key on
3083    /// dbGetFieldIndex (e.g. motorRecord.cc:3156-3361).
3084    struct PerFieldMetaRecord;
3085
3086    impl Record for PerFieldMetaRecord {
3087        fn record_type(&self) -> &'static str {
3088            "ai" // record-level metadata populates from EGU/PREC/HOPR/LOPR
3089        }
3090        fn get_field(&self, name: &str) -> Option<EpicsValue> {
3091            match name {
3092                "VAL" | "SPD" => Some(EpicsValue::Double(1.0)),
3093                "EGU" => Some(EpicsValue::String("mm".into())),
3094                "PREC" => Some(EpicsValue::Short(3)),
3095                "HOPR" => Some(EpicsValue::Double(100.0)),
3096                "LOPR" => Some(EpicsValue::Double(-100.0)),
3097                _ => None,
3098            }
3099        }
3100        fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
3101            Err(CaError::FieldNotFound(name.to_string()))
3102        }
3103        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3104            &[]
3105        }
3106        fn field_metadata_override(
3107            &self,
3108            field: &str,
3109        ) -> Option<crate::server::record::FieldMetadataOverride> {
3110            if field != "SPD" {
3111                return None;
3112            }
3113            Some(crate::server::record::FieldMetadataOverride {
3114                units: Some("mm/sec".into()),
3115                precision: Some(1),
3116                disp_limits: Some((5.0, 0.5)),
3117                ctrl_limits: Some((4.0, 1.0)),
3118                alarm_limits: Some((9.0, 8.0, -8.0, -9.0)),
3119            })
3120        }
3121    }
3122
3123    #[test]
3124    fn field_metadata_override_applies_on_get_and_monitor_paths() {
3125        let inst = RecordInstance::new("PFM".to_string(), PerFieldMetaRecord);
3126
3127        // VAL: no override — record-level metadata serves it.
3128        let snap = inst.snapshot_for_field("VAL").unwrap();
3129        let d = snap.display.unwrap();
3130        assert_eq!(d.units, "mm");
3131        assert_eq!(d.precision, 3);
3132        assert_eq!(d.upper_disp_limit, 100.0);
3133
3134        // SPD via the GET path: every member patched over the cache.
3135        let snap = inst.snapshot_for_field("SPD").unwrap();
3136        let d = snap.display.unwrap();
3137        assert_eq!(d.units, "mm/sec");
3138        assert_eq!(d.precision, 1);
3139        assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
3140        assert_eq!(
3141            (
3142                d.upper_alarm_limit,
3143                d.upper_warning_limit,
3144                d.lower_warning_limit,
3145                d.lower_alarm_limit
3146            ),
3147            (9.0, 8.0, -8.0, -9.0)
3148        );
3149        let c = snap.control.unwrap();
3150        assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
3151
3152        // SPD via the monitor path: identical override.
3153        let snap = inst.make_monitor_snapshot("SPD", EpicsValue::Double(2.0));
3154        let d = snap.display.unwrap();
3155        assert_eq!(d.units, "mm/sec");
3156        assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
3157        let c = snap.control.unwrap();
3158        assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
3159    }
3160
3161    /// Stub modelling the motor monitor() shape (C motorRecord.cc:
3162    /// 3468-3507): VAL is a setpoint, the MDEL/ADEL deadband tracks
3163    /// the RBV readback, which advances on every process.
3164    struct ReadbackDeadbandRecord {
3165        val: f64,
3166        rbv: f64,
3167        deadband: f64,
3168    }
3169
3170    impl Record for ReadbackDeadbandRecord {
3171        fn record_type(&self) -> &'static str {
3172            "ai"
3173        }
3174        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3175            self.rbv += 30.0;
3176            Ok(crate::server::record::ProcessOutcome::complete())
3177        }
3178        fn get_field(&self, name: &str) -> Option<EpicsValue> {
3179            match name {
3180                "VAL" => Some(EpicsValue::Double(self.val)),
3181                "RBV" => Some(EpicsValue::Double(self.rbv)),
3182                "MDEL" | "ADEL" => Some(EpicsValue::Double(self.deadband)),
3183                _ => None,
3184            }
3185        }
3186        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
3187            match (name, value) {
3188                ("VAL", EpicsValue::Double(v)) => {
3189                    self.val = v;
3190                    Ok(())
3191                }
3192                ("MDEL", EpicsValue::Double(v)) => {
3193                    self.deadband = v;
3194                    Ok(())
3195                }
3196                _ => Err(CaError::FieldNotFound(name.to_string())),
3197            }
3198        }
3199        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3200            &[]
3201        }
3202        fn monitor_deadband_value(&self) -> Option<EpicsValue> {
3203            Some(EpicsValue::Double(self.rbv))
3204        }
3205        fn monitor_deadband_field(&self) -> &'static str {
3206            "RBV"
3207        }
3208    }
3209
3210    /// C motor monitor() parity: MDEL/ADEL throttle the deadband
3211    /// field's (RBV) delivery; VAL posts only when the setpoint
3212    /// actually changed — not on every readback poll.
3213    #[test]
3214    fn deadband_field_routes_readback_and_val_posts_only_on_change() {
3215        use crate::server::recgbl::EventMask;
3216        let mut inst = RecordInstance::new(
3217            "RDB".to_string(),
3218            ReadbackDeadbandRecord {
3219                val: 5.0,
3220                rbv: 0.0,
3221                deadband: 10.0,
3222            },
3223        );
3224        let _val_rx = inst
3225            .add_subscriber(
3226                "VAL",
3227                1,
3228                crate::types::DbFieldType::Double,
3229                EventMask::VALUE.bits(),
3230            )
3231            .expect("VAL subscriber");
3232        let _rbv_rx = inst
3233            .add_subscriber(
3234                "RBV",
3235                2,
3236                crate::types::DbFieldType::Double,
3237                EventMask::VALUE.bits(),
3238            )
3239            .expect("RBV subscriber");
3240        let names = |snap: &ProcessSnapshot| {
3241            snap.changed_fields
3242                .iter()
3243                .map(|(n, _, _)| n.clone())
3244                .collect::<Vec<_>>()
3245        };
3246
3247        // Cycle 1 (first publish): RBV fires via the deadband trigger
3248        // (MLST starts at the NaN never-posted sentinel). VAL must NOT
3249        // post: `add_subscriber` seeded `last_posted` with the current
3250        // value (the initial value already went out with EVENT_ADD), and
3251        // C monitor() posts VAL only when MARKED(M_VAL) — nothing marked
3252        // it.
3253        let (snap, _) = inst.process_local().unwrap();
3254        let n = names(&snap);
3255        assert!(n.contains(&"RBV".to_string()), "{n:?}");
3256        assert!(
3257            !n.contains(&"VAL".to_string()),
3258            "VAL unchanged since subscribe must not post: {n:?}"
3259        );
3260
3261        // Cycle 2: RBV moved past MDEL, VAL unchanged → RBV posted,
3262        // VAL not re-posted.
3263        let (snap, _) = inst.process_local().unwrap();
3264        let n = names(&snap);
3265        assert!(n.contains(&"RBV".to_string()), "RBV crossed MDEL: {n:?}");
3266        assert!(
3267            !n.contains(&"VAL".to_string()),
3268            "unchanged VAL must not post: {n:?}"
3269        );
3270
3271        // Cycle 3: widen the deadband — RBV moves within it → throttled.
3272        let _ = inst.record.put_field("MDEL", EpicsValue::Double(1000.0));
3273        let (snap, _) = inst.process_local().unwrap();
3274        let n = names(&snap);
3275        assert!(
3276            !n.contains(&"RBV".to_string()),
3277            "MDEL must throttle RBV: {n:?}"
3278        );
3279
3280        // Cycle 4: setpoint moves while RBV stays inside the deadband →
3281        // VAL posts via change detection, RBV stays throttled.
3282        let _ = inst.record.put_field("VAL", EpicsValue::Double(42.0));
3283        let (snap, _) = inst.process_local().unwrap();
3284        let n = names(&snap);
3285        assert!(
3286            n.contains(&"VAL".to_string()),
3287            "changed VAL must post: {n:?}"
3288        );
3289        assert!(
3290            !n.contains(&"RBV".to_string()),
3291            "MDEL must throttle RBV: {n:?}"
3292        );
3293    }
3294
3295    /// Record that names DIFF in `force_posted_fields` (the motor's C
3296    /// `process_motor_info` unconditional `MARK(M_DIFF)`) while keeping
3297    /// every value constant — a settled axis parked at a fixed non-zero
3298    /// following error. VAL is a control: not force-listed, so it must
3299    /// fall back to change-detection.
3300    struct ForcePostRecord {
3301        diff: f64,
3302        val: f64,
3303    }
3304
3305    impl Record for ForcePostRecord {
3306        fn record_type(&self) -> &'static str {
3307            "ai"
3308        }
3309        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3310            // Values never change — the readback already matches; only the
3311            // unconditional MARK should keep DIFF flowing.
3312            Ok(crate::server::record::ProcessOutcome::complete())
3313        }
3314        fn get_field(&self, name: &str) -> Option<EpicsValue> {
3315            match name {
3316                "DIFF" => Some(EpicsValue::Double(self.diff)),
3317                "VAL" => Some(EpicsValue::Double(self.val)),
3318                _ => None,
3319            }
3320        }
3321        fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
3322            Err(CaError::FieldNotFound(name.to_string()))
3323        }
3324        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3325            &[]
3326        }
3327        fn force_posted_fields(&self) -> &'static [&'static str] {
3328            &["DIFF"]
3329        }
3330    }
3331
3332    /// C motorRecord parity: `process_motor_info` MARKs M_DIFF/M_RDIF every
3333    /// CALLBACK_DATA pass and `monitor()` posts them with `DBE_VAL_LOG`
3334    /// regardless of change, so a force-posted field re-posts on an
3335    /// otherwise-idle cycle while an unchanged non-force field does not.
3336    #[test]
3337    fn force_posted_field_reposts_unchanged_value_each_cycle() {
3338        use crate::server::recgbl::EventMask;
3339        let mut inst = RecordInstance::new(
3340            "FP".to_string(),
3341            ForcePostRecord {
3342                diff: 2.5,
3343                val: 1.0,
3344            },
3345        );
3346        let _diff_rx = inst
3347            .add_subscriber(
3348                "DIFF",
3349                1,
3350                crate::types::DbFieldType::Double,
3351                EventMask::VALUE.bits(),
3352            )
3353            .expect("DIFF subscriber");
3354        let _val_rx = inst
3355            .add_subscriber(
3356                "VAL",
3357                2,
3358                crate::types::DbFieldType::Double,
3359                EventMask::VALUE.bits(),
3360            )
3361            .expect("VAL subscriber");
3362        let names = |snap: &ProcessSnapshot| {
3363            snap.changed_fields
3364                .iter()
3365                .map(|(n, _, _)| n.clone())
3366                .collect::<Vec<_>>()
3367        };
3368
3369        // Cycle 1 (first publish): both DIFF and VAL post — last_posted is
3370        // empty so change-detection treats every subscribed field as new.
3371        let (snap1, _) = inst.process_local().unwrap();
3372        assert!(
3373            names(&snap1).contains(&"DIFF".to_string()),
3374            "DIFF posts on first publish: {:?}",
3375            names(&snap1)
3376        );
3377
3378        // Cycle 2: nothing changed. VAL (not force-listed) must NOT re-post;
3379        // DIFF (force-listed) MUST re-post — the C unconditional MARK +
3380        // DBE_VAL_LOG. This is the divergence MOT-1 closes.
3381        let (snap2, _) = inst.process_local().unwrap();
3382        assert!(
3383            names(&snap2).contains(&"DIFF".to_string()),
3384            "force-posted DIFF must re-post when unchanged: {:?}",
3385            names(&snap2)
3386        );
3387        assert!(
3388            !names(&snap2).contains(&"VAL".to_string()),
3389            "an unchanged non-force field must not re-post: {:?}",
3390            names(&snap2)
3391        );
3392        // The forced re-post carries DBE_VALUE|DBE_LOG (no alarm bits this
3393        // cycle), matching C `monitor_mask | DBE_VAL_LOG` with monitor_mask=0.
3394        let diff_mask = snap2
3395            .changed_fields
3396            .iter()
3397            .find(|(n, _, _)| n == "DIFF")
3398            .map(|(_, _, m)| *m)
3399            .expect("DIFF post present");
3400        assert_eq!(
3401            diff_mask.bits(),
3402            (EventMask::VALUE | EventMask::LOG).bits(),
3403            "forced re-post mask is DBE_VAL_LOG"
3404        );
3405    }
3406
3407    /// Record that names S1 in `log_swept_fields` (the scaler's idle
3408    /// `monitor()` DBE_LOG sweep) while keeping every value constant. S2
3409    /// is a control: subscribed but NOT swept, so an unchanged S2 must
3410    /// not re-post. Neither field is the primary `VAL`, so the default
3411    /// deadband field resolves to nothing and does not confound the test.
3412    struct LogSweepRecord {
3413        s1: i32,
3414        s2: i32,
3415    }
3416
3417    impl Record for LogSweepRecord {
3418        fn record_type(&self) -> &'static str {
3419            "scaler"
3420        }
3421        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3422            // Counts never change — only the unconditional idle LOG sweep
3423            // should keep S1 flowing to a DBE_LOG (archiver) subscriber.
3424            Ok(crate::server::record::ProcessOutcome::complete())
3425        }
3426        fn get_field(&self, name: &str) -> Option<EpicsValue> {
3427            match name {
3428                "S1" => Some(EpicsValue::Long(self.s1)),
3429                "S2" => Some(EpicsValue::Long(self.s2)),
3430                _ => None,
3431            }
3432        }
3433        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
3434            match (name, value) {
3435                ("S1", EpicsValue::Long(v)) => {
3436                    self.s1 = v;
3437                    Ok(())
3438                }
3439                ("S2", EpicsValue::Long(v)) => {
3440                    self.s2 = v;
3441                    Ok(())
3442                }
3443                _ => Err(CaError::FieldNotFound(name.to_string())),
3444            }
3445        }
3446        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3447            &[]
3448        }
3449        fn log_swept_fields(&self) -> &'static [&'static str] {
3450            &["S1"]
3451        }
3452    }
3453
3454    /// C scalerRecord.c:770-787 `monitor()` sweeps each active channel
3455    /// with a literal `DBE_LOG` on every idle process: an UNCHANGED swept
3456    /// field re-posts with `DBE_LOG` ONLY, while a CHANGED swept field is
3457    /// delivered once by change-detection with `DBE_VALUE|DBE_LOG` (NOT
3458    /// double-posted by the sweep). A non-swept field never re-posts when
3459    /// unchanged. `add_subscriber` seeds `last_posted` with the current
3460    /// value (the initial value goes out via EVENT_ADD), so a freshly
3461    /// subscribed unchanged field already takes the sweep path on cycle 1.
3462    #[test]
3463    fn log_swept_field_reposts_unchanged_with_log_mask_only() {
3464        use crate::server::recgbl::EventMask;
3465        let mut inst = RecordInstance::new("SW".to_string(), LogSweepRecord { s1: 7, s2: 9 });
3466        let _s1_rx = inst
3467            .add_subscriber(
3468                "S1",
3469                1,
3470                crate::types::DbFieldType::Long,
3471                EventMask::LOG.bits(),
3472            )
3473            .expect("S1 subscriber");
3474        let _s2_rx = inst
3475            .add_subscriber(
3476                "S2",
3477                2,
3478                crate::types::DbFieldType::Long,
3479                EventMask::VALUE.bits(),
3480            )
3481            .expect("S2 subscriber");
3482        let names = |snap: &ProcessSnapshot| {
3483            snap.changed_fields
3484                .iter()
3485                .map(|(n, _, _)| n.clone())
3486                .collect::<Vec<_>>()
3487        };
3488        let count_of = |snap: &ProcessSnapshot, f: &str| {
3489            snap.changed_fields
3490                .iter()
3491                .filter(|(n, _, _)| n == f)
3492                .count()
3493        };
3494        let mask_of = |snap: &ProcessSnapshot, f: &str| {
3495            snap.changed_fields
3496                .iter()
3497                .find(|(n, _, _)| n == f)
3498                .map(|(_, _, m)| *m)
3499        };
3500
3501        // Cycle 1: nothing changed since subscribe. S1 (swept) re-posts
3502        // with DBE_LOG ONLY; S2 (not swept) must NOT re-post.
3503        let (snap1, _) = inst.process_local().unwrap();
3504        assert!(
3505            names(&snap1).contains(&"S1".to_string()),
3506            "log-swept S1 must re-post when unchanged: {:?}",
3507            names(&snap1)
3508        );
3509        assert!(
3510            !names(&snap1).contains(&"S2".to_string()),
3511            "unchanged non-swept S2 must not re-post: {:?}",
3512            names(&snap1)
3513        );
3514        assert_eq!(
3515            mask_of(&snap1, "S1").unwrap().bits(),
3516            EventMask::LOG.bits(),
3517            "idle sweep posts DBE_LOG only (no DBE_VALUE)"
3518        );
3519
3520        // Cycle 2: S1's count changed. Change-detection delivers it ONCE
3521        // with DBE_VALUE|DBE_LOG; the sweep does NOT add a second post.
3522        inst.record.put_field("S1", EpicsValue::Long(8)).unwrap();
3523        let (snap2, _) = inst.process_local().unwrap();
3524        assert_eq!(
3525            count_of(&snap2, "S1"),
3526            1,
3527            "a changed swept field posts exactly once (no double-post): {:?}",
3528            snap2.changed_fields
3529        );
3530        assert_eq!(
3531            mask_of(&snap2, "S1").unwrap().bits(),
3532            (EventMask::VALUE | EventMask::LOG).bits(),
3533            "a changed swept field posts VALUE|LOG via change-detection"
3534        );
3535
3536        // Cycle 3: unchanged again — back to the DBE_LOG-only sweep.
3537        let (snap3, _) = inst.process_local().unwrap();
3538        assert_eq!(
3539            mask_of(&snap3, "S1").unwrap().bits(),
3540            EventMask::LOG.bits(),
3541            "unchanged-again S1 returns to the DBE_LOG-only sweep"
3542        );
3543    }
3544
3545    /// Stub record that simulates a record whose process() mutates an
3546    /// internal metadata field. Used to verify that the
3547    /// `Record::took_metadata_change()` hook actually triggers cache
3548    /// invalidation in `process_local()`.
3549    struct MutatingMetaRecord {
3550        val: f64,
3551        egu: String,
3552        took_change: bool,
3553    }
3554
3555    impl Record for MutatingMetaRecord {
3556        fn record_type(&self) -> &'static str {
3557            "ai" // pretend to be ai so populate_display_info populates EGU
3558        }
3559        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3560            // Simulate dynamic metadata change inside processing
3561            self.egu = "kV".into();
3562            self.took_change = true;
3563            Ok(crate::server::record::ProcessOutcome::complete())
3564        }
3565        fn get_field(&self, name: &str) -> Option<EpicsValue> {
3566            match name {
3567                "VAL" => Some(EpicsValue::Double(self.val)),
3568                "EGU" => Some(EpicsValue::String(self.egu.clone().into())),
3569                "PREC" => Some(EpicsValue::Short(0)),
3570                "HOPR" => Some(EpicsValue::Double(0.0)),
3571                "LOPR" => Some(EpicsValue::Double(0.0)),
3572                _ => None,
3573            }
3574        }
3575        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
3576            match (name, value) {
3577                ("VAL", EpicsValue::Double(v)) => {
3578                    self.val = v;
3579                    Ok(())
3580                }
3581                ("EGU", EpicsValue::String(s)) => {
3582                    self.egu = s.as_str_lossy().into_owned();
3583                    Ok(())
3584                }
3585                _ => Err(CaError::FieldNotFound(name.to_string())),
3586            }
3587        }
3588        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3589            &[]
3590        }
3591        fn took_metadata_change(&mut self) -> bool {
3592            let was = self.took_change;
3593            self.took_change = false; // reset after reporting
3594            was
3595        }
3596    }
3597
3598    #[test]
3599    fn process_local_invalidates_cache_on_took_metadata_change() {
3600        let mut inst = RecordInstance::new(
3601            "MUT".to_string(),
3602            MutatingMetaRecord {
3603                val: 1.0,
3604                egu: "V".to_string(),
3605                took_change: false,
3606            },
3607        );
3608
3609        // Build the cache once with the original EGU
3610        let snap1 = inst.snapshot_for_field("VAL").unwrap();
3611        assert_eq!(snap1.display.unwrap().units, "V");
3612        assert!(inst.metadata_cache.lock().unwrap().is_some());
3613
3614        // Run process_local — the stub record sets took_change inside process()
3615        let _ = inst.process_local();
3616
3617        // Cache should now be invalidated (took_metadata_change returned true)
3618        assert!(
3619            inst.metadata_cache.lock().unwrap().is_none(),
3620            "process_local should invalidate cache when took_metadata_change is true"
3621        );
3622
3623        // Next snapshot picks up the new EGU
3624        let snap2 = inst.snapshot_for_field("VAL").unwrap();
3625        assert_eq!(snap2.display.unwrap().units, "kV");
3626    }
3627
3628    /// Stub record that does NOT mutate metadata fields. Verifies the
3629    /// default `took_metadata_change` returns false and the cache stays.
3630    struct StableMetaRecord {
3631        val: f64,
3632    }
3633    impl Record for StableMetaRecord {
3634        fn record_type(&self) -> &'static str {
3635            "ai"
3636        }
3637        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
3638            self.val += 1.0;
3639            Ok(crate::server::record::ProcessOutcome::complete())
3640        }
3641        fn get_field(&self, name: &str) -> Option<EpicsValue> {
3642            match name {
3643                "VAL" => Some(EpicsValue::Double(self.val)),
3644                "EGU" => Some(EpicsValue::String("V".into())),
3645                "PREC" => Some(EpicsValue::Short(0)),
3646                "HOPR" => Some(EpicsValue::Double(0.0)),
3647                "LOPR" => Some(EpicsValue::Double(0.0)),
3648                _ => None,
3649            }
3650        }
3651        fn put_field(&mut self, _: &str, _: EpicsValue) -> CaResult<()> {
3652            Ok(())
3653        }
3654        fn field_list(&self) -> &'static [crate::server::record::FieldDesc] {
3655            &[]
3656        }
3657        // took_metadata_change uses default impl (returns false)
3658    }
3659
3660    #[test]
3661    fn process_local_keeps_cache_when_no_metadata_change() {
3662        let mut inst = RecordInstance::new("STABLE".to_string(), StableMetaRecord { val: 0.0 });
3663
3664        let _ = inst.snapshot_for_field("VAL");
3665        assert!(inst.metadata_cache.lock().unwrap().is_some());
3666
3667        // Run process_local several times — cache should remain intact
3668        let _ = inst.process_local();
3669        assert!(inst.metadata_cache.lock().unwrap().is_some());
3670        let _ = inst.process_local();
3671        assert!(inst.metadata_cache.lock().unwrap().is_some());
3672        let _ = inst.process_local();
3673        assert!(inst.metadata_cache.lock().unwrap().is_some());
3674    }
3675
3676    // ── Regression: DBE_PROPERTY event delivery boundaries ──────────────
3677
3678    /// motor `prop(YES)` fields (motorRecord.dbd 154/161/289/361/368)
3679    /// are property-class: a changed write must post DBE_PROPERTY
3680    /// (C dbAccess.c dbPut, `pfldDes->prop`). They feed the
3681    /// live-computed `field_metadata_override`, not the cache, but the
3682    /// posting gate is this same set.
3683    #[test]
3684    fn motor_prop_yes_fields_are_property_class() {
3685        for f in ["VBAS", "VMAX", "MRES", "DHLM", "DLLM"] {
3686            assert!(is_metadata_field(f), "{f} must be property-class");
3687        }
3688    }
3689
3690    /// Boundary 1: metadata field written with a CHANGED value, subscriber
3691    /// mask includes PROPERTY → subscriber receives an event.
3692    /// Mirrors C dbAccess.c:1396-1397 `db_post_events(precord,NULL,DBE_PROPERTY)`.
3693    #[test]
3694    fn r47_property_event_delivered_on_changed_metadata() {
3695        use crate::server::recgbl::EventMask;
3696        let mut inst = ai_instance();
3697        let mut rx = inst
3698            .add_subscriber(
3699                "VAL",
3700                1,
3701                crate::types::DbFieldType::Double,
3702                EventMask::PROPERTY.bits(),
3703            )
3704            .expect("subscriber added");
3705
3706        let prev = inst.record.get_field("EGU"); // "degC"
3707        let _ = inst
3708            .record
3709            .put_field("EGU", EpicsValue::String("kPa".into()));
3710        inst.notify_field_written_if_changed("EGU", prev.as_ref());
3711
3712        assert!(
3713            rx.try_recv().is_ok(),
3714            "PROPERTY subscriber must receive event when metadata field changes"
3715        );
3716    }
3717
3718    /// Boundary 2: same metadata field written with the SAME value → NO event.
3719    /// Matches C suppression at dbAccess.c:1379-1383 and the `prev != now` gate.
3720    #[test]
3721    fn r47_no_event_on_unchanged_metadata() {
3722        use crate::server::recgbl::EventMask;
3723        let mut inst = ai_instance();
3724        let mut rx = inst
3725            .add_subscriber(
3726                "VAL",
3727                1,
3728                crate::types::DbFieldType::Double,
3729                EventMask::PROPERTY.bits(),
3730            )
3731            .expect("subscriber added");
3732
3733        let prev = inst.record.get_field("EGU"); // "degC"
3734        // Write the same value — no change
3735        let _ = inst.record.put_field("EGU", prev.clone().unwrap());
3736        inst.notify_field_written_if_changed("EGU", prev.as_ref());
3737
3738        assert!(
3739            rx.try_recv().is_err(),
3740            "PROPERTY subscriber must NOT receive event when metadata value is unchanged"
3741        );
3742    }
3743
3744    /// Boundary 3: VALUE-only subscriber (no PROPERTY bit) receives NO event
3745    /// from a metadata write, even when the field value changed.
3746    #[test]
3747    fn r47_value_only_subscriber_no_event_on_metadata_write() {
3748        use crate::server::recgbl::EventMask;
3749        let mut inst = ai_instance();
3750        let mut rx = inst
3751            .add_subscriber(
3752                "VAL",
3753                1,
3754                crate::types::DbFieldType::Double,
3755                EventMask::VALUE.bits(),
3756            )
3757            .expect("subscriber added");
3758
3759        let prev = inst.record.get_field("EGU"); // "degC"
3760        let _ = inst
3761            .record
3762            .put_field("EGU", EpicsValue::String("kPa".into()));
3763        inst.notify_field_written_if_changed("EGU", prev.as_ref());
3764
3765        assert!(
3766            rx.try_recv().is_err(),
3767            "VALUE-only subscriber must NOT receive event from a metadata write"
3768        );
3769    }
3770
3771    /// Boundary 4 (took_metadata_change path): PROPERTY subscriber receives
3772    /// event after process_local() when the record reports a metadata change.
3773    #[test]
3774    fn r47_process_local_property_event_on_took_metadata_change() {
3775        use crate::server::recgbl::EventMask;
3776        let mut inst = RecordInstance::new(
3777            "MUT2".to_string(),
3778            MutatingMetaRecord {
3779                val: 1.0,
3780                egu: "V".to_string(),
3781                took_change: false,
3782            },
3783        );
3784        let mut rx = inst
3785            .add_subscriber(
3786                "VAL",
3787                1,
3788                crate::types::DbFieldType::Double,
3789                EventMask::PROPERTY.bits(),
3790            )
3791            .expect("subscriber added");
3792
3793        // process() sets took_change = true and updates egu to "kV"
3794        let _ = inst.process_local();
3795
3796        assert!(
3797            rx.try_recv().is_ok(),
3798            "PROPERTY subscriber must receive event after process_local reports took_metadata_change"
3799        );
3800    }
3801}
3802
3803#[cfg(test)]
3804mod aftc_filter_tests {
3805    //! Tests for the shared AFTC alarm-range filter
3806    //! (`records::alarm_filter::aftc_filter`) as driven by
3807    //! `evaluate_analog_alarm`. Pure-function tests: no record instance
3808    //! needed — the filter is a stateless transform of (raw_alarm, aftc,
3809    //! afvl_in, t_last, t_now). Algorithm provenance: 2009 EPICS
3810    //! Codeathon (epics-base `824d37811`), C `aiRecord.c:355-401`.
3811
3812    use crate::server::records::alarm_filter::aftc_filter;
3813    use std::time::{Duration, SystemTime};
3814
3815    fn at(secs: f64) -> SystemTime {
3816        SystemTime::UNIX_EPOCH + Duration::from_secs_f64(secs)
3817    }
3818
3819    #[test]
3820    fn disabled_when_aftc_le_zero() {
3821        // aftc=0 means filter disabled — pass-through.
3822        let (out, afvl) = aftc_filter(2, 0.0, 0.0, at(0.0), at(1.0));
3823        assert_eq!(out, 2);
3824        assert_eq!(afvl, 0.0);
3825    }
3826
3827    #[test]
3828    fn initial_sample_seeds_state_unchanged_alarm() {
3829        // afvl=0 means first sample after enable — alarm passes through
3830        // and accumulator seeds with the raw severity.
3831        let (out, afvl) = aftc_filter(2, 3.0, 0.0, at(0.0), at(0.5));
3832        assert_eq!(out, 2);
3833        assert_eq!(afvl, 2.0);
3834    }
3835
3836    #[test]
3837    fn raises_alarm_only_after_full_time_constant() {
3838        // Single-step heuristic: with `aftc = 3s` and `dt = 0.1s`, alpha
3839        // ≈ 0.967, so a one-shot raw_alarm=2 against afvl=0.0 should not
3840        // produce alarm=2 yet — the filter must hold off until the
3841        // accumulator crosses the threshold.
3842        // Seed with afvl=0.01 (tiny prior, simulating "almost no alarm
3843        // yet"); the filter must keep alarm at 0 after one short tick.
3844        let (out, afvl) = aftc_filter(2, 3.0, 0.01, at(0.0), at(0.1));
3845        assert_eq!(out, 0, "filter should suppress alarm rise on a 0.1s tick");
3846        assert!(afvl > 0.0 && afvl < 2.0);
3847    }
3848
3849    #[test]
3850    fn dt_zero_is_no_op() {
3851        // Two evaluations at the same instant produce no filter advance.
3852        let (out, afvl) = aftc_filter(2, 3.0, 1.5, at(0.0), at(0.0));
3853        assert_eq!(out, 1); // floor(|1.5|) = 1
3854        assert_eq!(afvl, 1.5);
3855    }
3856
3857    #[test]
3858    fn long_steady_state_converges_to_alarm() {
3859        // After many steps with raw_alarm=2 and dt much smaller than aftc,
3860        // the accumulator must converge towards 2.
3861        let aftc = 1.0;
3862        let mut afvl = 0.0;
3863        let mut last = at(0.0);
3864        let mut alarm = 0;
3865        for i in 1..=100 {
3866            let now = at(i as f64 * 0.05);
3867            let (out, new_afvl) = aftc_filter(2, aftc, afvl, last, now);
3868            alarm = out;
3869            afvl = new_afvl;
3870            last = now;
3871        }
3872        assert_eq!(
3873            alarm, 2,
3874            "after 5 s of steady raw=2 with aftc=1 s, output must reach 2"
3875        );
3876        assert!(afvl.abs() >= 1.99 && afvl.abs() <= 2.0);
3877    }
3878}
3879
3880#[cfg(test)]
3881mod check_deadband_tests {
3882    use super::check_deadband;
3883
3884    /// Sentinel: `oldval=NaN` means "no prior posting", always fire.
3885    #[test]
3886    fn nan_old_value_fires() {
3887        assert!(check_deadband(0.0, f64::NAN, 1.0));
3888        assert!(check_deadband(f64::NAN, f64::NAN, 1.0));
3889    }
3890
3891    /// C path: `delta > deadband` with both finite. delta within deadband
3892    /// must NOT fire.
3893    #[test]
3894    fn within_finite_deadband_does_not_fire() {
3895        assert!(!check_deadband(10.0, 10.5, 1.0));
3896        assert!(!check_deadband(10.0, 9.5, 1.0));
3897        // Boundary: `delta == deadband` is NOT strictly greater.
3898        assert!(!check_deadband(10.0, 11.0, 1.0));
3899    }
3900
3901    /// `delta > deadband` with both finite, beyond → fire.
3902    #[test]
3903    fn beyond_finite_deadband_fires() {
3904        assert!(check_deadband(10.0, 12.0, 1.0));
3905    }
3906
3907    /// Negative deadband acts as "always fire" (C `delta > deadband` is
3908    /// trivially true for any non-negative delta).
3909    #[test]
3910    fn negative_deadband_fires() {
3911        assert!(check_deadband(10.0, 10.0, -1.0));
3912    }
3913
3914    /// C parity bug fix (recGbl.c:355-358): exactly one of {newval,
3915    /// oldval} is NaN — fire. Rust port previously short-circuited only
3916    /// on `oldval=NaN`; `newval=NaN` with `oldval=finite` produced
3917    /// `(NaN - finite).abs() = NaN`, `NaN > deadband = false` →
3918    /// silently dropped the NaN transition. End effect: a record that
3919    /// went UDF (e.g. divide-by-zero in calc) never posted the change
3920    /// to monitors, leaving every camonitor seeing the last valid value.
3921    #[test]
3922    fn newval_nan_with_finite_oldval_fires() {
3923        assert!(check_deadband(f64::NAN, 10.0, 1.0));
3924    }
3925
3926    /// C path case 2 (recGbl.c:355): exactly one infinite, the other
3927    /// finite — fire.
3928    #[test]
3929    fn one_finite_one_infinite_fires() {
3930        assert!(check_deadband(f64::INFINITY, 10.0, 1.0));
3931        assert!(check_deadband(10.0, f64::INFINITY, 1.0));
3932        assert!(check_deadband(f64::NEG_INFINITY, 10.0, 1.0));
3933    }
3934
3935    /// C path case 3 (recGbl.c:360-362): both infinite with opposite
3936    /// signs — fire.
3937    #[test]
3938    fn opposite_signed_infinities_fire() {
3939        assert!(check_deadband(f64::INFINITY, f64::NEG_INFINITY, 1.0));
3940        assert!(check_deadband(f64::NEG_INFINITY, f64::INFINITY, 1.0));
3941    }
3942
3943    /// Same-signed infinity → no fire (C path leaves `delta = 0`,
3944    /// `0 > deadband` is false for any non-negative deadband).
3945    #[test]
3946    fn same_signed_infinity_does_not_fire() {
3947        assert!(!check_deadband(f64::INFINITY, f64::INFINITY, 1.0));
3948        assert!(!check_deadband(f64::NEG_INFINITY, f64::NEG_INFINITY, 1.0));
3949    }
3950}
3951
3952#[cfg(test)]
3953mod common_field_dbload_tests {
3954    use super::*;
3955    use crate::server::records::ai::AiRecord;
3956
3957    /// The db loader feeds every common field to `put_common_field` as an
3958    /// `EpicsValue::String`. Each numeric/menu common field directive must
3959    /// take effect at load — both the integer form (`field(PHAS, "1")`) and
3960    /// the menu-label form (`field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`) —
3961    /// rather than being silently dropped because the arm matched only its
3962    /// typed variant. One assertion per affected common-field arm.
3963    #[test]
3964    fn db_loaded_string_common_fields_take_effect() {
3965        let mut inst = RecordInstance::new("REC".to_string(), AiRecord::default());
3966        let put = |inst: &mut RecordInstance, f: &str, v: &str| {
3967            inst.put_common_field(f, EpicsValue::String(v.into()))
3968                .unwrap_or_else(|e| panic!("put_common_field({f}, {v:?}) failed: {e}"));
3969        };
3970
3971        // Integer-valued directives.
3972        put(&mut inst, "PHAS", "1");
3973        assert_eq!(inst.common.phas, 1, "field(PHAS, \"1\")");
3974        put(&mut inst, "TSE", "-2");
3975        assert_eq!(inst.common.tse, -2, "field(TSE, \"-2\")");
3976        put(&mut inst, "DISV", "1");
3977        assert_eq!(inst.common.disv, 1, "field(DISV, \"1\")");
3978        put(&mut inst, "DISA", "1");
3979        assert_eq!(inst.common.disa, 1, "field(DISA, \"1\")");
3980        put(&mut inst, "LCNT", "3");
3981        assert_eq!(inst.common.lcnt, 3, "field(LCNT, \"3\")");
3982        put(&mut inst, "DISP", "1");
3983        assert!(inst.common.disp, "field(DISP, \"1\")");
3984        put(&mut inst, "UDF", "0");
3985        assert!(!inst.common.udf, "field(UDF, \"0\")");
3986
3987        // Menu-label directives (resolved via resolve_menu_string).
3988        put(&mut inst, "PRIO", "HIGH");
3989        assert_eq!(inst.common.prio, 2, "field(PRIO, \"HIGH\")");
3990        put(&mut inst, "DISS", "MAJOR");
3991        assert_eq!(
3992            inst.common.diss,
3993            AlarmSeverity::Major,
3994            "field(DISS, \"MAJOR\")"
3995        );
3996        put(&mut inst, "UDFS", "NO_ALARM");
3997        assert_eq!(
3998            inst.common.udfs,
3999            AlarmSeverity::NoAlarm,
4000            "field(UDFS, \"NO_ALARM\")"
4001        );
4002        put(&mut inst, "ACKT", "NO");
4003        assert!(!inst.common.ackt, "field(ACKT, \"NO\")");
4004
4005        // Numeric form of a menu field still works (field(PRIO, "0")).
4006        put(&mut inst, "PRIO", "0");
4007        assert_eq!(inst.common.prio, 0, "field(PRIO, \"0\")");
4008
4009        // A String-typed common field is untouched by the coercion.
4010        put(&mut inst, "DESC", "a description");
4011        assert_eq!(inst.common.desc.as_str_lossy().as_ref(), "a description");
4012    }
4013}