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