Skip to main content

epics_base_rs/server/record/
record_instance.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::sync::Arc;
4use std::sync::Mutex as StdMutex;
5use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
6
7use crate::error::{CaError, CaResult};
8use crate::server::database::LinkBacking;
9use crate::server::event_queue::{EventReader, EventUser};
10use crate::server::pv::{MonitorEvent, Subscriber};
11use crate::server::recgbl::EventMask;
12use crate::server::snapshot::{
13    ControlInfo, DisplayInfo, EnumInfo, EnumStringForm, PropertySupport,
14};
15use crate::types::c_parse::Converted;
16use crate::types::{DbFieldType, EpicsValue, PvString, c_parse};
17
18use super::alarm::{AlarmLimit, AlarmSeverity, AnalogAlarmConfig};
19use super::common_fields::{BkptFlag, CommonFields};
20use super::link::{
21    ParsedLink, out_link_discards_cp, parse_forward_link_v2, parse_link_v2, parse_output_link_v2,
22};
23use super::menu_choices::MenuBound;
24use super::record_trait::{
25    AuxPostMask, CommonFieldPutResult, FieldDeclaration, FieldDesc, InputLinkRequest, LinkReadAs,
26    ProcessSnapshot, Record, RecordProcessResult, SubroutineFn,
27};
28use super::scan::{ScanType, SimModeScan};
29
30/// C `msstring[4]` (`dbStaticLib.c:61`) — the maximize-severity word
31/// `dbGetString` appends to a link and `dblsr` prints in its own column.
32pub(crate) fn monitor_switch_word(switch_: super::MonitorSwitch) -> &'static str {
33    use super::MonitorSwitch::*;
34    match switch_ {
35        NoMaximize => "NMS",
36        Maximize => "MS",
37        MaximizeIfInvalid => "MSI",
38        MaximizeStatus => "MSS",
39    }
40}
41
42/// C `dbGetString`'s three link branches (`dbStaticLib.c:1906-2050`) — how a
43/// link field READS, as opposed to how it is stored.
44///
45/// C keeps a link field as a parsed `struct link` in record memory and renders
46/// it on every read, so the modifiers a `.db` left out come back as their
47/// defaults: `L:B` in an `INP` reads `L:B NPP NMS`, and `L:B PP MS` in a
48/// `FLNK` reads `L:B`, because `DBF_FWDLINK` carries no process class and no
49/// severity switch. Measured against `softIoc` R7.0.10-146 over CA.
50///
51/// This port stores the text instead, so the rendering happens on the way out.
52/// It is applied once, in [`RecordInstance::resolve_field`], and never in a
53/// printer: the CA server, `dbgf`, `dbpf`'s read-back and `dbpr` all read
54/// through that funnel, and a rule kept in three printers is a rule the fourth
55/// reader does not get.
56///
57/// The target is the slice before the FIRST space rather than a name rebuilt
58/// from the parse, because that is what C stores: `dbParseLink` splits there
59/// and keeps the head verbatim in `pv_link.pvname`. `X.VAL` therefore prints
60/// as `X.VAL`, not as the `X` a round trip through `DbLink::channel_name`
61/// would produce.
62///
63/// Only PV/DB/CA links carry modifiers, so every other link type falls through
64/// to its own text — CONSTANT prints `constantStr` (`:1911-1917`), JSON_LINK
65/// the JSON (`:1927`). Hardware links are the exception that is not a
66/// fall-through: C stores them as numbers and re-renders them per bus
67/// (`:1953-2006`), so this funnel asks [`HwLink::render`](super::HwLink::render)
68/// rather than echoing the field text, and `#C0x10 S-2` reads back as
69/// `#C16 S-2 @`.
70///
71/// Rendering is idempotent under the parser — `L:B NPP NMS` parses to the link
72/// `L:B` does — so a consumer that re-parses what a reader saw gets the link
73/// the store holds.
74pub(crate) fn render_link_field(class: crate::types::DbfLinkClass, raw: &str) -> String {
75    use super::{LinkFieldType, LinkProcessPolicy};
76    use crate::types::DbfLinkClass;
77
78    let text = raw.trim();
79    let ftype = LinkFieldType::for_class(class);
80    // The parse already applied C's per-field-type modifier mask
81    // (`dbStaticLib.c:2380-2391`), so a `DBF_FWDLINK` reaching the arm below
82    // has had everything but `CA` cleared and cannot render a stale ` MS`.
83    let (policy, ms, ca_class) = match super::parse_link_field(text, ftype) {
84        ParsedLink::Db(link) => (link.policy, link.monitor_switch, false),
85        ParsedLink::Ca(link) => (link.policy, link.monitor_switch, true),
86        // The store holds the parsed bus numbers, so the text comes from
87        // them and from nowhere else.
88        ParsedLink::Hw(hw) => return hw.render(),
89        _ => return text.to_string(),
90    };
91    let target = text.split_once(' ').map_or(text, |(head, _)| head);
92
93    // A forward link prints its target and, alone among the modifiers, ` CA`
94    // (`dbStaticLib.c:2034-2044`): no process class and no maximize-severity
95    // switch, which is why C answers a bare `FLNK` with just the record name.
96    if matches!(class, DbfLinkClass::FwdLink) {
97        return if ca_class {
98            format!("{target} CA")
99        } else {
100            target.to_string()
101        };
102    }
103
104    // C's `ppind` chain (`:1938-1943`) tests `PP` before `CA`, so a `ca://`
105    // link that also asked for `PP` renders ` PP`; and a `CP`/`CPP` link that
106    // resolved to a CA channel still renders its own class, because C reads
107    // `pvlMask` and not the type the link ended up with.
108    let pp = if ca_class && policy == LinkProcessPolicy::NoProcess {
109        " CA"
110    } else {
111        match policy {
112            LinkProcessPolicy::NoProcess => " NPP",
113            LinkProcessPolicy::ProcessPassive => " PP",
114            LinkProcessPolicy::ChannelProcess => " CP",
115            LinkProcessPolicy::ChannelProcessPassive => " CPP",
116        }
117    };
118    format!("{target}{pp} {}", monitor_switch_word(ms))
119}
120
121/// Every client-visible `special(SPC_NOMOD)` field of `dbCommon.dbd:13-190`.
122///
123/// These are common fields — no record's `field_list` declares them — so the
124/// declaration names them here. The remaining `SPC_NOMOD` entries in
125/// `dbCommon.dbd` are `DBF_NOACCESS` ([`is_dbcommon_noaccess`]): they have
126/// no field API in this port at all.
127///
128/// TIME is `DBF_NOACCESS` in C, and so it is here — [`FieldDesc::unreadable`]
129/// refuses the read. It is still named here because `SPC_NOMOD` is a fact about
130/// the declaration, not about readability: C's `dbCommon.dbd` marks it
131/// `special(SPC_NOMOD)` and every write path must see that whether or not any
132/// read path ever succeeds.
133///
134/// [`FieldDesc::unreadable`]: super::FieldDesc::unreadable
135///
136/// Read only through [`RecordInstance::is_no_mod`].
137const DBCOMMON_NOMOD: &[&str] = &[
138    "NAME", "STAT", "SEVR", "AMSG", "NSTA", "NSEV", "NAMSG", "ACKS", "ACKT", "LCNT", "PACT",
139    "PUTF", "RPRO", "TIME", "UTAG",
140];
141
142/// Is `field` (already uppercased) a `dbCommon` `DBF_NOACCESS` internal —
143/// a name C resolves but never serves?
144///
145/// These are C-internal pointers with no value API in this port. Their NAMES
146/// still exist to C's resolver: `dbNameToAddr` resolves a `DBF_NOACCESS`
147/// field and the refusal lands at channel *creation*, where `mapDBFToDBR`
148/// yields `DBR_NOACCESS` — measured against `softIocPVX`:
149/// `pvxget ORACLE:AI.MLOK` → `Refused to create Channel`, i.e. the SEARCH
150/// was answered. The search gate (`PvDatabase::has_name_no_resolve`)
151/// consults this — via [`RecordInstance::resolves_noaccess_name`] — so those
152/// names keep answering; every *value* path stays closed to them.
153///
154/// The name list is the generated spec
155/// ([`DB_COMMON_NOACCESS`](super::dbd_generated::DB_COMMON_NOACCESS)) minus
156/// [`DBCOMMON_NOMOD`], and it holds only the rows the generator could state no
157/// width for. `BKPT` and `TIME` are NOT in it: their `extra(...)` names a plain
158/// scalar, so the generator carries the whole descriptor and the search gate is
159/// answered by its `field_desc` arm instead. Both arms answer the SEARCH; which
160/// one does is a property of the declaration, not of this function.
161pub(crate) fn is_dbcommon_noaccess(field: &str) -> bool {
162    super::dbd_generated::DB_COMMON_NOACCESS.contains(&field) && !DBCOMMON_NOMOD.contains(&field)
163}
164
165thread_local! {
166    /// The origin tag applied to every event posted from the current
167    /// thread's synchronous put+process cascade when the poster itself
168    /// passes origin 0. Set only by [`AmbientWriteOriginScope`], read only
169    /// by [`RecordInstance::notify_field_with_origin`]. An in-process
170    /// writer (a ported SNL state machine) uses this so the whole
171    /// synchronous consequence of its put — the direct field post AND the
172    /// process-cycle posts, FLNK cascade included — carries its origin and
173    /// is filtered from its own subscriptions, while posts from work the
174    /// cascade merely *spawned* (a motor poller on another task) stay
175    /// untagged and visible to it.
176    static AMBIENT_WRITE_ORIGIN: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
177}
178
179/// RAII scope for `AMBIENT_WRITE_ORIGIN`. Sound only around code with no
180/// `.await` inside: the tag is thread-local, so crossing an await point
181/// would both leak it to interleaved tasks and lose it on work-stealing.
182/// The put paths that use it (`put_record_field_from_ca_no_notify_with_origin`)
183/// wrap a fully synchronous body.
184pub struct AmbientWriteOriginScope {
185    prev: u64,
186}
187
188/// Enter an ambient-origin scope; the previous value is restored on drop
189/// (scopes nest).
190pub fn ambient_write_origin_scope(origin: u64) -> AmbientWriteOriginScope {
191    let prev = AMBIENT_WRITE_ORIGIN.with(|c| c.replace(origin));
192    AmbientWriteOriginScope { prev }
193}
194
195impl Drop for AmbientWriteOriginScope {
196    fn drop(&mut self) {
197        AMBIENT_WRITE_ORIGIN.with(|c| c.set(self.prev));
198    }
199}
200
201/// The current thread's ambient write origin (0 outside any scope).
202/// `pub(crate)` so the simple-PV posting funnel
203/// (`ProcessVariable::deliver`) applies the same inheritance rule as the
204/// two record funnels in this file.
205pub(crate) fn ambient_write_origin() -> u64 {
206    AMBIENT_WRITE_ORIGIN.with(|c| c.get())
207}
208
209/// Put-notify completion wait-set — the C `dbNotify.c` `processNotify`
210/// waitList analogue (`dbNotifyAdd` / `dbNotifyCompletion`).
211///
212/// A `ca_put_callback` / WRITE_NOTIFY completion must fire only after the
213/// originating (put-target) record AND every record reached through its
214/// FLNK / OUT / process-action dispatch chain (synchronous *or* async)
215/// has finished processing. A single wait-set owns the completion
216/// oneshot; only it fires, and only when the last chain member leaves.
217///
218/// Counting convention: [`Self::new`] arms `pending = 1` for the
219/// originating record (which always joins). Every additional PP target
220/// that will process under the active notify [`Self::enter`]s on join
221/// (C `dbNotifyAdd`), and every record [`Self::leave`]s when its
222/// processing completes (C `dbNotifyCompletion`). The oneshot fires on
223/// the `leave` that drops `pending` to zero.
224///
225/// Membership is BOTH counted and named, because the two questions have
226/// different answers here and C only ever has to ask one of them. C keeps
227/// no count at all — `ellCount(&pnotifyPvt->waitList)` (`dbNotify.c:460`)
228/// IS its count, so its list answers "has the chain settled?" and "which
229/// records must a cancel release?" at once. The port cannot merge them:
230/// `pending` also carries contributions that own no record slot (the
231/// initiator's own hold, and a `dbCaPutLinkCallback` awaiting its network
232/// completion — `links.rs`), which C models as `state` rather than as list
233/// entries. So `pending` answers settlement and the `joined` list answers
234/// cancellation, each with one meaning on every path.
235pub struct NotifyWaitSet {
236    pending: AtomicUsize,
237    /// C `notifyPvt::waitList` (`dbNotify.c:73`) — every record that took
238    /// this set into its `notify` slot, so a cancel can sweep them the way
239    /// `dbNotifyCancel` walks the wait list (`:428-430`).
240    ///
241    /// Append-only, and deliberately so: C deletes a member on completion
242    /// only because `ellCount` is its settlement count, which `pending`
243    /// already is here. Keeping a completed member listed costs a slot
244    /// re-check and cannot lose one, whereas removing on completion would
245    /// make the list the second thing that has to be right for a cancel to
246    /// reach a record.
247    ///
248    /// Names, not handles: the sweep needs the record's write lock anyway,
249    /// and the authority on membership is the record's own slot — a stale
250    /// name simply fails the `Arc::ptr_eq` and is skipped. That makes
251    /// over-listing harmless and under-listing the only failure, which is
252    /// what the append-only rule then makes unrepresentable.
253    ///
254    /// Written in exactly one place, [`RecordInstance::take_notify_slot`],
255    /// which is also the only writer of the slot itself — so "every record
256    /// holding this set is named here" holds by construction.
257    joined: StdMutex<Vec<Box<str>>>,
258    tx: StdMutex<Option<crate::runtime::sync::oneshot::Sender<()>>>,
259    /// C `dbChannelRecord(ppn->chan)` — the record the notify was ISSUED
260    /// against, as opposed to the records that joined its chain.
261    ///
262    /// `None` for a set that is not a `processNotify` at all: the
263    /// completion accounting [`PvDatabase::new_put_notify`] arms for a
264    /// downstream link put has no `chan`, so C has no such record and
265    /// `dbNotifyDump` has no block to print for it.
266    ///
267    /// Set at construction and never afterwards. The only mint of an
268    /// entry-bearing set is [`RecordInstance::install_or_queue_notify`],
269    /// which passes its own record, and the other path into the slot
270    /// ([`RecordInstance::join_put_notify`]) clones a set it did not make —
271    /// so "the entry names the record whose slot minted it" holds by
272    /// construction rather than by a check.
273    ///
274    /// It is NOT the membership test. Every member, entry or joined, is named
275    /// in [`Self::joined`]; this only says which member `dbNotifyDump` prints
276    /// a block for (`dbNotify.c:659-660`).
277    ///
278    /// [`PvDatabase::new_put_notify`]: crate::server::database::PvDatabase::new_put_notify
279    entry: Option<Box<str>>,
280}
281
282impl NotifyWaitSet {
283    /// Arm a wait-set whose `tx` fires when the chain settles. `pending`
284    /// starts at 1 for the originating record — its completion `leave`s
285    /// that implicit slot, so a put with no chain targets fires
286    /// immediately on the originating record's own completion.
287    ///
288    /// No entry record: this is the chain-internal set, C's `dbNotifyAdd`
289    /// bookkeeping without a `processNotify` of its own.
290    /// `Self::for_entry_record` is the `dbProcessNotify` arm.
291    pub fn new(tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
292        Arc::new(Self {
293            pending: AtomicUsize::new(1),
294            joined: StdMutex::new(Vec::new()),
295            tx: StdMutex::new(Some(tx)),
296            entry: None,
297        })
298    }
299
300    /// C `dbProcessNotify` (`dbNotify.c:196-270`): the put named `record`, so
301    /// `dbChannelRecord(ppn->chan)` is `record` and that is the one record
302    /// `dbNotifyDump` prints a block for.
303    fn for_entry_record(record: &str, tx: crate::runtime::sync::oneshot::Sender<()>) -> Arc<Self> {
304        Arc::new(Self {
305            pending: AtomicUsize::new(1),
306            joined: StdMutex::new(Vec::new()),
307            tx: StdMutex::new(Some(tx)),
308            entry: Some(record.into()),
309        })
310    }
311
312    /// The record this notify was issued against, or `None` for a set with
313    /// no `processNotify` behind it. See [`Self::entry`].
314    pub(crate) fn entry_record(&self) -> Option<&str> {
315        self.entry.as_deref()
316    }
317
318    /// A PP target joined the chain (C `dbNotifyAdd`). Balanced by exactly
319    /// one [`Self::leave`].
320    pub fn enter(&self) {
321        self.pending.fetch_add(1, Ordering::AcqRel);
322    }
323
324    /// A record finished its contribution (C `dbNotifyCompletion`). Fires
325    /// the completion oneshot on the `leave` that empties the set.
326    pub fn leave(&self) {
327        let prev = self.pending.fetch_sub(1, Ordering::AcqRel);
328        debug_assert!(prev >= 1, "NotifyWaitSet::leave underflow");
329        if prev == 1 {
330            if let Some(tx) = self.tx.lock().unwrap().take() {
331                let _ = tx.send(());
332            }
333        }
334    }
335
336    /// True once every chain member has left (the completion has fired).
337    /// Used by the put entry to decide synchronous ([`ProcessCompletion::Sync`])
338    /// vs async-pending ([`ProcessCompletion::Async`]) completion.
339    pub fn completed(&self) -> bool {
340        self.pending.load(Ordering::Acquire) == 0
341    }
342
343    /// Record `name` took this set into its `notify` slot — C
344    /// `ellSafeAdd(&pnotifyPvt->waitList, &precord->ppnr->waitNode)`
345    /// (`dbNotify.c:227`/`:258`/`:498`).
346    ///
347    /// Private to this module and called from the one slot writer
348    /// ([`RecordInstance::take_notify_slot`]), so joining the list and taking
349    /// the slot are one act and cannot be done separately.
350    fn record_joined(&self, name: &str) {
351        self.joined.lock().unwrap().push(name.into());
352    }
353
354    /// Every record that has held this set — C's wait list as
355    /// `dbNotifyCancel` enumerates it (`dbNotify.c:428`).
356    ///
357    /// A snapshot, because the sweep must take record write locks and cannot
358    /// hold this mutex while it does. Growth after the snapshot is not a
359    /// missed member: a record can only join a set that is still answerable,
360    /// and this is read only of a set that is not.
361    pub(crate) fn joined_records(&self) -> Vec<Box<str>> {
362        self.joined.lock().unwrap().clone()
363    }
364
365    /// Nobody is left to answer: the completion never fired (the sender is
366    /// still here) and the client that would have received it is gone.
367    ///
368    /// This is the condition C detects at client teardown —
369    /// `rsrvFreePutNotify` sees `pNotify->busy` and calls `dbNotifyCancel`
370    /// (`camessage.c:1630-1638`). A set that already fired holds no sender and
371    /// is NOT unanswerable: it completed.
372    pub(crate) fn is_unanswerable(&self) -> bool {
373        self.tx
374            .lock()
375            .unwrap()
376            .as_ref()
377            .is_some_and(|tx| tx.is_closed())
378    }
379}
380
381/// The completion outcome of an externally-initiated record process cycle —
382/// the value a caller learns after driving the synchronous head of a
383/// `dbPutNotify` / CA `WRITE_NOTIFY`.
384///
385/// This is the contract the **RTEMS CA driver** consumes: the CA thread drives
386/// the synchronous head of a put (C `dbProcessNotify`, `rsrv/camessage.c`
387/// `write_notify_action`) to completion — on RTEMS via `park_on` — then
388/// `match`es this value to decide whether to reply inline or return now and let
389/// background infrastructure deliver the completion later. The caller learns
390/// sync-vs-async as a typed value, not by inferring it from `Option::is_some`.
391///
392/// # C parity (`dbNotify.c`)
393///
394/// The C `processNotify` state machine forks a put-notify exactly here:
395///
396/// * **[`Self::Sync`]** — the record was neither active (`pact`) nor selected
397///   for processing, so `processNotifyCommon` runs `callDone`
398///   (`dbNotify.c:270`), which fires `doneCallback` INLINE on the calling
399///   thread (`dbNotify.c:182`). Our fully-synchronous chain drains the
400///   [`NotifyWaitSet`] before the put entry returns.
401/// * **[`Self::Async`]** — the record was `pact` (`notifyRestartInProgress`,
402///   `dbNotify.c:225-231`) or processed into an async device
403///   (`notifyProcessInProgress`, `dbNotify.c:252-263`). Completion is deferred
404///   to `dbNotifyCompletion` (`dbNotify.c:445-475`), which fires the user
405///   callback via `callbackRequest` (`:466`/`:470`) when the tracked waitList
406///   empties. Our [`NotifyWaitSet::leave`]-to-zero fires the `handle` oneshot
407///   at that same moment.
408///
409/// # Invariant (by construction)
410///
411/// Exactly one of {`Sync` returned, the `Async` handle fires exactly once} per
412/// initiated cycle. The single owner of the fire is [`NotifyWaitSet`]: its
413/// `leave`-to-zero `take`s the oneshot sender and sends once, so the handle can
414/// never fire twice; and `Sync` is returned only when the wait-set already
415/// drained, so no handle is outstanding to fire. There is no parallel
416/// signalling path — the oneshot is the sole completion channel.
417#[derive(Debug)]
418pub enum ProcessCompletion {
419    /// The cycle settled within the calling thread — the caller replies inline.
420    Sync,
421    /// The cycle went async; `handle` fires exactly once when the tracked
422    /// FLNK/OUT chain settles (C `dbNotifyCompletion`).
423    Async(crate::runtime::sync::oneshot::Receiver<()>),
424}
425
426impl ProcessCompletion {
427    /// Build the outcome from the wait-set's internal signal. `None` — the
428    /// wait-set drained synchronously, or the completion receiver lives
429    /// elsewhere (a deferred-restart replay carries only the sender) — is
430    /// [`Self::Sync`]; `Some(rx)` is [`Self::Async`].
431    pub(crate) fn from_signal(rx: Option<crate::runtime::sync::oneshot::Receiver<()>>) -> Self {
432        match rx {
433            Some(rx) => Self::Async(rx),
434            None => Self::Sync,
435        }
436    }
437
438    /// The completion handle if this cycle went async, else `None`. The CA
439    /// `WRITE_NOTIFY` dispatch uses this to choose inline reply (`None`) vs a
440    /// spawned completion task (`Some(rx)`).
441    pub fn into_handle(self) -> Option<crate::runtime::sync::oneshot::Receiver<()>> {
442        match self {
443            Self::Sync => None,
444            Self::Async(rx) => Some(rx),
445        }
446    }
447
448    /// True if the cycle went async (a completion handle is outstanding).
449    pub fn is_async(&self) -> bool {
450        matches!(self, Self::Async(_))
451    }
452
453    /// True if the cycle completed synchronously (no handle to await).
454    pub fn is_sync(&self) -> bool {
455        matches!(self, Self::Sync)
456    }
457}
458
459/// A put-notify (`dbPutNotify` — CA WRITE_NOTIFY, `caput -c`) that landed on a
460/// PACT record and was therefore deferred WHOLE.
461///
462/// C `processNotifyCommon` (dbNotify.c:225-231) tests `precord->pact` above
463/// `ppn->putCallback`, so nothing is written and nothing is marked: the record
464/// joins the notify's wait list in state `notifyRestartInProgress`, and when the
465/// async cycle completes the put is replayed against a record that is no longer
466/// active — value written, record processed, callback fired only after THAT
467/// process finishes. So a client's "callback returned" still means "the value I
468/// sent has been processed".
469///
470/// softIoc 7.0.10.1-DEV, `ASY` (calcout, `ODLY=4`, `A=5`), `caput -c ASY.A 7`
471/// issued 1 s into the async cycle:
472///
473/// ```text
474/// t=1s  A=5  PACT=1                      <- cycle in flight
475/// t=2s  A=5  PACT=1  RPRO=0              <- put-notify pending: nothing written
476/// t=4s  A=7  PACT=1                      <- cycle done; the put is replayed
477/// callback returns at t=6.9s: A=7 VAL=7  <- after the RESTARTED process
478/// ```
479pub struct DeferredNotifyPut {
480    /// The field the client wrote (already upper-cased).
481    pub field: String,
482    /// The value it wrote — held here, unwritten, until the restart.
483    pub value: crate::types::EpicsValue,
484    /// The client's completion channel. The replayed put builds its wait-set
485    /// around this sender, so the callback fires on the restarted process, not
486    /// on the in-flight cycle.
487    pub completion: crate::runtime::sync::oneshot::Sender<()>,
488}
489
490/// One entry of C `precord->ppnr->restartList` — a whole `processNotify`
491/// waiting for the record, not just a put.
492///
493/// C queues the *request* (`ellSafeAdd(&restartList, &ppn->restartNode)`,
494/// dbNotify.c:217) and `restartCheck` re-enters `processNotifyCommon`, which
495/// dispatches on `ppn->requestType`. A queue that could hold only a
496/// field-and-value put left the other request type — C `processGetRequest`,
497/// the port's [`crate::server::database::PvDatabase::process_record_with_notify`]
498/// — with nowhere to wait, so its entry refused instead of queueing.
499pub enum DeferredNotify {
500    /// C `putProcessRequest` / `putProcessGetRequest`: write the field, then
501    /// process; the callback fires on the replayed cycle.
502    Put(DeferredNotifyPut),
503    /// C `processGetRequest`: process the record, write nothing.
504    Process {
505        /// The client's completion channel, armed on the replayed process.
506        completion: crate::runtime::sync::oneshot::Sender<()>,
507    },
508}
509
510/// The PACT→idle transition, as a value.
511///
512/// Carries one bit: whether the record owed a restart at the moment the token
513/// was minted. Queued put-notifies live on the record
514/// (`RecordInstance::notify_restart_list`) from arrival to replay and are
515/// promoted by one owner, `PvDatabase::apply_pact_exit` — so a release path
516/// that forgets its tail delays a restart, it cannot strand one inside a
517/// dropped value.
518///
519/// The bit is a HINT, not a second home for the queue. It is minted under a
520/// record lock the minting site already holds (every constructor is reached
521/// from `&mut self` or an explicit read), which is what lets
522/// `apply_pact_exit` take NO record lock at all — so it is safe to call from
523/// a `Drop`, where a still-live write guard in the same scope would otherwise
524/// deadlock parking_lot. A stale `true` costs one no-op drain: the drain
525/// re-reads the queue under the write lock via
526/// `RecordInstance::take_next_notify_restart` and returns if it is empty.
527///
528/// The token is `#[must_use]` because that tail is where the restart happens:
529/// C `recGbl.c:295` (`if (pdbc->ppn) dbNotifyCompletion(pdbc)`) →
530/// `dbNotifyCompletion` → `restartCheck` (`dbNotify.c:149-170`). Holding it to
531/// the tail rather than promoting at the `pact = FALSE` store is what keeps the
532/// replay behind the rest of the cycle, exactly as C's queued callback is.
533#[must_use = "a PACT release must reach PvDatabase::apply_pact_exit, which is \
534              where a queued put-notify is restarted"]
535pub struct PactExit {
536    restart_pending: bool,
537}
538
539impl PactExit {
540    /// Mint a token from the record's queue state, read under the caller's
541    /// lock.
542    pub(crate) fn new(restart_pending: bool) -> PactExit {
543        PactExit { restart_pending }
544    }
545
546    /// Fold two releases of the same cycle into one token.
547    ///
548    /// A simulated SDLY continuation releases PACT inside `check_simulation_mode`
549    /// and again at the `is_continuation` arm; one restart check covers both, so
550    /// either half owing a restart makes the folded token owe one.
551    pub(crate) fn merge(self, other: PactExit) -> PactExit {
552        PactExit {
553            restart_pending: self.restart_pending || other.restart_pending,
554        }
555    }
556
557    /// Whether the minting site saw a queued notify. See the type docs: this
558    /// is a hint the drain re-validates, never the queue itself.
559    pub(crate) fn restart_pending(&self) -> bool {
560        self.restart_pending
561    }
562}
563
564/// Cached metadata for a record.
565///
566/// Stores the result of `populate_display_info` / `populate_control_info` /
567/// `populate_enum_info` so subsequent `snapshot_for_field` /
568/// `make_monitor_snapshot` calls can skip rebuilding the metadata. The
569/// cache is invalidated whenever a metadata-class field is written
570/// (EGU, PREC, HOPR, LOPR, alarm limits, DRVH/DRVL, state strings).
571///
572/// In a CA-only IOC this is a CPU win; in a hybrid CA + PVA IOC where
573/// every snapshot needs full metadata for NTScalar serialization, the
574/// cache eliminates redundant per-event populate work.
575#[derive(Clone, Default)]
576pub(crate) struct MetadataSnapshot {
577    pub display: Option<DisplayInfo>,
578    pub control: Option<ControlInfo>,
579    pub enums: Option<EnumInfo>,
580    /// `(hihi, high, low, lolo)` from [`RecordInstance::explicit_alarm_limits`]
581    /// — the record's OWN bands, which C `get_alarm_double` reads as struct
582    /// members (`calcRecord.c:205-221`). Record-level, not per-field: the
583    /// function ignores the field and answers from `rtype` alone, so which
584    /// FIELDS receive these is still [`RecordInstance::route_field_metadata`]'s
585    /// decision — only the eight `resolve_field` name lookups behind the
586    /// numbers move here, where an invalidation rather than a snapshot pays
587    /// for them.
588    pub alarm: (f64, f64, f64, f64),
589}
590
591/// Does a write to this field make [`RecordInstance::metadata_cache`] stale?
592///
593/// **Cache bookkeeping only** — NOT the `DBE_PROPERTY` gate, which is the
594/// field's own `prop(YES)` declaration ([`RecordInstance::field_posts_property`]).
595/// The two used to be one hand-written list, so every field this port had to
596/// invalidate on became a property event C does not post, and every `prop(YES)`
597/// field nobody had listed posted nothing. They answer different questions and
598/// the sets genuinely differ in both directions: `busy.ZNAM` is a cache source
599/// that C does not mark `prop(YES)` (busy's `.dbd` declares no `prop` at all),
600/// and `histogram.ULIM` is `prop(YES)` yet feeds only the live-computed
601/// `apply_field_metadata_override`.
602///
603/// The rule: **every field read by `populate_display_info`,
604/// `populate_control_info`, or `populate_enum_info` MUST be in this set** —
605/// otherwise the cache serves stale metadata until some other source field is
606/// written. Field name is expected uppercase.
607///
608/// `DESC` feeds `display.description` but is deliberately absent: its
609/// invalidation is owned by the DESC arm of `put_common_field`, the single
610/// writer of `common.desc`. The `Q:form` info tag (`populate_display_info` ->
611/// `display.form`) is an immutable load-time tag, not a runtime field, so it
612/// needs no invalidation either.
613fn is_metadata_cache_source(name: &str) -> bool {
614    matches!(
615        name,
616        // `populate_display_info` — units/precision/display limits for the
617        // analog, integer, array and motor arms.
618        "EGU" | "PREC" | "HOPR" | "LOPR" | "HLM" | "LLM"
619        // `populate_control_info` — the ao/longout/int64out drive limits.
620        | "DRVH" | "DRVL"
621        // `explicit_alarm_limits` — the four bands and the four severities
622        // that gate them. C reads these as struct members on every
623        // `get_alarm_double`, so nothing there has to be invalidated; here
624        // they are cached, and a write to any of the eight is what makes the
625        // cached answer wrong.
626        | "HIHI" | "HIGH" | "LOW" | "LOLO"
627        | "HHSV" | "HSV" | "LSV" | "LLSV"
628        // `populate_enum_info` via `Record::enum_state_strings` — bi/bo/busy
629        // two-state names and the sixteen mbbi/mbbo state strings.
630        | "ZNAM" | "ONAM"
631        | "ZRST" | "ONST" | "TWST" | "THST" | "FRST" | "FVST" | "SXST" | "SVST"
632        | "EIST" | "NIST" | "TEST" | "ELST" | "TVST" | "TTST" | "FTST" | "FFST"
633    )
634}
635
636/// One alarm limit for a DBR_AL_DOUBLE response: the value when its
637/// severity threshold is enabled, `NaN` otherwise. Mirrors C
638/// `get_alarm_double`'s `prec->hhsv ? prec->hihi : epicsNAN` — a NONZERO
639/// test on the raw ordinal, so an out-of-range severity still enables the
640/// limit.
641fn gated(severity: i16, limit: f64) -> f64 {
642    if severity != 0 { limit } else { f64::NAN }
643}
644
645/// Extract the RAW stored ordinal a put lands in a `menu(menuAlarmSevr)`
646/// severity field (`HHSV`/`HSV`/`LSV`/`LLSV`/`UDFS`/`DISS`), WITHOUT clamping
647/// to the 0..=3 valid range.
648///
649/// C's numeric menu put stores whatever `(epicsEnum16)` the value truncates to
650/// (`dbConvert.c::putDoubleEnum` = `*pfield = (epicsEnum16)*psrc`), so
651/// `caput REC.HSV 4` keeps `4` and `caput REC.HSV -1` keeps `65535` — both
652/// wire-visible (served signed as `-1`) and both used verbatim to derive the
653/// alarm. The carrier is `i16` so the 16-bit pattern round-trips; the alarm
654/// meaning is read back with [`AlarmSeverity::from_u16`] and the C nonzero
655/// enable with `!= 0`.
656///
657/// A numeric value has already been wrapped to `epicsEnum16` upstream
658/// (`EpicsValue::convert_to(Enum)`, the one owner of C's double→enum cast); this
659/// only reinterprets its bit pattern. A `String` is a db-load / internal-link
660/// label (a client string put is rejected-or-resolved by `putStringMenu`
661/// upstream), resolved to its ordinal here.
662fn menu_ordinal_raw(value: &EpicsValue) -> i16 {
663    match value {
664        EpicsValue::String(s) => match s.as_str_lossy().as_ref() {
665            "NO_ALARM" => 0,
666            "MINOR" => 1,
667            "MAJOR" => 2,
668            "INVALID" => 3,
669            other => other
670                .parse::<i64>()
671                .ok()
672                .map(|n| n as u16 as i16)
673                .unwrap_or(0),
674        },
675        other => other.to_f64().unwrap_or(0.0) as i64 as u16 as i16,
676    }
677}
678
679/// Coerce a db-loaded `String` for a numeric/menu **common** field to that
680/// field's canonical DBF type before [`RecordInstance::put_common_field`]
681/// dispatches on it.
682///
683/// The db loader applies a record's own fields with the typed
684/// `EpicsValue::parse(desc.dbf_type, value_str)` (`db_loader::apply_fields`),
685/// but a field absent from `field_list` is pushed to the common-field path as
686/// a raw `EpicsValue::String` — it has no `FieldDesc` to parse against. The
687/// numeric common-field arms in `put_common_field` match only their typed
688/// variant, so without this step a `.db` `field(PHAS, "1")`,
689/// `field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`, `field(DISA, "1")`, … is
690/// silently dropped at IOC load. Routing the String through the same
691/// `EpicsValue::parse` the record-field path uses handles the numeric *and*
692/// menu-label forms uniformly, so the arm receives the value it expects.
693///
694/// Only fields whose canonical type is numeric/menu are listed; the
695/// Port of libcom `epicsParseInt32(str, &to, 10, NULL)`
696/// (`libcom/src/misc/epicsStdlib.c:26-53,245-261`), which is how pvxs parses
697/// the `nsec:lsb:` digit count. Returns `None` for every status the C
698/// returns non-zero for:
699///
700/// - `S_stdlib_noConversion` — `strtol` consumed nothing (empty / no digits)
701/// - `S_stdlib_extraneous` — trailing non-space bytes with `units == NULL`
702/// - `S_stdlib_overflow` — outside `epicsInt32`
703///
704/// Leading and trailing whitespace — C `isspace`, vertical tab included — and
705/// a leading `+`/`-` sign are accepted, matching `epicsParseLong`'s skips and
706/// `strtol`.
707fn epics_parse_int32_base10(s: &str) -> Option<i32> {
708    // `while ((c = *str) && isspace(c)) ++str;` then `strtol(str, &endp, 10)`.
709    let body = s.trim_start_matches(crate::runtime::stdlib::c_isspace);
710    let (sign, digits) = match body.strip_prefix(['+', '-']) {
711        Some(rest) if body.starts_with('-') => (-1i64, rest),
712        Some(rest) => (1i64, rest),
713        None => (1i64, body),
714    };
715    let end = digits
716        .find(|c: char| !c.is_ascii_digit())
717        .unwrap_or(digits.len());
718    if end == 0 {
719        return None; // endp == str → S_stdlib_noConversion
720    }
721    // `if (c && !units) return S_stdlib_extraneous;` after skipping trailing
722    // whitespace.
723    if !digits[end..]
724        .trim_start_matches(crate::runtime::stdlib::c_isspace)
725        .is_empty()
726    {
727        return None;
728    }
729    // ERANGE from `strtol`, then the explicit `epicsInt32` range check.
730    let magnitude: i64 = digits[..end].parse().ok()?;
731    i32::try_from(sign * magnitude).ok()
732}
733
734/// The STORED type of a `dbCommon` field — the variant its
735/// [`RecordInstance::put_common_field_bounded`] arm binds, which is not always
736/// the type the `.dbd` DECLARES it as (a `menu()` field is declared `DBF_MENU`
737/// and served `DBR_ENUM`, but held here as its bare index).
738///
739/// String-typed common fields (DESC, ASG, OUT, TSEL, …) have no entry: their
740/// arms take the string verbatim.
741fn stored_common_field_type(name: &str, declared: Option<DbFieldType>) -> Option<DbFieldType> {
742    Some(match name {
743        "SCAN" | "SSCN" | "PINI" => DbFieldType::Enum,
744        "TSE" | "PHAS" | "PRIO" | "DISV" | "DISA" | "DISS" | "LCNT" | "UDFS" | "ACKT" | "ACKS"
745        | "SEVR" | "STAT" | "NSEV" | "NSTA" => DbFieldType::Short,
746        // The analog-alarm limits and the hysteresis margin are the one row
747        // here whose stored type is the DECLARED type, and it varies by record:
748        // `DBF_DOUBLE` on ai/ao/calc/calcout/sub/scalcout, `DBF_LONG` on
749        // longin/longout, `DBF_INT64` on int64in/int64out
750        // (`int64inRecord.dbd.pod:152-208`). Naming `Double` for all of them
751        // discarded the record's own `.dbd` row on both writers — the `.db`
752        // string parse and a runtime `dbPut` — so an `epicsInt64` limit above
753        // 2^53 was rounded before it ever reached storage. Ask the record's
754        // generated field table instead; a caller with no table (a hand-built
755        // test record) keeps the `DBF_DOUBLE` majority.
756        //
757        // A field missing from this table entirely reaches its arm as whatever
758        // variant the caller built, and an arm that binds a typed variant then
759        // drops it: that is how `field(HYST,"2")` silently became 0 on every
760        // record whose hysteresis lives in `common.hyst`.
761        "HIHI" | "HIGH" | "LOW" | "LOLO" | "HYST" => declared.unwrap_or(DbFieldType::Double),
762        // The `DBF_UCHAR` flags. `bool` here, a NUMBER in C.
763        "DISP" | "UDF" | "TPRO" | "RPRO" | "BKPT" | "PROC" => DbFieldType::Char,
764        _ => return None,
765    })
766}
767
768/// **The single owner of "what type does a `dbCommon` field hold"**, run on
769/// EVERY put before the typed arms below see the value — so an arm may bind one
770/// variant and know the put cannot have arrived in another.
771///
772/// This is not a string-parsing convenience. A common field is reached by three
773/// writers with three different ideas of the value's shape: the db loader hands
774/// every field over as a raw `String`; a `dbPut` arrives coerced to the field's
775/// DECLARED type (`DBF_MENU` → `Enum` for PRIO, `DBF_UCHAR` → `Char` for DISP);
776/// an internal link delivers whatever its source stored. Before this ran on the
777/// non-`String` shapes too, each arm's single-variant `if let` was a silent
778/// drop for the other two writers — `caput REC.PRIO HIGH` resolved its label to
779/// `Enum(2)` and then vanished at the arm, leaving PRIO at 0.
780///
781/// An unparseable String is returned as-is so the arm drops it, and a menu
782/// field's bad label FAILS the put (`S_db_badChoice`) rather than landing as
783/// index 0.
784fn coerce_common_field(
785    name: &str,
786    value: EpicsValue,
787    bound: MenuBound,
788    declared: Option<DbFieldType>,
789) -> CaResult<Converted> {
790    let Some(dbf) = stored_common_field_type(name, declared) else {
791        return Ok(Converted::Stored(value));
792    };
793    let EpicsValue::String(s) = &value else {
794        // Already typed: project onto the stored type through the one
795        // value-coercion owner. `convert_to` short-circuits a value that is
796        // already `dbf`, so the common case costs nothing.
797        return Ok(Converted::Stored(value.convert_to(dbf)));
798    };
799    let text = s.as_str_lossy();
800    // A `DBF_MENU` common field resolves its label against THAT field's own
801    // menu through the one converter every menu-field string put uses
802    // (C `dbConvert.c::putStringMenu`: exact label, else an index below
803    // `nChoice`, else `S_db_badChoice`) — the same rule the record-specific
804    // menu fields follow in `coerce_write_value`. The failure PROPAGATES: the
805    // field-blind `EpicsValue::parse` fallback below must never see a menu
806    // field, or `caput REC.PRIO Bogus` lands as index 0 instead of failing.
807    //
808    // SCAN/SSCN/PINI are menu fields like any other and go through the same
809    // converter. They used to each carry a hand-written `from_str` that drifted
810    // from C: `ScanType::from_str` case-folded and invented `"0.5 second"`
811    // aliases for menuScan's `".5 second"` (and mapped any out-of-range index
812    // to Passive), `SimModeScan::from_str` took any u16, `PiniMode::from_str`
813    // trimmed. C has ONE converter and it does none of that.
814    if let Some(choices) = super::menu_choices::shared_menu_choices(name) {
815        return super::menu_choices::resolve_menu_field_string_bounded(
816            name, choices, dbf, &text, bound,
817        )
818        .map(Converted::Stored);
819    }
820    // Numeric (non-menu) common field: C's `dbPut` runs the string through the
821    // SAME `epicsParse*` (`dbConvert.c` `putString*`) the record data fields use,
822    // and a non-zero status REFUSES the whole put (`dbAccess.c:1362`, mapped to
823    // `ECA_PUTFAIL`). Route it through the single owner of that conversion —
824    // [`c_parse::put_string`] — instead of the field-blind `EpicsValue::parse`,
825    // which wrapped (`256 as u8 == 0`) and swallowed the error (`Err(_) =>
826    // Ok(value)`), so `caput REC.PROC 256` and `caput REC.PROC notanumber` were
827    // accepted where C rejects them.
828    //
829    // Key the parse on the field's C-DECLARED width, not its stored variant: the
830    // `DBF_UCHAR` flags (DISP/UDF/TPRO/RPRO/BKPT/PROC, `dbCommon.dbd`) are held in
831    // the signed `Char` variant here but C parses them with `epicsParseUInt8`, so
832    // `caput REC.PROC 255` and `caput REC.PROC -1` (→255) are accepted and only
833    // `256`+/non-numeric refused. `put_string` returns the value in the declared
834    // variant; project it back onto the stored variant through the one
835    // value-coercion owner (byte-identity for `UChar`→`Char`).
836    let declared = match dbf {
837        DbFieldType::Char => DbFieldType::UChar,
838        other => other,
839    };
840    let Some(target) = c_parse::NumericField::of(declared) else {
841        // Unreachable: every numeric `stored_common_field_type` (Char/Short/
842        // Double, all with a numeric row) reaches here; the `Enum` menu types
843        // returned above. Keep the pre-parse value rather than panic.
844        return Ok(Converted::Stored(value));
845    };
846    // Hand the string over UNTRIMMED. C tests `*from == 0` on the raw bytes
847    // (`dbFastLinkConv.c:147`), so `"   "` is not the empty string to it and
848    // falls through to `epicsParse*`, which refuses it; trimming first turned
849    // it into the accepted empty case. Nothing else was riding on the trim —
850    // `scan_int`/`strtod` skip leading `isspace` themselves, and `epicsParse*`
851    // is called with a non-NULL `units` pointer, so trailing text is legal.
852    Ok(match c_parse::put_string(name, target, &text)? {
853        c_parse::Converted::Stored(parsed) => Converted::Stored(parsed.convert_to(dbf)),
854        c_parse::Converted::Unchanged => Converted::Unchanged,
855    })
856}
857
858/// The alarm-acknowledge request types C's `dbPut` dispatches on
859/// (`dbAccess.c:1331-1335`): `DBR_PUT_ACKT` and `DBR_PUT_ACKS`.
860///
861/// Acknowledgement is a *request type*, not a field write — the two handlers
862/// run above the `SPC_NOMOD` gate that refuses every ordinary put to ACKT/ACKS.
863#[derive(Debug, Clone, Copy, PartialEq, Eq)]
864pub enum AlarmAck {
865    /// `DBR_PUT_ACKT` → `putAckt`: set transient-alarm acknowledgement.
866    Transient,
867    /// `DBR_PUT_ACKS` → `putAcks`: acknowledge an alarm of this severity.
868    Severity,
869}
870
871pub(crate) enum ForwardTarget {
872    /// Nothing to forward: the record vetoed its own FLNK this cycle
873    /// ([`Record::should_fire_forward_link`]), or `FLNK` is unset / constant /
874    /// hardware — every kind C's `dbScanFwdLink` walks past.
875    None,
876    /// A local database record, C `dbScanFwdLink` → `dbScanPassive` →
877    /// `processTarget`, with what that call carries: C `processTarget`
878    /// (dbDbLink.c:460-474) hands each target `psrc->putf` and `psrc->ppn`
879    /// as a unit — the PUTF bit and the put-notify wait-set always travel
880    /// together. Resolved here, under the guard that resolved the target, so
881    /// a cycle with no DB forward link reads neither.
882    Db {
883        name: String,
884        putf: bool,
885        notify: Option<Arc<crate::server::record::NotifyWaitSet>>,
886    },
887    /// An external `pva://` / `ca://` PV, C `dbScanFwdLink` → the link set's
888    /// `scanForward` (pvxs `pvaScanForward`) — a process-only trigger.
889    External(String),
890}
891impl ProcessPlan {
892    /// The answers, asked of the record type once. Pure in the record's
893    /// type: every input is a `Record` method or a table keyed on its name,
894    /// so the plan is the same whenever it is built.
895    pub(crate) fn of(record: &dyn Record) -> Self {
896        let rtype = record.record_type();
897        let declares_simulation = field_desc_of(record, "SIMM").is_some();
898        ProcessPlan {
899            dset_can_refuse: crate::server::recgbl::dev_sup_process_refusal(rtype).is_some(),
900            simulation: declares_simulation,
901            sel_nvl: rtype == "sel",
902            string_input: !record.string_input_links().is_empty(),
903            multi_output_dispatch: crate::server::database::multi_output_dispatch_owned(rtype),
904            reads_sell: crate::server::database::reads_sell(rtype),
905            posts_software_event: crate::server::database::posts_software_event(rtype),
906            resolves_subroutine_from_link: crate::server::database::resolves_subroutine_from_link(
907                rtype,
908            ),
909            // Asked of the record itself, not of a table keyed on `rtype`: the
910            // type that implements the step is the one that answers whether it
911            // has one, so the two cannot drift into different files.
912            substitutes_input_stage_when_simulating: record.simulation_substitutes_input_stage(),
913            redecides_after_output: record.redecides_after_output(),
914            input_fetch_policy: record.input_fetch_policy(),
915            constants_deliver_at_process: record.constant_inputs_deliver_at_process(),
916            multi_input_is_db_get_link: record.multi_input_fetch_is_db_get_link(),
917            multi_inputs_read_native: record.input_link_answers_fixed_at_type()
918                && record.multi_input_links().iter().all(|(lf, _)| {
919                    record.input_link_request(lf) == InputLinkRequest::As(LinkReadAs::Native)
920                        && !record.input_link_failure_is_inert(lf)
921                }),
922            dispatches_generic_multi_output: !crate::server::database::multi_output_dispatch_owned(
923                rtype,
924            ) && record.declares_multi_output_links(),
925            narrows_input_links: record.narrows_input_links(),
926            output_stage: record.can_device_write()
927                || field_desc_of(record, "OUT").is_some()
928                || field_desc_of(record, "OEVT").is_some()
929                || crate::server::database::multi_output_dispatch_owned(rtype)
930                || record.declares_multi_output_links()
931                || declares_simulation,
932            fetches_dol_closed_loop: record.fetches_dol_closed_loop(),
933            soft_channel_skips_convert: record.soft_channel_skips_convert(),
934            skips_timestamp_when_undefined: record.skips_timestamp_when_undefined(),
935            restamps_time_after_completion: record.restamps_time_after_completion(),
936            clears_udf: record.clears_udf(),
937        }
938    }
939}
940
941/// C's `if (prec->udf) recGblSetSevr(prec, UDF_ALARM, prec->udfs);` line, as
942/// one record type writes it — whether the type has the line at all, whether
943/// it tests `udf == TRUE` instead of truthiness, the severity it substitutes
944/// for `UDFS`, and the message it attaches. Four `&dyn Record` calls, once per
945/// cycle per record, for four answers the type fixed at construction.
946#[derive(Clone, Copy, Debug)]
947pub(crate) struct UdfAlarm {
948    pub(crate) exact_one: bool,
949    pub(crate) severity: Option<crate::server::record::AlarmSeverity>,
950    pub(crate) message: &'static str,
951}
952
953/// What a record's TYPE can reach in a process cycle.
954///
955/// C enters a record through one `rset->process` pointer and runs only the
956/// lines that type has. This port runs one generic body for every type, so a
957/// `calc` cycle otherwise takes a record lock to ask whether device support
958/// may refuse it, another for the simulation block, another for `sel`'s NVL
959/// and another for the string-input fetch — four locks and four dynamic calls
960/// to learn four answers that were fixed when the type was compiled. They are
961/// settled once, in [`RecordCell::new`], and live on the cell rather than
962/// under its lock: a cycle borrows them ([`RecordCell::process_plan`])
963/// instead of copying them out of the guarded instance, which the body did
964/// field by field — one load and one spill per flag — on every cycle.
965#[derive(Clone, Copy, Debug, Default)]
966pub(crate) struct ProcessPlan {
967    /// This type's C `process()` opens with a dset test that can refuse the
968    /// cycle (`dev_sup_process_refusal`). `false` for `calc`, `sub`, `fanout`
969    /// and `calcout`, whose C `process()` has no such line.
970    pub(crate) dset_can_refuse: bool,
971    /// This type's dbd declares SIMM, so it has a C `readValue`/`writeValue`
972    /// simulation block at all.
973    pub(crate) simulation: bool,
974    /// This type is `sel`, the only one with an NVL link feeding SELN.
975    pub(crate) sel_nvl: bool,
976    /// This type declares at least one string-input link.
977    pub(crate) string_input: bool,
978    /// This type's `fanout`/`dfanout`/`seq` link array is driven by
979    /// `PvDatabase::dispatch_multi_output`. Gates all three of its call sites
980    /// — the pre-commit value phase and both forward-link tails.
981    pub(crate) multi_output_dispatch: bool,
982    /// This type reads `SELL` into `SELN` at some phase of the cycle.
983    pub(crate) reads_sell: bool,
984    /// This type is `event`, the only one that posts a named software event
985    /// from the forward-link tail.
986    pub(crate) posts_software_event: bool,
987    /// This type is `aSub`, the only one that can re-read its subroutine name
988    /// from a link mid-cycle.
989    pub(crate) resolves_subroutine_from_link: bool,
990    /// This type's simulation block replaces the input stage rather than
991    /// running alongside it — `swait` alone. `false` makes the whole
992    /// `set_simulation_active` step disappear, lock included, rather than
993    /// taking the record's write lock to ask and be told no.
994    pub(crate) substitutes_input_stage_when_simulating: bool,
995    /// This type has a `conditional_write` epilogue — see
996    /// [`Record::redecides_after_output`]. `false` makes the post-output write
997    /// lock disappear.
998    pub(crate) redecides_after_output: bool,
999    /// What a failed multi-input read means to this type — see
1000    /// [`Record::input_fetch_policy`].
1001    pub(crate) input_fetch_policy: crate::server::record::InputFetchPolicy,
1002    /// `printf` alone re-runs `recGblInitConstantLink` on every process, so
1003    /// its constant inputs deliver every cycle — see
1004    /// [`Record::constant_inputs_deliver_at_process`].
1005    pub(crate) constants_deliver_at_process: bool,
1006    /// Whether a failed multi-input read owes C's `setLinkAlarm` — see
1007    /// [`Record::multi_input_fetch_is_db_get_link`].
1008    pub(crate) multi_input_is_db_get_link: bool,
1009    /// Every declared multi-input link reads [`LinkReadAs::Native`] and none
1010    /// declares its failure inert — the framework default, which every base
1011    /// numeric type gives. The fetch loop then carries the answer instead of
1012    /// asking [`Record::input_link_request`] and
1013    /// [`Record::input_link_failure_is_inert`] through the vtable per link,
1014    /// per cycle. Only settled here for a type whose answers are fixed at the
1015    /// type ([`Record::input_link_answers_fixed_at_type`]).
1016    pub(crate) multi_inputs_read_native: bool,
1017    /// This type declares generic multi-output pairs
1018    /// ([`Record::multi_output_links`]) for the pre-commit output stage to
1019    /// drive — scalcout, acalcout, aSub, epid. `false` makes
1020    /// `dispatch_multi_output_values` and the record lock it takes disappear,
1021    /// rather than acquiring the lock to re-derive the answer from the
1022    /// record's type name every cycle.
1023    ///
1024    /// Disjoint from [`ProcessPlan::multi_output_dispatch`] by construction:
1025    /// the `fanout`/`dfanout`/`seq` arrays are driven by
1026    /// `dispatch_multi_output`, and `multi_output_dispatch_owned` excludes
1027    /// them from the generic block, so no type can answer `true` to both.
1028    pub(crate) dispatches_generic_multi_output: bool,
1029    /// This type can narrow the cycle's input list — see
1030    /// [`Record::narrows_input_links`]. `false` makes the guard that asks
1031    /// disappear.
1032    pub(crate) narrows_input_links: bool,
1033    /// This type has an output stage — a device it can write, an `OUT` link,
1034    /// an `OEVT` event, multi-output links, or a simulation block — so a cycle
1035    /// composes and drives outputs between `checkAlarms` and the alarm commit.
1036    /// `false` is that whole stage absent from the type's C `process()`
1037    /// (`calcRecord.c` has no such lines): the cycle skips it, and with it the
1038    /// guard boundary the stage's link writes would need.
1039    pub(crate) output_stage: bool,
1040    /// Five per-type answers the cycle asked the record for through the
1041    /// vtable every process; each is a literal in every implementation, so
1042    /// they are read once here. See the `Record` method of the same name.
1043    pub(crate) fetches_dol_closed_loop: bool,
1044    pub(crate) soft_channel_skips_convert: bool,
1045    pub(crate) skips_timestamp_when_undefined: bool,
1046    pub(crate) restamps_time_after_completion: bool,
1047    pub(crate) clears_udf: bool,
1048}
1049
1050/// The allocation every holder of a record shares — the port's `precord`.
1051///
1052/// The record's data is guarded by its **lock set** and nothing else — C's
1053/// `dbScanLock` (`dbLock.c:184-213`), the one mutex every record a DB link
1054/// reaches shares. [`Self::read`] and [`Self::write`] take that set
1055/// (recursively, as C's `epicsMutex` is) and hand out a borrow of the data;
1056/// a thread already inside the set — the process path, which took it at
1057/// entry — pays a thread-key compare and a counter, no atomic
1058/// read-modify-write. The per-record `RwLock` this replaced was a second
1059/// lock over the same data: every field access on the process path took it
1060/// again under the set that already excluded every other writer.
1061///
1062/// What lives HERE rather than in [`RecordInstance`] is decided by one rule:
1063/// **a fact a caller needs before it holds the lock cannot live behind the
1064/// lock.** C has exactly one such fact — `precord->lset`, read off
1065/// `dbCommon` with no lock at all to learn which mutex to take.
1066///
1067/// Same-thread aliasing is what the `RwLock` used to refuse by deadlocking
1068/// (a `write()` under a live guard of the same record never returned). The
1069/// set recurses, so that refusal is `borrow`'s now, and it panics —
1070/// on the thread that did it, naming the record — instead of parking it.
1071pub struct RecordCell {
1072    /// C `dbCommon::lset` (`dbLockPvt.h:52`). Born pointing at the bootstrap
1073    /// set — C's null `lset` before `dbLockInitRecords`, made lockable — and
1074    /// moved onto a set of its own by the registry, which adopts this very
1075    /// cell for the record's name so that the record and the registry can
1076    /// never hold two answers to "which set is this record in". A lock-set
1077    /// MERGE moves the set the cell points at, never the cell.
1078    lock_record: std::sync::Arc<crate::server::database::LockRecord>,
1079    /// `RefCell`-style borrow state of `data`: `0` free, `n > 0` that many
1080    /// shared borrows, `-1` one exclusive borrow. Touched only by the thread
1081    /// holding the record's lock set, which is why plain loads and stores
1082    /// (no read-modify-write) are enough — the atomic is for `Sync`, not for
1083    /// contention, and there is none.
1084    borrow: std::sync::atomic::AtomicIsize,
1085    /// The record's `BKPT` byte, shared with `data.common.bkpt` — reachable
1086    /// here without the lock set, as C reads `precord->bkpt` (see
1087    /// [`BkptFlag`]).
1088    bkpt: BkptFlag,
1089    /// Which published `cp_links` map `PvDatabase::sources_cp_edges` was resolved
1090    /// from, or 0 before it ever was.
1091    ///
1092    /// The question the cache answers — "does any CP/CPP edge name this
1093    /// record as its source?" — is asked once per process cycle by
1094    /// `PvDatabase::dispatch_cp_targets`, and the registry that holds the
1095    /// answer is keyed by record NAME. Asking it directly costs a hash of the
1096    /// name and a map probe every cycle to hear `false`, which is what every
1097    /// record with no CP holder hears forever. The registry moves its
1098    /// revision on every edit, so a record that has heard the answer once may
1099    /// keep it until it does.
1100    cp_edges_revision: std::sync::atomic::AtomicU64,
1101    /// The cached answer, valid only while [`Self::cp_edges_revision`] still
1102    /// matches the registry's.
1103    cp_edges: std::sync::atomic::AtomicBool,
1104    /// C `dbCommon::rdes` — the record type's description, reachable without
1105    /// a borrow of the record because a cell's type never changes. What a
1106    /// link target's field address ([`FieldAddr`]) is resolved against.
1107    rdes: RecordDesc,
1108    /// [`Record::field_slot`] for each entry of `rdes.fields`, asked once
1109    /// here so a link resolving its target's address needs no lock.
1110    slots: Box<[Option<super::record_trait::FieldSlot>]>,
1111    /// [`Record::field_slot`] of each [`Record::multi_input_links`] value
1112    /// field, by the link's index — C's `&prec->a + i`, the address the
1113    /// fetch stores through ([`Record::put_slot_f64`]) when the type hands
1114    /// one out.
1115    multi_input_val_slots: Box<[Option<super::record_trait::FieldSlot>]>,
1116    /// The type-static answers a process cycle tests — see [`ProcessPlan`].
1117    /// Fixed by the type, like `rdes`, so it is reachable without a borrow
1118    /// of the record.
1119    plan: ProcessPlan,
1120    data: LockSetGuarded<RecordInstance>,
1121}
1122
1123/// A record type's declaration, C's `dbRecordType` behind `precord->rdes`:
1124/// the two static tables a field name is resolved against.
1125#[derive(Clone, Copy)]
1126pub(crate) struct RecordDesc {
1127    pub(crate) record_type: &'static str,
1128    pub(crate) fields: &'static [FieldDesc],
1129}
1130
1131impl RecordDesc {
1132    pub(crate) fn of<R: Record + ?Sized>(record: &R) -> Self {
1133        RecordDesc {
1134            record_type: record.record_type(),
1135            fields: record.field_list(),
1136        }
1137    }
1138}
1139
1140/// C's `dbAddr` field half — `pfldDes`, plus whether the name is one the
1141/// type DECLARES (an undeclared one falls to the attribute table, C
1142/// `dbNameToAddr`'s `dbGetAttributePart` fallthrough, `dbAccess.c:667-675`).
1143/// Settled once per link target and read through every cycle after, as C
1144/// resolves a link's `dbAddr` once in `dbDbInitLink`.
1145#[derive(Clone, Copy)]
1146pub(crate) struct FieldAddr {
1147    pub(crate) declared: bool,
1148    pub(crate) desc: Option<&'static FieldDesc>,
1149    /// The record's own handle for the field ([`Record::field_slot`]), when
1150    /// the type has one and the address was resolved with the record in
1151    /// hand ([`FieldAddr::resolve_in`]). A read at a slot goes to the typed
1152    /// accessor and skips the by-name chain.
1153    pub(crate) slot: Option<super::record_trait::FieldSlot>,
1154}
1155
1156impl FieldAddr {
1157    /// `field` is upper-case already.
1158    pub(crate) fn resolve(rdes: &RecordDesc, field: &str) -> Self {
1159        FieldAddr {
1160            declared: declares_field(rdes.record_type, field),
1161            desc: field_desc_in(rdes.fields, field),
1162            slot: None,
1163        }
1164    }
1165
1166    /// [`Self::resolve`] with the record's slot for the field — for an
1167    /// address that is kept and read through. Takes no lock: the slots were
1168    /// settled with the cell ([`RecordCell::field_slot`]), so a record may
1169    /// resolve a link to itself while it is held for writing.
1170    pub(crate) fn resolve_in(rec: &RecordCell, field: &str) -> Self {
1171        let mut addr = Self::resolve(rec.rdes(), field);
1172        let slot = rec.field_slot(field);
1173        debug_assert!(
1174            slot.is_none() || (addr.declared && addr.desc.is_some_and(|d| !d.unreadable())),
1175            "{}.{field}: a field slot must name a declared, readable field",
1176            rec.rdes().record_type
1177        );
1178        addr.slot = slot;
1179        addr
1180    }
1181}
1182
1183/// Whether `record_type`'s `.dbd` declares `field` — C `dbFindField` on the
1184/// type's declared list, which `pvNameLookup` (`dbChannel.c:311-329`) asks
1185/// before it falls through to the attribute table.
1186pub(crate) fn declares_field(record_type: &str, field: &str) -> bool {
1187    super::dbd_generated::record_declaration_order(record_type)
1188        .is_some_and(|names| names.contains(&field))
1189}
1190
1191/// The record's data, guarded by its lock set. `Sync` on exactly the bound
1192/// `RwLock<T>` demanded for it — the lock set excludes every other thread
1193/// while a borrow is out, the same guarantee the `RwLock` gave.
1194struct LockSetGuarded<T: Send + Sync>(std::cell::UnsafeCell<T>);
1195
1196// SAFETY: a `&T` or `&mut T` is only ever produced by `RecordCell::read` /
1197// `RecordCell::write`, on a thread that holds the record's lock set, and
1198// lives no longer than that hold (`RecordRef` / `RecordMut` carry the set
1199// guard). Two threads therefore never observe the cell at once, and the
1200// same-thread borrow rules are enforced by `RecordCell::borrow`.
1201unsafe impl<T: Send + Sync> Sync for LockSetGuarded<T> {}
1202
1203impl RecordCell {
1204    pub(crate) fn new(instance: RecordInstance) -> Self {
1205        Self {
1206            lock_record: crate::server::database::LockRecord::bootstrap(),
1207            borrow: std::sync::atomic::AtomicIsize::new(0),
1208            bkpt: instance.common.bkpt.share(),
1209            cp_edges_revision: std::sync::atomic::AtomicU64::new(0),
1210            cp_edges: std::sync::atomic::AtomicBool::new(false),
1211            rdes: RecordDesc::of(&*instance.record),
1212            slots: instance
1213                .record
1214                .field_list()
1215                .iter()
1216                .map(|f| instance.record.field_slot(f.name))
1217                .collect(),
1218            multi_input_val_slots: instance
1219                .record
1220                .multi_input_links()
1221                .iter()
1222                .map(|(_, vf)| instance.record.field_slot(vf))
1223                .collect(),
1224            plan: ProcessPlan::of(&*instance.record),
1225            data: LockSetGuarded(std::cell::UnsafeCell::new(instance)),
1226        }
1227    }
1228
1229    /// The type-static answers this cycle may test instead of re-asking the
1230    /// record under a lock.
1231    pub(crate) fn process_plan(&self) -> &ProcessPlan {
1232        &self.plan
1233    }
1234
1235    /// The record type's declaration — C `precord->rdes`.
1236    pub(crate) fn rdes(&self) -> &RecordDesc {
1237        &self.rdes
1238    }
1239
1240    /// [`Record::field_slot`] for `field` (upper-case), answered from the
1241    /// table settled at construction: a slot names a field of the type's
1242    /// own list, and `dbCommon` fields have none.
1243    /// The slot the fetch stores multi-input link `index`'s value through,
1244    /// when the type hands one out.
1245    #[inline(always)]
1246    pub(crate) fn multi_input_val_slot(
1247        &self,
1248        index: usize,
1249    ) -> Option<super::record_trait::FieldSlot> {
1250        self.multi_input_val_slots[index]
1251    }
1252
1253    pub(crate) fn field_slot(&self, field: &str) -> Option<super::record_trait::FieldSlot> {
1254        self.rdes
1255            .fields
1256            .iter()
1257            .position(|f| f.name.eq_ignore_ascii_case(field))
1258            .and_then(|i| self.slots[i])
1259    }
1260
1261    /// The record's `BKPT` byte, without taking its lock set.
1262    pub(crate) fn bkpt(&self) -> &BkptFlag {
1263        &self.bkpt
1264    }
1265
1266    /// Shared access to the record — C `dbScanLock` followed by reading the
1267    /// fields. Blocks while another thread holds the record's lock set;
1268    /// recurses on the thread that holds it.
1269    ///
1270    /// # Panics
1271    ///
1272    /// If this thread holds a [`RecordMut`] of the same record: the borrow
1273    /// the caller asked for cannot coexist with it, and the `RwLock` this
1274    /// replaced would have parked the thread forever here.
1275    pub fn read(&self) -> RecordRef<'_> {
1276        use std::sync::atomic::Ordering;
1277        let set = self.lock_record.acquire();
1278        let borrow = self.borrow.load(Ordering::Relaxed);
1279        assert!(
1280            borrow >= 0,
1281            "record data read while this thread holds it for writing"
1282        );
1283        self.borrow.store(borrow + 1, Ordering::Relaxed);
1284        RecordRef {
1285            cell: self,
1286            _set: Some(set),
1287        }
1288    }
1289
1290    /// [`Self::read`] for a thread that holds `held`: when this record is in
1291    /// that set the set is held already, and only the borrow count is
1292    /// touched — no thread key, no depth counter. Every DB link target is in
1293    /// its reader's set (the merge, see `record_lock.rs`), so this is the
1294    /// per-link read of a process cycle; a record in another set takes
1295    /// [`Self::read`]'s path. Sound by Rule R: a held set keeps its records,
1296    /// so the test cannot go stale while `held` lives, and the borrow is
1297    /// bound to `held` so it cannot outlive the hold.
1298    ///
1299    /// # Panics
1300    ///
1301    /// As [`Self::read`].
1302    #[inline]
1303    pub(crate) fn read_in<'a>(
1304        &'a self,
1305        held: &'a crate::server::database::SetGuard,
1306    ) -> RecordRef<'a> {
1307        use std::sync::atomic::Ordering;
1308        if !held.holds(&self.lock_record) {
1309            return self.read();
1310        }
1311        let borrow = self.borrow.load(Ordering::Relaxed);
1312        assert!(
1313            borrow >= 0,
1314            "record data read while this thread holds it for writing"
1315        );
1316        self.borrow.store(borrow + 1, Ordering::Relaxed);
1317        RecordRef {
1318            cell: self,
1319            _set: None,
1320        }
1321    }
1322
1323    /// Exclusive access to the record — C `dbScanLock` followed by writing
1324    /// the fields. Blocks and recurses as [`Self::read`] does.
1325    ///
1326    /// # Panics
1327    ///
1328    /// If this thread holds any guard of the same record, for the reason
1329    /// given on [`Self::read`].
1330    pub fn write(&self) -> RecordMut<'_> {
1331        use std::sync::atomic::Ordering;
1332        let set = self.lock_record.acquire();
1333        assert!(
1334            self.borrow.load(Ordering::Relaxed) == 0,
1335            "record data written while this thread still holds a guard of it"
1336        );
1337        self.borrow.store(-1, Ordering::Relaxed);
1338        RecordMut {
1339            cell: self,
1340            _set: set,
1341        }
1342    }
1343
1344    /// The answer this record cached for `registry_revision`, or `None` when
1345    /// it has not heard one for that revision.
1346    ///
1347    /// Only `PvDatabase::sources_cp_edges` reads or writes the pair, so the
1348    /// two atomics move as one: the flag is stored first and the revision
1349    /// second, which is the order that makes a matching revision proof that
1350    /// the flag beside it belongs to that revision.
1351    pub(crate) fn cached_cp_edges(&self, registry_revision: u64) -> Option<bool> {
1352        use std::sync::atomic::Ordering;
1353        (self.cp_edges_revision.load(Ordering::Acquire) == registry_revision)
1354            .then(|| self.cp_edges.load(Ordering::Relaxed))
1355    }
1356
1357    /// File `present` as this record's answer for `registry_revision`.
1358    pub(crate) fn cache_cp_edges(&self, registry_revision: u64, present: bool) {
1359        use std::sync::atomic::Ordering;
1360        self.cp_edges.store(present, Ordering::Relaxed);
1361        self.cp_edges_revision
1362            .store(registry_revision, Ordering::Release);
1363    }
1364
1365    /// The lock-set cell — C `precord->lset`.
1366    pub(crate) fn lock_record(&self) -> &std::sync::Arc<crate::server::database::LockRecord> {
1367        &self.lock_record
1368    }
1369}
1370
1371/// A shared borrow of a record, holding its lock set — what
1372/// [`RecordCell::read`] hands out. `!Send`, as the set guard inside it is:
1373/// a lock set is released by the thread that took it.
1374#[must_use = "the record's lock set is released as soon as the guard is dropped"]
1375pub struct RecordRef<'a> {
1376    cell: &'a RecordCell,
1377    /// `None` for a [`RecordCell::read_in`] borrow, which rides on the set
1378    /// guard of its `'a` rather than taking one.
1379    _set: Option<crate::server::database::SetGuard>,
1380}
1381
1382impl RecordRef<'_> {
1383    /// Whether the borrow rode a set guard the caller held rather than
1384    /// taking one — [`RecordCell::read_in`]'s same-set case.
1385    #[cfg(test)]
1386    pub(crate) fn rides_held_set(&self) -> bool {
1387        self._set.is_none()
1388    }
1389}
1390
1391impl std::ops::Deref for RecordRef<'_> {
1392    type Target = RecordInstance;
1393
1394    fn deref(&self) -> &RecordInstance {
1395        // SAFETY: this thread holds the record's lock set (`_set`) and the
1396        // borrow count admitted a shared borrow, so no `&mut` exists.
1397        unsafe { &*self.cell.data.0.get() }
1398    }
1399}
1400
1401impl Drop for RecordRef<'_> {
1402    fn drop(&mut self) {
1403        use std::sync::atomic::Ordering;
1404        // Runs before `_set` is released (fields drop after this body), so
1405        // the count is never touched by a thread outside the set.
1406        let borrow = self.cell.borrow.load(Ordering::Relaxed);
1407        self.cell.borrow.store(borrow - 1, Ordering::Relaxed);
1408    }
1409}
1410
1411/// An exclusive borrow of a record, holding its lock set — what
1412/// [`RecordCell::write`] hands out.
1413#[must_use = "the record's lock set is released as soon as the guard is dropped"]
1414pub struct RecordMut<'a> {
1415    cell: &'a RecordCell,
1416    _set: crate::server::database::SetGuard,
1417}
1418
1419impl std::ops::Deref for RecordMut<'_> {
1420    type Target = RecordInstance;
1421
1422    fn deref(&self) -> &RecordInstance {
1423        // SAFETY: as `RecordRef`, and the borrow count admitted the one
1424        // exclusive borrow, so this is the only reference.
1425        unsafe { &*self.cell.data.0.get() }
1426    }
1427}
1428
1429impl std::ops::DerefMut for RecordMut<'_> {
1430    fn deref_mut(&mut self) -> &mut RecordInstance {
1431        // SAFETY: as `deref`, through the unique borrow of the guard.
1432        unsafe { &mut *self.cell.data.0.get() }
1433    }
1434}
1435
1436impl RecordMut<'_> {
1437    /// The instance and the set guard it is held under, apart — for a
1438    /// caller that writes the record while it reads another record of the
1439    /// same set through [`RecordCell::read_in`].
1440    #[inline]
1441    pub(crate) fn split(&mut self) -> (&mut RecordInstance, &crate::server::database::SetGuard) {
1442        // SAFETY: as `deref_mut`; the set guard is a separate field.
1443        (unsafe { &mut *self.cell.data.0.get() }, &self._set)
1444    }
1445}
1446
1447impl Drop for RecordMut<'_> {
1448    fn drop(&mut self) {
1449        self.cell
1450            .borrow
1451            .store(0, std::sync::atomic::Ordering::Relaxed);
1452    }
1453}
1454
1455/// One entry of [`RecordInstance::parsed_inputs`]: a link text the record
1456/// held at some read, its parse, and the local record the parse addressed.
1457pub(crate) struct ParsedInputLink {
1458    text: String,
1459    /// The [`Record::input_links_generation`] the record answered when
1460    /// `text` was last read off it, for a type that answers one: while the
1461    /// record still answers it, `text` IS the field's text and the cycle
1462    /// need not read the field to know.
1463    generation: Option<u64>,
1464    parsed: Arc<ParsedLink>,
1465    /// C's `dbAddr` from `dbDbInitLink` (`dbDbLink.c:88-111`): the target
1466    /// record, resolved once and read through until something can have
1467    /// changed the answer. C re-resolves only when the link text is put; the
1468    /// port also re-resolves when a name map changed, because records and
1469    /// aliases can come and go after `iocInit` here.
1470    target: Option<LinkTargetHandle>,
1471}
1472
1473/// A resolved link target and the name-map revision it was resolved under.
1474///
1475/// Holds the target's `Arc` — C's `dbAddr` holds the record pointer — so the
1476/// cycle's read is a load, not a `Weak` upgrade. What keeps that from
1477/// leaking: a record leaves the name map only through
1478/// [`RecordInstance::destroy`], which drops the handles the record holds,
1479/// and the map's revision moves with it so every handle that named the
1480/// record is re-resolved on its holder's next read; the database's own drop
1481/// destroys every record still in the map, which breaks the `A -> B -> A`
1482/// cycles two such handles make.
1483struct LinkTargetHandle {
1484    target: ResolvedTarget,
1485    revision: u64,
1486    /// Settled with the target, since every input is the parse's or the
1487    /// target's: whether the reader's own hold reads it, and how.
1488    native: Option<NativeRead>,
1489}
1490
1491/// How the held native fetch reads a link it may read — C's link flags and
1492/// `dbAddr` as the fetch consumes them, decided once per resolution rather
1493/// than per cycle.
1494#[derive(Clone, Copy)]
1495pub(crate) struct NativeRead {
1496    /// The field is not `VAL`, so a simple PV may shadow its spelling and
1497    /// the directory is asked first.
1498    pub(crate) shadowed: bool,
1499    /// `MS` / `MSS` / `MSI`: the target's alarm is inherited.
1500    pub(crate) inherits: bool,
1501    /// The reader's own slot for the link's value field
1502    /// ([`RecordCell::multi_input_val_slot`]) — C's `&prec->a + i`, the
1503    /// address the read is stored through, when the type hands one out.
1504    pub(crate) val_slot: Option<super::record_trait::FieldSlot>,
1505}
1506
1507impl NativeRead {
1508    /// The read the reader's hold makes of `parsed` at `target`, or `None`
1509    /// when it is not this frame's: a read of the reader's own field, a `PP`
1510    /// source (processed with the guard released), a filtered channel, or
1511    /// not a local record read at all.
1512    fn of(
1513        parsed: &ParsedLink,
1514        target: &ResolvedTarget,
1515        reader: &Arc<RecordCell>,
1516        slot: usize,
1517    ) -> Option<Self> {
1518        use crate::server::record::{LinkProcessPolicy, MonitorSwitch};
1519        let ParsedLink::Db(db) = parsed else {
1520            return None;
1521        };
1522        let channel = db.target();
1523        if Arc::ptr_eq(&target.rec, reader)
1524            || db.policy == LinkProcessPolicy::ProcessPassive
1525            || channel.json_suffix.is_some()
1526        {
1527            return None;
1528        }
1529        Some(NativeRead {
1530            shadowed: &*channel.field != "VAL",
1531            inherits: db.monitor_switch != MonitorSwitch::NoMaximize,
1532            val_slot: reader.multi_input_val_slot(slot),
1533        })
1534    }
1535}
1536
1537impl ParsedInputLink {
1538    /// The entry of `cache` for `slot`, holding the parse of the text `record`
1539    /// has in link `slot` of `declared` (its [`Record::multi_input_links`])
1540    /// now; `None` when the link is unset. The list rather than the name,
1541    /// so the frame carries an index and reads the name only when it reads
1542    /// the text.
1543    ///
1544    /// The single writer of [`RecordInstance::parsed_inputs`]: the record's
1545    /// current text is read first and the cached entry is reused only when
1546    /// its text is that text byte for byte, so the parse runs once per
1547    /// distinct text a slot has held and the invariant on the field's doc
1548    /// needs no other site to hold. Takes the record's parts rather than the
1549    /// record so the fetch stage can hold the entry while it writes the
1550    /// record's fields. `always`: the held and the general fetch both call
1551    /// it, and the held one is the loop's common frame.
1552    ///
1553    /// `generation` is [`Record::input_links_generation`] as the caller read
1554    /// it under the hold it calls this under — never carried across a
1555    /// release, since a put in the gap moves it. An entry stamped with that
1556    /// generation was compared byte for byte at it, and the record's one
1557    /// counting writer has not run since — so the text is not read at all.
1558    #[inline(always)]
1559    pub(crate) fn validated<'c>(
1560        cache: &'c mut [Option<ParsedInputLink>],
1561        record: &dyn Record,
1562        slot: usize,
1563        declared: &'static [(&'static str, &'static str)],
1564        generation: Option<u64>,
1565    ) -> Option<&'c mut ParsedInputLink> {
1566        let cell = cache.get_mut(slot)?;
1567        if generation.is_some()
1568            && let Some(entry) = cell.as_ref()
1569            && entry.generation == generation
1570        {
1571            debug_assert!(
1572                record.link_text_ref(declared[slot].0) == Some(entry.text.as_str()),
1573                "{}: the link text changed under generation {generation:?} — \
1574                 a writer of the text does not move input_links_generation",
1575                declared[slot].0
1576            );
1577            return cell.as_mut();
1578        }
1579        let link_field = declared[slot].0;
1580        let owned;
1581        let text: &str = match record.link_text_ref(link_field) {
1582            Some("") => return None,
1583            Some(text) => text,
1584            None => {
1585                owned = link_text_of(record, link_field)?;
1586                &owned
1587            }
1588        };
1589        if cell.as_ref().is_none_or(|entry| entry.text != text) {
1590            *cell = Some(Self::fresh(text));
1591        }
1592        let entry = cell.as_mut()?;
1593        entry.generation = generation;
1594        Some(entry)
1595    }
1596
1597    /// A slot's entry for a text it has not held: the parse, with no target
1598    /// resolved yet. Its own frame so the cycle's byte-for-byte reuse test
1599    /// does not carry the parser's.
1600    fn fresh(text: &str) -> ParsedInputLink {
1601        ParsedInputLink {
1602            text: text.to_owned(),
1603            generation: None,
1604            parsed: Arc::new(parse_link_v2(text)),
1605            target: None,
1606        }
1607    }
1608
1609    /// The parse, shared: the cycle hands it to reads that run after the
1610    /// record lock is released.
1611    pub(crate) fn parsed(&self) -> &Arc<ParsedLink> {
1612        &self.parsed
1613    }
1614
1615    /// The handle at the name maps' current revision: reused while the maps
1616    /// are at the revision it was resolved under, re-resolved otherwise. A
1617    /// link that resolves to no local record is asked again on every read,
1618    /// as it was before the cache. `reader` is the record holding the entry
1619    /// and `slot` the entry's index in its list, for [`NativeRead::of`].
1620    ///
1621    /// Over the entry's parts, so a caller can hold the parse beside the
1622    /// handle it returns.
1623    #[inline(always)]
1624    fn resolve<'h>(
1625        handle: &'h mut Option<LinkTargetHandle>,
1626        parsed: &ParsedLink,
1627        names: &dyn LinkTargetResolver,
1628        reader: &Arc<RecordCell>,
1629        slot: usize,
1630    ) -> Option<&'h LinkTargetHandle> {
1631        let revision = names.name_revision();
1632        if handle
1633            .as_ref()
1634            .is_none_or(|handle| handle.revision != revision)
1635        {
1636            *handle = names.local_target(parsed).map(|target| {
1637                let native = NativeRead::of(parsed, &target, reader, slot);
1638                LinkTargetHandle {
1639                    target,
1640                    revision,
1641                    native,
1642                }
1643            });
1644        }
1645        handle.as_ref()
1646    }
1647
1648    /// The parse, with C's `dbAddr` for it — the local record and field the
1649    /// link addresses, or `None` for a link that is not a local record read.
1650    /// The two come out together because both borrow the entry and the
1651    /// cycle wants both of every link.
1652    #[inline(always)]
1653    pub(crate) fn target(
1654        &mut self,
1655        names: &dyn LinkTargetResolver,
1656        reader: &Arc<RecordCell>,
1657        slot: usize,
1658    ) -> (&ParsedLink, Option<&ResolvedTarget>) {
1659        let target = Self::resolve(&mut self.target, &self.parsed, names, reader, slot)
1660            .map(|handle| &handle.target);
1661        (&self.parsed, target)
1662    }
1663
1664    /// The link as the reader's own hold reads it — see [`NativeRead::of`]
1665    /// — or `None` for a link that is not that frame's.
1666    #[inline(always)]
1667    pub(crate) fn native_read(
1668        &mut self,
1669        names: &dyn LinkTargetResolver,
1670        reader: &Arc<RecordCell>,
1671        slot: usize,
1672    ) -> Option<(&crate::server::record::DbLink, &ResolvedTarget, NativeRead)> {
1673        let handle = Self::resolve(&mut self.target, &self.parsed, names, reader, slot)?;
1674        let native = handle.native?;
1675        let ParsedLink::Db(db) = &*self.parsed else {
1676            return None;
1677        };
1678        Some((db, &handle.target, native))
1679    }
1680
1681    /// Drop the target handle, leaving the parse. What
1682    /// [`RecordInstance::destroy`] does to every entry, so a record that has
1683    /// left the database holds no other record.
1684    fn release_target(&mut self) {
1685        self.target = None;
1686    }
1687
1688    /// Whether the handle names `cell`.
1689    fn targets(&self, cell: &Arc<RecordCell>) -> bool {
1690        self.target
1691            .as_ref()
1692            .is_some_and(|handle| Arc::ptr_eq(&handle.target.rec, cell))
1693    }
1694}
1695
1696/// The text of link field `field` of `record`, `None` when the link is unset
1697/// — [`RecordInstance::link_text`] over the record alone.
1698fn link_text_of(record: &dyn Record, field: &str) -> Option<String> {
1699    if let Some(text) = record.link_text_ref(field) {
1700        return (!text.is_empty()).then(|| text.to_owned());
1701    }
1702    match record.get_field(field)? {
1703        EpicsValue::String(text) if !text.is_empty() => Some(text.as_str_lossy().into_owned()),
1704        _ => None,
1705    }
1706}
1707
1708/// A link's local target with its field resolved — C's `dbAddr`.
1709#[derive(Clone)]
1710pub(crate) struct ResolvedTarget {
1711    pub(crate) rec: Arc<RecordCell>,
1712    pub(crate) field: FieldAddr,
1713}
1714
1715/// Where a DB link's local target comes from — `PvDatabase` in production.
1716pub(crate) trait LinkTargetResolver {
1717    /// The revision of the name maps (records and aliases). Loaded BEFORE
1718    /// the maps are read, so a handle stamped with it can never be one a
1719    /// later mutation produced under an earlier number.
1720    fn name_revision(&self) -> u64;
1721    /// The local record a `Db` link addresses, with its field resolved, or
1722    /// `None` for a link that is not a local record read (a constant, an
1723    /// external PV, a simple PV).
1724    fn local_target(&self, link: &ParsedLink) -> Option<ResolvedTarget>;
1725}
1726
1727/// A type-erased record instance stored in the database.
1728pub struct RecordInstance {
1729    pub name: String,
1730    pub record: Box<dyn Record>,
1731    pub common: CommonFields,
1732    pub subscribers: HashMap<String, Vec<Subscriber>>,
1733    /// Terminal destruction marker, the [`crate::server::pv::ProcessVariable`]
1734    /// flag's counterpart for a record-backed channel. Set once by
1735    /// [`Self::destroy`], whose only caller is
1736    /// [`crate::server::database::PvDatabase::remove_record`], so *removed
1737    /// from the database* and *destroyed* are one event for both target
1738    /// kinds and a server can sweep them with one uniform test.
1739    destroyed: bool,
1740    // Link parse cache
1741    pub parsed_inp: ParsedLink,
1742    /// The parsed form of each [`Record::multi_input_links`] slot the record
1743    /// has wired, validated against the text the record holds NOW on every
1744    /// read — see [`ParsedInputLink::validated`], the one writer. The record's
1745    /// put paths need no hook: an entry whose text is not the record's text
1746    /// is re-parsed at the next read, so `parsed == parse_link_v2(&text)`
1747    /// holds for every entry by construction. C parses a link once at
1748    /// `dbInitLink`; the port parsed every set `INPA`..`INPL` on every cycle.
1749    pub(crate) parsed_inputs: Vec<Option<ParsedInputLink>>,
1750    pub parsed_out: ParsedLink,
1751    pub parsed_flnk: ParsedLink,
1752    pub parsed_sdis: ParsedLink,
1753    pub parsed_tsel: ParsedLink,
1754    // Device support
1755    pub device: Option<Box<dyn super::super::device_support::DeviceSupport>>,
1756    // Subroutine (for sub records)
1757    pub subroutine: Option<Arc<SubroutineFn>>,
1758    /// The by-name function registry this record's PENDING `init_record` pass
1759    /// 1 will resolve INAM/SNAM against — C `registryFunctionFind`, reading a
1760    /// process-global table from inside `init_record`.
1761    ///
1762    /// One meaning on every path: armed by the creation sink immediately
1763    /// before [`Self::run_init_passes`], consumed (and cleared) by the pass
1764    /// itself. It exists because the lookup's failure is an EARLY RETURN — the
1765    /// init tail must not run past it — and only the init owner can honour
1766    /// that, while only the database holds the registry. Resolving from
1767    /// outside the passes, as both builders used to, put the lookup after the
1768    /// tail it is supposed to skip.
1769    init_subroutines: Option<Arc<HashMap<String, Arc<SubroutineFn>>>>,
1770    /// PACT (C `precord->pact`) — the re-entrancy guard, and the record's
1771    /// "busy" state for every put that lands on it.
1772    ///
1773    /// PRIVATE by construction: entered through [`RecordInstance::enter_pact`]
1774    /// and released ONLY through [`RecordInstance::leave_pact`], which hands
1775    /// back the [`PactExit`] that routes the release to the cycle tail where
1776    /// queued put-notifies are restarted. A `pact.store(false)` open-coded at
1777    /// a release site is what skipped that tail on the ODLY/SDLY paths; it is
1778    /// no longer expressible.
1779    pact: AtomicBool,
1780    // Put-notify wait-set this record currently belongs to (C
1781    // `precord->ppn`). Set when the record joins an active put-notify
1782    // (originating put target, or a FLNK/OUT PP target via `dbNotifyAdd`);
1783    // taken + `leave`d when the record's processing completes. `None`
1784    // outside any put-notify. See [`NotifyWaitSet`].
1785    // Private to the crate: the two writers ([`RecordInstance::
1786    // install_or_queue_notify`] and [`RecordInstance::join_put_notify`]) are
1787    // the slot's only assignment sites, and a `pub` field made a third one
1788    // constructible from outside. Read it with [`RecordInstance::has_notify`].
1789    pub(crate) notify: Option<Arc<NotifyWaitSet>>,
1790    /// C `precord->ppnr->restartList` — put-notifies waiting to take this
1791    /// record, oldest first.
1792    ///
1793    /// `processNotifyCommon` (dbNotify.c:213-219, 225-231) tests both
1794    /// "another processNotify owns the record" and `precord->pact` ABOVE
1795    /// `putCallback`, so a `dbPutNotify` onto a busy record writes nothing:
1796    /// no value, no RPRO. The whole put — value, process, callback — is
1797    /// deferred and restarted later. C queues them with `ellSafeAdd` and
1798    /// promotes one per completion (`restartCheck`, dbNotify.c:149-170); this
1799    /// is that list, and modelling it as a list rather than one slot is what
1800    /// stops the second concurrent `caput -c` being refused with an
1801    /// `ECA_PUTCBINPROG` C never sends.
1802    ///
1803    /// PRIVATE. Appended only by [`Self::queue_notify_put`], drained only by
1804    /// [`Self::take_next_notify_restart`], which pops only onto a record no
1805    /// put-notify owns.
1806    notify_restart_list: std::collections::VecDeque<DeferredNotify>,
1807    /// The value of each subscribed field as ALREADY PUBLISHED to that
1808    /// field's `DBE_VALUE`/`DBE_LOG` subscribers. The generic
1809    /// change-detection loop in every snapshot builder posts a field only
1810    /// when its current value differs from this — so this map is what
1811    /// C's per-record `*_lst` / MARK state is to `monitor()`.
1812    ///
1813    /// # Invariant (CONTRACT)
1814    ///
1815    /// A field's value MUST NOT be published twice by the framework.
1816    /// Concretely: every value-class post (a `db_post_events` carrying
1817    /// `DBE_VALUE` and/or `DBE_LOG`) MUST advance this map for the field it
1818    /// posts, whether or not that field has a subscriber (C's `monitor()`
1819    /// state advances on a post to an empty `mlis`, and a later subscriber
1820    /// inherits it); an alarm-only / property-only post MUST NOT (those classes do
1821    /// not deliver the value to a `DBE_VALUE`/`DBE_LOG` subscriber, so the
1822    /// change is still owed to them).
1823    ///
1824    /// In C, `dbPut` (dbAccess.c:1407-1414) is the record's ONLY post for a
1825    /// put: `db_post_events(precord, pfieldsave, DBE_VALUE|DBE_LOG)`. No
1826    /// record's `monitor()` re-posts that field — it posts a closed set and
1827    /// compares against its own `*_lst` fields. A framework that posts on the
1828    /// put and then change-detects the same field on the next process cycle
1829    /// sends an event C never sends.
1830    ///
1831    /// # Owner
1832    ///
1833    /// [`RecordInstance::record_value_post`] is the SINGLE writer. The field
1834    /// is private so no path outside this module can advance (or fail to
1835    /// advance) it: the snapshot builders read it through
1836    /// [`RecordInstance::posted_value`] and every poster —
1837    /// [`RecordInstance::notify_field_with_origin`] included — advances it
1838    /// through the owner.
1839    last_posted: HashMap<String, EpicsValue>,
1840    /// The live store for a field the record's `.dbd` DECLARES but the record
1841    /// struct has no `put_field` arm / no storage for — the WRITE analog of the
1842    /// read-side [`Self::declared_default`] fallback.
1843    ///
1844    /// C makes every `.dbd` field not just readable but WRITABLE: `dbPutField`
1845    /// resolves the field from its `dbFldDes` and `dbPut` writes the incoming
1846    /// value into record memory, whether or not any record code ever reads it
1847    /// back — a `caput dfanout.HOPR 10` sticks even though `dfanoutRecord.c`
1848    /// never touches HOPR. A Rust record models only the fields it has
1849    /// behaviour for, so a field it declares but never stores had nowhere for a
1850    /// put to land: [`Self::put_common_field`]'s catch-all reported
1851    /// `S_dbLib_fieldNotFound` and the client's put was refused, while a READ of
1852    /// the same field succeeded through `declared_default`. This map is that
1853    /// missing storage — one uniform mechanism for the whole family, not a
1854    /// per-field struct member on each record type.
1855    ///
1856    /// Keyed by upper-case field name, holding the value already coerced to the
1857    /// field's C-declared DBF type (the same projection `declared_default` and
1858    /// the read path serve). [`Self::resolve_field`] reads it BEFORE
1859    /// `declared_default`, so a read reflects a prior write and an untouched
1860    /// field still reads its `.dbd` initial. Empty for a record whose declared
1861    /// fields are all modeled.
1862    declared_overrides: HashMap<String, EpicsValue>,
1863    /// This record's OWN link fields whose target supplies some field's
1864    /// units/precision/graphic/alarm — the distinct answers of
1865    /// [`Record::link_backed_metadata_field`] over the record's declared
1866    /// field list, collected ONCE here.
1867    ///
1868    /// Derived, never declared a second time: the record type states the
1869    /// mapping in one place and this is the reverse index of that one
1870    /// statement, so the two cannot drift the way the central
1871    /// `match rtype` list they replace drifted away from `aSub`.
1872    link_backed_metadata_links: Vec<String>,
1873    /// What this record's TYPE can reach in a process cycle, settled once at
1874    /// construction. See [`ProcessPlan`].
1875    /// C's `UDF_ALARM` guard for this type, or `None` for a type whose C
1876    /// support has no such line (`swait`, `waveform` proper). See
1877    /// [`UdfAlarm`]. Read by [`Self::evaluate_alarms`] under the lock, which
1878    /// is why it lives here and not on the cell's [`ProcessPlan`].
1879    udf_alarm: Option<UdfAlarm>,
1880    /// What this record's TYPE answers to `monitor()`'s type-static
1881    /// questions, settled once at construction. See [`MonitorPlan`].
1882    monitor_plan: MonitorPlan,
1883    /// Where each of those links sits in [`Record::multi_input_links`] — the
1884    /// list a process cycle reads once at its top. Both lists are properties
1885    /// of the record TYPE, so the mapping is fixed here and cannot drift;
1886    /// `None` marks a metadata link the multi-input fetch does not cover,
1887    /// which the cycle then reads for itself. Taken once because the
1888    /// alternative is searching the pre-read list by name, per link, per pass.
1889    link_backed_metadata_input_slot: Vec<Option<usize>>,
1890    /// Does this record type's `.dbd` declare a simulation block — i.e. the
1891    /// SIMM field C's `readValue`/`writeValue` dispatch on?
1892    ///
1893    /// `dbCommon` declares none of SIMM/SIML/SIOL/SIMS/SDLY, so the answer is
1894    /// a property of the record TYPE, and for 18 of the 41 types this port
1895    /// carries (calc, calcout, sub, aSub, sel, seq, fanout, compress,
1896    /// subArray, ...) it is `false`: their C record support has no
1897    /// `readValue`/`writeValue` at all. Taken once here because the process
1898    /// cycle asks it on every pass, where resolving SIMM by name costs a
1899    /// scan of the record's declared field list and `dbCommon`'s.
1900    declares_simulation: bool,
1901    /// Set by `check_deadband_ext` for waveform/aai/aao when their
1902    /// content hash changed this cycle (C `monitor()` On Change mode,
1903    /// waveformRecord.c:310-319). The snapshot builders read it to post
1904    /// `HASH` with a literal `DBE_VALUE` event, independent of the VAL
1905    /// post mask. False for every record without the MPST/APST/HASH
1906    /// mechanism.
1907    pub(crate) array_hash_changed: bool,
1908    /// One-shot "skip the registered subroutine this cycle" signal for aSub
1909    /// `LFLG=READ`. The async processing path resolves the `SUBL` link before
1910    /// taking this lock; when the resolved name is bad (C `fetch_values` ->
1911    /// `S_db_BadSub`) or the link read failed, C `process` runs `do_sub` only
1912    /// on `!status`, so the subroutine is skipped. Set by the resolution
1913    /// apply, consumed (and cleared) by [`Self::run_registered_subroutine`];
1914    /// `false` for every record without a pending bad re-resolution.
1915    pub(crate) suppress_subroutine_run: bool,
1916    /// Generation counter for ReprocessAfter timer cancellation.
1917    /// Bumped each process cycle. Spawned timers check this to avoid
1918    /// stale re-processes from accumulated timers.
1919    pub reprocess_generation: Arc<std::sync::atomic::AtomicU64>,
1920    /// Generation counter for the monitor watchdog
1921    /// ([`Record::watchdog_interval`] / [`Record::watchdog_fire`]), bumped by
1922    /// each `PvDatabase::arm_watchdog` so a re-arm supersedes the tick already
1923    /// in flight — C `callbackRequestDelayed` replacing an outstanding delayed
1924    /// callback. Deliberately NOT `reprocess_generation`: C's histogram wdog is
1925    /// its own `epicsCallback`, independent of the record's SDLY/async
1926    /// re-entry, so an SDLY defer must not cancel the watchdog nor vice versa.
1927    pub watchdog_generation: Arc<std::sync::atomic::AtomicU64>,
1928    /// Per-record info tags from `info("key", "value")` directives in
1929    /// the .db file (epics-base info(...) grammar). Consumers include
1930    /// asyn (`asyn:READBACK`), record-as-PV bridge tags
1931    /// (`Q:group`, `Q:form`), and IOC-specific extensions. Empty for
1932    /// records loaded without info(...) clauses.
1933    pub info: HashMap<String, String>,
1934    /// Cached metadata (display/control/enums) — `None` means stale or
1935    /// not yet built. Populated lazily by `snapshot_for_field` /
1936    /// `make_monitor_snapshot` and invalidated by `invalidate_metadata_cache`
1937    /// whenever a metadata-class field (EGU/PREC/HOPR/LOPR/limit/state)
1938    /// is written.
1939    ///
1940    /// Wrapped in `std::sync::Mutex` for interior mutability — the
1941    /// containing `RecordInstance` is shared via `Arc<RwLock<...>>` from
1942    /// `PvDatabase`, and snapshot construction holds a read lock; the
1943    /// inner Mutex lets us still mutate the cache from a `&self` method.
1944    ///
1945    /// # Cache invariant (CONTRACT)
1946    ///
1947    /// The cache is **only correct under the following contract**: every
1948    /// code path that mutates a cache-source field (the set defined in
1949    /// the file-private [`is_metadata_cache_source`] predicate) MUST call
1950    /// [`RecordInstance::notify_field_written`] (or
1951    /// [`RecordInstance::invalidate_metadata_cache`] directly) afterward.
1952    ///
1953    /// All current write paths in `field_io.rs` already do this. If you
1954    /// add a new code path that:
1955    ///
1956    /// - calls `instance.record.put_field(...)` directly, OR
1957    /// - mutates record fields from inside `Record::process()`,
1958    ///   `Record::on_put`, or `Record::special` and that mutation could
1959    ///   touch a cache-source field, OR
1960    /// - lets a `Box<dyn Record>` implementation expose its own
1961    ///   mutation methods that change cache-source fields,
1962    ///
1963    /// then call `instance.notify_field_written(field_name)` to keep the
1964    /// cache consistent. Forgetting will produce a stale snapshot —
1965    /// monitors will continue to see the old EGU/PREC/limits until the
1966    /// next legitimate cache-source write triggers invalidation.
1967    ///
1968    /// # Symmetric note for `populate_*` extensions
1969    ///
1970    /// If a future change adds a new field to `populate_display_info`,
1971    /// `populate_control_info`, or `populate_enum_info`, the new source
1972    /// field name MUST also be added to [`is_metadata_cache_source`] so
1973    /// writes to it invalidate the cache — unless, like DESC
1974    /// (`display.description`), its write owner invalidates directly (see
1975    /// the DESC arm of `put_common_field`). This set says nothing about
1976    /// `DBE_PROPERTY`, which the field's own `prop(YES)` declaration
1977    /// decides ([`RecordInstance::field_posts_property`]). (The `Q:form`
1978    /// -> `display.form` mapping is exempt: it reads an immutable
1979    /// load-time info tag, not a runtime field.)
1980    pub(crate) metadata_cache: StdMutex<Option<MetadataSnapshot>>,
1981}
1982
1983/// The cycle status [`RecordInstance::run_registered_subroutine`] reports when
1984/// `do_sub` was skipped — C's `fetch_values` failure / `S_db_BadSub` path, which
1985/// leaves `process`'s `status` non-zero (aSubRecord.c:216-224).
1986const SUBROUTINE_STATUS_SKIPPED: i64 = -1;
1987/// Which C `do_sub` a record type owns. Only `subRecord.c` and `aSubRecord.c`
1988/// define one; every other record type reaches
1989/// [`RecordInstance::run_registered_subroutine`] with no subroutine bound for
1990/// the trivial reason that it never had one, and must not be handed `do_sub`'s
1991/// bad-sub verdict. Resolving the kind once also keeps the two record types'
1992/// three points of divergence (empty-SNAM exemption, bad-sub status, UDF
1993/// clear) reading off one decision instead of three `record_type()` compares.
1994#[derive(Clone, Copy, PartialEq, Eq)]
1995enum SubroutineKind {
1996    /// `subRecord.c::do_sub` — VAL is the subroutine's computed value.
1997    Sub,
1998    /// `aSubRecord.c::do_sub` — VAL is the returned status.
1999    ASub,
2000}
2001
2002impl SubroutineKind {
2003    fn of(record_type: &str) -> Option<Self> {
2004        match record_type {
2005            "sub" => Some(Self::Sub),
2006            "aSub" => Some(Self::ASub),
2007            _ => None,
2008        }
2009    }
2010}
2011
2012/// C `S_db_BadSub` — `(M_dbAccess | 35)` with `M_dbAccess = 511 << 16`
2013/// (`dbAccessDefs.h:189`, `errMdef.h:39`), i.e. 33488931. aSub's `do_sub`
2014/// returns it verbatim for an unregistered SNAM and `process` publishes it as
2015/// VAL, so the number is observable on the wire and cannot be a private
2016/// sentinel.
2017const S_DB_BAD_SUB: i64 = (511 << 16) | 35;
2018/// The bound subroutine returned `Err` — no C counterpart (a C subroutine
2019/// returns a `long`), and a failed cycle either way.
2020const SUBROUTINE_STATUS_ERROR: i64 = -3;
2021
2022/// What a record TYPE answers to the type-static questions C `monitor()`
2023/// asks about it — the deadband field and its mask narrowings, the value
2024/// gate, and whether the record posts its value at all. Every answer is a
2025/// property of the type, so it is read once at construction rather than
2026/// through the `dyn Record` vtable on every cycle. The per-cycle questions
2027/// (`monitor_value_changed`, `process_posted_fields`, the `take_*` one-shot
2028/// masks) stay dynamic.
2029#[derive(Clone, Copy)]
2030pub(crate) struct MonitorPlan {
2031    /// [`Record::process_posts_value_monitor`].
2032    pub(crate) posts_value_monitor: bool,
2033    /// [`Record::uses_monitor_deadband`].
2034    pub(crate) uses_deadband: bool,
2035    /// [`Record::monitor_deadband_field`].
2036    pub(crate) deadband_field: &'static str,
2037    /// The deadband field is in [`Record::value_only_change_fields`] or
2038    /// [`Record::fields_posted_with_monitor_mask`]: C posts it without
2039    /// `DBE_LOG`.
2040    pub(crate) log_suppressed: bool,
2041    /// [`Record::fields_posted_with_value_mask`].
2042    pub(crate) value_masked: &'static [(&'static str, crate::server::record::ValuePostGate)],
2043    /// The change-detected aux fields' mask resolver.
2044    pub(crate) aux_post: AuxPostMask,
2045}
2046
2047impl MonitorPlan {
2048    pub(crate) fn of(record: &dyn Record) -> Self {
2049        let deadband_field = record.monitor_deadband_field();
2050        Self {
2051            posts_value_monitor: record.process_posts_value_monitor(),
2052            uses_deadband: record.uses_monitor_deadband(),
2053            deadband_field,
2054            log_suppressed: record.value_only_change_fields().contains(&deadband_field)
2055                || record
2056                    .fields_posted_with_monitor_mask()
2057                    .contains(&deadband_field),
2058            value_masked: record.fields_posted_with_value_mask(),
2059            aux_post: AuxPostMask::of(record),
2060        }
2061    }
2062}
2063
2064/// What one `monitor()` hands its publisher: the value snapshot (only the
2065/// fields somebody subscribes to carry a value) and the `recGblResetAlarms`
2066/// posts with their per-field C masks.
2067pub(crate) struct MonitorOutcome {
2068    pub(crate) snapshot: ProcessSnapshot,
2069    pub(crate) alarm_posts: crate::server::database::AlarmPosts,
2070}
2071
2072/// C `monitor()`'s post of the deadband field, as assembled by the single owner
2073/// [`RecordInstance::deadband_post`].
2074pub(crate) struct DeadbandPost {
2075    /// C's `monitor_mask` for this cycle. Also the mask the
2076    /// [`Record::fields_posted_with_value_mask`] secondaries ride: C posts them
2077    /// from INSIDE the `if (monitor_mask)` guard, with the same mask.
2078    pub mask: EventMask,
2079    /// The deadband field's own post — `(field, value)`. `None` when no class
2080    /// fired (C's `if (monitor_mask)` skips the post) or the field does not
2081    /// resolve. The name is the one [`Record::monitor_deadband_field`] already
2082    /// hands out as a `&'static str`; owning a copy of it charged a malloc per
2083    /// cycle for a string that is in the binary.
2084    pub field: Option<(&'static str, EpicsValue)>,
2085}
2086
2087/// A value's `DBR_STRING` form, for a source whose field metadata is NOT
2088/// reachable (an external CA/PVA link, a constant, an lnkCalc result) — the
2089/// fallback half of [`RecordInstance::field_as_dbr_string`], which is also the
2090/// whole rule once the local choice table has had its say.
2091///
2092/// A pvalink NTEnum still resolves its label here: the carrier brings its own
2093/// `choices` (pvxs `pvxs/ioc/pvalink_lset.cpp:344-356` — a `DBR_STRING` target copies
2094/// `choices[index]`). A bare `Enum` index from a link whose labels the port
2095/// cannot reach falls back to its decimal form, like the CA `*_STRING` encoder.
2096/// **The** declaration lookup: a field's `dbFldDes`, in C's terms.
2097///
2098/// The record type's declaration is [`FieldDeclaration::field_list`] — the
2099/// generated `.dbd` table where one exists and the hand-written table where it
2100/// does not, never both. `dbCommon` is asked last, so a record-specific field
2101/// shadows the common one.
2102///
2103/// A free function, not just a [`RecordInstance`] method, because the sites that
2104/// need the declaration do not all hold an instance — a constant-link seed, a
2105/// link write, the db loader all have `&dyn Record`.
2106///
2107/// `None` for a field with no declaration at all: a virtual field (`RTYP`,
2108/// `TIME`, ...), which C answers from dbStaticLib rather than from a `dbFldDes`.
2109pub(crate) fn field_desc_of<R: Record + ?Sized>(
2110    record: &R,
2111    field: &str,
2112) -> Option<&'static FieldDesc> {
2113    field_desc_in(record.field_list(), field)
2114}
2115
2116/// [`field_desc_of`] against a type's table — C `dbFindFieldPart`: the
2117/// record type's own `.dbd` table, then `dbCommon`.
2118pub(crate) fn field_desc_in(
2119    fields: &'static [FieldDesc],
2120    field: &str,
2121) -> Option<&'static FieldDesc> {
2122    let named = |t: &'static [FieldDesc]| t.iter().find(|f| f.name.eq_ignore_ascii_case(field));
2123    named(fields).or_else(|| named(super::dbd_generated::DB_COMMON_FIELDS))
2124}
2125
2126/// **The single owner of "which choice list does this field resolve against"**,
2127/// asked by BOTH sides of a menu field:
2128///
2129/// * the READ side ([`RecordInstance::enum_string_form_for`]) — C `getMenuString`,
2130///   which renders the stored index as its choice;
2131/// * the WRITE side ([`crate::server::record::coerce_put_value`]) — C
2132///   `putStringMenu`, which resolves an incoming label to that same index.
2133///
2134/// They MUST see the same list or the field is not round-trippable. The write
2135/// side used to ask only [`Record::menu_field_choices`] (the record's hand
2136/// table), so an `aSub`'s `caput FTA LONG` found no menu, fell through to the
2137/// numeric parse and landed as index 0 — while the read side, on the `.dbd`
2138/// menu, rendered index 0 as `STRING`.
2139///
2140/// Order is C's: the field's own `menu()` from the declaration, then the
2141/// record's hand table where the `.dbd` does not reach (the downstream crates'
2142/// record types), then the `dbCommon` menus.
2143///
2144/// The last step — [`shared_menu_choices`](super::menu_choices::shared_menu_choices)
2145/// — is a heuristic keyed on the field NAME (`OSV`, `SIMS`, `HHSV`, …), so it is
2146/// consulted ONLY when the field's declaration does not already pin a non-menu
2147/// type: a menu is served as `DBR_ENUM`, so a field DECLARED `DBF_STRING` is
2148/// never a menu. Without this gate scalcout's string `OSV` ("Output string
2149/// value") matched the name-based `menuAlarmSevr` entry a same-named bi/bo
2150/// severity field owns, and `caput SCALCOUT.OSV <string>` was rejected with
2151/// `S_db_badChoice` where C accepts the string.
2152pub(crate) fn menu_choices_of<R: Record + ?Sized>(
2153    record: &R,
2154    field: &str,
2155) -> Option<&'static [&'static str]> {
2156    // `DTYP` is `DBF_DEVICE`: its choices are the record type's DEVICE menu
2157    // (C `dbDeviceMenu`, built from the `device()` declarations), which is
2158    // per-record-type and so cannot live in the shared `dbCommon` FieldDesc.
2159    if field.eq_ignore_ascii_case("DTYP") {
2160        return super::dbd_generated::device_menu(record.record_type());
2161    }
2162    let desc = field_desc_of(record, field);
2163    desc.and_then(|f| f.menu)
2164        .or_else(|| record.menu_field_choices(field))
2165        .or_else(|| {
2166            // Name-based fallback — but the declared type wins: a `DBF_STRING`
2167            // field is not a menu even when a same-named field elsewhere is.
2168            if desc.is_some_and(|f| f.dbf_type == DbFieldType::String) {
2169                None
2170            } else {
2171                super::menu_choices::shared_menu_choices(field)
2172            }
2173        })
2174}
2175
2176/// The `DBF_*` type `field` is SERVED as, for a caller that holds only a
2177/// `&dyn Record` — the free-function form of
2178/// [`RecordInstance::declared_field_type`], and the ONLY way any site outside
2179/// the instance may turn a [`FieldDesc`] into a type.
2180///
2181/// A [`FieldDesc::runtime_typed`] field (`waveform.VAL` typed by `FTVL`, an
2182/// `aSub`'s `A`..`U` typed by `FTA`..`FTU`) has NO type in its declaration: C's
2183/// `cvt_dbaddr` overwrites `paddr->field_type` from record state, so the `.dbd`
2184/// entry is a placeholder and the value the record stores is the answer. Every
2185/// caller falls back to that value, which is why this returns `None` rather than
2186/// the placeholder — handing out `DBF_DOUBLE` for a `FTVL=CHAR` waveform is how
2187/// a string written down an output link became `0.0`.
2188pub(crate) fn declared_field_type_of<R: Record + ?Sized>(
2189    record: &R,
2190    field: &str,
2191) -> Option<DbFieldType> {
2192    let desc = field_desc_of(record, field)?;
2193    (!desc.runtime_typed).then_some(desc.dbf_type)
2194}
2195
2196pub(crate) fn value_as_dbr_string(value: &EpicsValue) -> Option<PvString> {
2197    match value {
2198        EpicsValue::String(s) => Some(s.clone()),
2199        EpicsValue::Enum(v) => Some(PvString::from(v.to_string())),
2200        EpicsValue::EnumWithChoices { index, choices } => Some(
2201            choices
2202                .get(*index as usize)
2203                .cloned()
2204                .unwrap_or_else(|| PvString::from(index.to_string())),
2205        ),
2206        other => match other.clone().convert_to(DbFieldType::String) {
2207            EpicsValue::String(s) => Some(s),
2208            _ => None,
2209        },
2210    }
2211}
2212
2213/// The `dbCommon` link fields, each with the C link-field type its text is
2214/// parsed under (`dbStaticLib.c:2380-2391`): `INP`/`TSEL`/`SDIS` are
2215/// `DBF_INLINK`, `OUT` is `DBF_OUTLINK`, `FLNK` is `DBF_FWDLINK`.
2216///
2217/// These five — and only these five — have a parse cache on
2218/// [`RecordInstance`], which is what lets a one-shot init decision (C
2219/// `dbInitLink` setting `DBLINK_FLAG_INITIALIZED`) be committed for them.
2220/// The list has one owner because it is read from three places that must not
2221/// drift: the per-field parse in `put_common_field`, the database's
2222/// `record_link_fields` enumeration, and the `initialize_link_locality`
2223/// commit. `FLNK` missing from just one of them is exactly how an external
2224/// forward link went un-opened at init.
2225pub const COMMON_LINK_FIELDS: [(&str, super::link::LinkFieldType); 5] = [
2226    ("INP", super::link::LinkFieldType::In),
2227    ("OUT", super::link::LinkFieldType::Out),
2228    ("TSEL", super::link::LinkFieldType::In),
2229    ("SDIS", super::link::LinkFieldType::In),
2230    ("FLNK", super::link::LinkFieldType::Fwd),
2231];
2232
2233impl RecordInstance {
2234    pub fn new(name: String, record: impl Record) -> Self {
2235        Self::new_boxed(name, Box::new(record))
2236    }
2237
2238    /// The raw text of one `COMMON_LINK_FIELDS` entry, or `None` for any
2239    /// other field name.
2240    pub fn common_link_text(&self, field: &str) -> Option<&str> {
2241        Some(match field {
2242            "INP" => self.common.inp.as_str(),
2243            "OUT" => self.common.out.as_str(),
2244            "TSEL" => self.common.tsel.as_str(),
2245            "SDIS" => self.common.sdis.as_str(),
2246            "FLNK" => self.common.flnk.as_str(),
2247            _ => return None,
2248        })
2249    }
2250
2251    /// The parse cache of one `COMMON_LINK_FIELDS` entry, or `None` for any
2252    /// other field name. The only mutable handle on the cache outside
2253    /// `put_common_field`, so the iocInit locality commit cannot reach a slot
2254    /// that has no matching raw text.
2255    pub fn common_link_cache_mut(&mut self, field: &str) -> Option<&mut ParsedLink> {
2256        Some(match field {
2257            "INP" => &mut self.parsed_inp,
2258            "OUT" => &mut self.parsed_out,
2259            "TSEL" => &mut self.parsed_tsel,
2260            "SDIS" => &mut self.parsed_sdis,
2261            "FLNK" => &mut self.parsed_flnk,
2262            _ => return None,
2263        })
2264    }
2265
2266    /// The link fields whose target metadata this record's rset serves — the
2267    /// work list [`PvDatabase::resolve_link_backed_metadata`] resolves for a
2268    /// batch post, and the set `Self::link_backed_metadata_field_of` answers
2269    /// one field out of.
2270    ///
2271    /// [`PvDatabase::resolve_link_backed_metadata`]: crate::server::database::PvDatabase
2272    pub fn link_backed_metadata_links(&self) -> &[String] {
2273        &self.link_backed_metadata_links
2274    }
2275
2276    /// Where each [`Self::link_backed_metadata_links`] entry sits in the
2277    /// record's multi-input link list, in that same order. See the field.
2278    pub(crate) fn link_backed_metadata_input_slots(&self) -> &[Option<usize>] {
2279        &self.link_backed_metadata_input_slot
2280    }
2281
2282    /// C `monitor()` and the `recGblResetAlarms` it opens with, for every
2283    /// path that ends a process cycle. The single owner of the cycle's
2284    /// monitor STATE — alarm commit, MLST/ALST, the previous-value store, the
2285    /// record's one-shot post masks — and of the posts it hands the
2286    /// publisher. The state half runs whether or not anyone subscribes;
2287    /// the per-field payload is built by [`Self::collect_subscriber_posts`]
2288    /// only for subscribed fields, decided under the guard the caller
2289    /// delivers with, so no post is decided on one lock and delivered on
2290    /// another.
2291    pub(crate) fn monitor_cycle(&mut self) -> MonitorOutcome {
2292        let alarm_result = crate::server::recgbl::rec_gbl_reset_alarms(&mut self.common);
2293        // C `recGblResetAlarms` returns `val_mask = DBE_ALARM`
2294        // (recGbl.c:194/203/212) when the severity/status OR the alarm
2295        // message moved — every monitored-value post this cycle carries
2296        // DBE_ALARM so a `DBE_ALARM`-only subscriber sees the value at the
2297        // moment the alarm changed.
2298        let alarm_bits = if alarm_result.alarm_changed || alarm_result.amsg_changed {
2299            EventMask::ALARM
2300        } else {
2301            EventMask::NONE
2302        };
2303
2304        let (include_val, include_archive) = self.value_include_classes();
2305        // The deadband-tracked field posts with the classes that actually
2306        // fired: MDEL crossing → DBE_VALUE, ADEL crossing → DBE_LOG, alarm
2307        // movement → DBE_ALARM — and nothing else (C `monitor()` per-field
2308        // masks: motorRecord.cc:3476-3507 RBV, aiRecord.c VAL). A record
2309        // like motor deadbands its readback; its VAL then routes through
2310        // the change-detected loop in `collect_subscriber_posts`.
2311        let deadband = self.deadband_post(alarm_bits, include_val, include_archive);
2312        let mut snapshot = ProcessSnapshot::new();
2313        if let Some((field, value)) = deadband.field {
2314            snapshot.push((field.into(), value, deadband.mask));
2315        }
2316        self.collect_subscriber_posts(&mut snapshot, deadband.mask, alarm_bits, include_val);
2317        // C waveform/aai/aao `monitor()` posts HASH with a literal
2318        // `DBE_VALUE` only on a content-hash change (waveformRecord.c:
2319        // 317-319), independent of the VAL post mask. `array_hash_changed`
2320        // was set by `check_deadband_ext` this cycle.
2321        if self.array_hash_changed {
2322            if let Some(h) = self.resolve_field("HASH") {
2323                snapshot.push(("HASH".into(), h, EventMask::VALUE));
2324            }
2325        }
2326        // NO `.UDF` post. C `monitor()` never posts UDF, and neither does
2327        // `recGblResetAlarms` (recGbl.c:202-222 posts SEVR/STAT/AMSG/ACKS
2328        // only). UDF reaches a `.UDF` subscriber only through the generic
2329        // put path (C `dbPut` posts the field it wrote, dbAccess.c:1411-1413).
2330        let alarm_posts = crate::server::database::alarm_field_posts(&self.common, &alarm_result);
2331        MonitorOutcome {
2332            snapshot,
2333            alarm_posts,
2334        }
2335    }
2336
2337    /// This cycle's forward link, resolved from the two things C's
2338    /// `dbScanFwdLink` reads: the record's own veto
2339    /// ([`Record::should_fire_forward_link`]) and `FLNK`'s parsed kind.
2340    ///
2341    /// The single owner of that question. The DB half and the external half
2342    /// used to be derived at separate points — the DB name under the monitor
2343    /// segment's guard, the external PV by `dispatch_external_forward_link`
2344    /// re-taking the record's read lock in the tail — so every record in the
2345    /// database paid a second acquisition and a second `should_fire_forward_link`
2346    /// call once a cycle, and the two derivations could disagree about a
2347    /// record whose veto changed in between.
2348    pub(crate) fn forward_target(&self) -> ForwardTarget {
2349        if !self.record.should_fire_forward_link() {
2350            return ForwardTarget::None;
2351        }
2352        match &self.parsed_flnk {
2353            ParsedLink::Db(l) => ForwardTarget::Db {
2354                name: l.target().record.clone(),
2355                putf: self.common.putf,
2356                notify: self.notify.clone(),
2357            },
2358            ParsedLink::Pva(_) | ParsedLink::PvaJson(_) | ParsedLink::Ca(_) => self
2359                .parsed_flnk
2360                .external_pv_name()
2361                .map_or(ForwardTarget::None, |s| {
2362                    ForwardTarget::External(s.to_string())
2363                }),
2364            // Constant / Hw / Calc / None carry no forward action.
2365            _ => ForwardTarget::None,
2366        }
2367    }
2368
2369    /// Does this record's `.dbd` declare the simulation block — the gate C's
2370    /// `readValue`/`writeValue` exist behind. See the field.
2371    pub(crate) fn declares_simulation(&self) -> bool {
2372        self.declares_simulation
2373    }
2374
2375    /// The parse of multi-input link `slot` for a reader that holds the
2376    /// record shared: the cached one when its text is the record's current
2377    /// text, a fresh parse otherwise — never written back, since the fetch
2378    /// stage that runs under the exclusive guard validates the entry itself.
2379    pub(crate) fn cached_multi_input(
2380        &self,
2381        slot: usize,
2382        link_field: &str,
2383    ) -> Option<Arc<ParsedLink>> {
2384        let owned;
2385        let text: &str = match self.record.link_text_ref(link_field) {
2386            Some("") => return None,
2387            Some(text) => text,
2388            None => {
2389                owned = self.link_text(link_field)?;
2390                &owned
2391            }
2392        };
2393        match self.parsed_inputs.get(slot) {
2394            Some(Some(entry)) if entry.text == text => Some(entry.parsed.clone()),
2395            _ => Some(Arc::new(parse_link_v2(text))),
2396        }
2397    }
2398
2399    /// The text of a link field, `None` when the link is unset.
2400    ///
2401    /// A declared link is unset far more often than set — a `calc` declares 21
2402    /// inputs and a stock database wires none of them — and the process cycle
2403    /// asks every declared link on every pass, so the unset answer is the one
2404    /// that has to be cheap. Returning `Option` rather than an empty `String`
2405    /// is what makes it so at the call sites: there is no empty text to test,
2406    /// so nothing the caller wants only for a set link — a cloned field name,
2407    /// a parse, a `Vec` entry — can be built before the answer is known. Each
2408    /// of the six link reads this replaced did build something first and throw
2409    /// it away.
2410    #[inline]
2411    pub(crate) fn link_text(&self, field: &str) -> Option<String> {
2412        link_text_of(&*self.record, field)
2413    }
2414
2415    /// Whether link field `field` holds a text — [`Self::link_text`] without
2416    /// the copy.
2417    pub(crate) fn link_is_set(&self, field: &str) -> bool {
2418        match self.record.link_text_ref(field) {
2419            Some(text) => !text.is_empty(),
2420            None => self.link_text(field).is_some(),
2421        }
2422    }
2423
2424    pub fn new_boxed(name: String, record: Box<dyn Record>) -> Self {
2425        let rtype = record.record_type();
2426        // The reverse index of `Record::link_backed_metadata_field`, built once
2427        // from the record's own declaration so no second list can go stale.
2428        // Empty for every record type that answers `None` — which is all but
2429        // calc, calcout, sub, seq and aSub.
2430        let link_backed_metadata_links: Vec<String> = {
2431            use crate::server::record::FieldDeclaration;
2432            let mut links: Vec<String> = record
2433                .field_list()
2434                .iter()
2435                .filter_map(|d| record.link_backed_metadata_field(d.name))
2436                .collect();
2437            links.sort_unstable();
2438            links.dedup();
2439            links
2440        };
2441        // See the field's own doc: fixed by the record TYPE, both sides of it.
2442        let link_backed_metadata_input_slot: Vec<Option<usize>> = link_backed_metadata_links
2443            .iter()
2444            .map(|lf| {
2445                record
2446                    .multi_input_links()
2447                    .iter()
2448                    .position(|(mf, _)| mf == lf)
2449            })
2450            .collect();
2451        // The gate on the whole simulation block — see the field's own doc.
2452        let declares_simulation = field_desc_of(record.as_ref(), "SIMM").is_some();
2453        let udf_alarm = record.raises_udf_alarm().then(|| UdfAlarm {
2454            exact_one: record.udf_alarm_on_exact_one(),
2455            severity: record.udf_alarm_severity(),
2456            message: record.udf_alarm_message(),
2457        });
2458        let monitor_plan = MonitorPlan::of(record.as_ref());
2459        let analog_alarm = match rtype {
2460            // C parity: every record type whose dbd carries
2461            // HIHI/HIGH/LOW/LOLO/HHSV/HSV/LSV/LLSV gets an analog-alarm
2462            // config slot. Previously calc / calcout were missing —
2463            // their put_field for those fields silently no-op'd
2464            // because `self.common.analog_alarm` was None at the
2465            // mutation site. Confirmed via
2466            // calcRecord.dbd.pod:716-744 (HIHI..LLSV) and
2467            // calcoutRecord.dbd.pod:1103+ (same). `sub` carries the same
2468            // HIHI/HIGH/LOLO/LOW + HHSV/HSV/LSV/LLSV set
2469            // (subRecord.dbd.pod:569-642) and runs the analog `checkAlarms`.
2470            // `scalcout` declares the identical set (`sCalcoutRecord.dbd:479-531`
2471            // HIHI/LOLO/HIGH/LOW/HHSV/LLSV/HSV/LSV/HYST + `:858` LALM) and its
2472            // `checkAlarms` (`sCalcoutRecord.c:699-752`) is the same ladder, run
2473            // BEFORE the OOPT switch (`:374`) precisely so a limit excursion can
2474            // drive IVOA. Without the slot the record had no alarm surface at
2475            // all: `caput scalc.HIHI 5` was a `FieldNotFound` and a scalcout
2476            // could never go MINOR/MAJOR on its own result.
2477            //
2478            // **This match is the single owner of "which records have the analog
2479            // ladder"** — `evaluate_alarms` runs it off the slot's presence, so a
2480            // record added here gets the ladder and one absent cannot.
2481            "ai" | "ao" | "longin" | "longout" | "int64in" | "int64out" | "calc" | "calcout"
2482            | "sub" | "scalcout" => Some(AnalogAlarmConfig::default()),
2483            _ => None,
2484        };
2485        let mut common = CommonFields::default();
2486        common.analog_alarm = analog_alarm;
2487        let multi_input_slots = record.multi_input_links().len();
2488
2489        Self {
2490            destroyed: false,
2491            name,
2492            record,
2493            common,
2494            subscribers: HashMap::new(),
2495            parsed_inp: ParsedLink::None,
2496            parsed_out: ParsedLink::None,
2497            parsed_flnk: ParsedLink::None,
2498            parsed_sdis: ParsedLink::None,
2499            parsed_tsel: ParsedLink::None,
2500            parsed_inputs: (0..multi_input_slots).map(|_| None).collect(),
2501            device: None,
2502            subroutine: None,
2503            init_subroutines: None,
2504            pact: AtomicBool::new(false),
2505            notify: None,
2506            notify_restart_list: std::collections::VecDeque::new(),
2507            last_posted: HashMap::new(),
2508            declared_overrides: HashMap::new(),
2509            link_backed_metadata_links,
2510            link_backed_metadata_input_slot,
2511            declares_simulation,
2512            udf_alarm,
2513            monitor_plan,
2514            array_hash_changed: false,
2515            suppress_subroutine_run: false,
2516            reprocess_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
2517            watchdog_generation: Arc::new(std::sync::atomic::AtomicU64::new(0)),
2518            info: HashMap::new(),
2519            metadata_cache: StdMutex::new(None),
2520        }
2521    }
2522
2523    /// **The owner of a record's init passes** — C `iocInit.c::doInitRecord0`
2524    /// (`:508-536`) and `doInitRecord1`. Nothing else may call
2525    /// `Record::init_record`.
2526    ///
2527    /// C runs a prologue on EVERY record before pass 0, and it is the reason
2528    /// this is one function instead of two `init_record` calls at each caller:
2529    ///
2530    /// ```c
2531    /// /* Reset the process active field */
2532    /// precord->pact = FALSE;
2533    ///
2534    /// /* Initial UDF severity */
2535    /// if (precord->udf && precord->stat == UDF_ALARM)
2536    ///     precord->sevr = precord->udfs;
2537    /// ```
2538    ///
2539    /// A record is born `udf = 1`, `stat = UDF_ALARM` (dbCommon.dbd
2540    /// `initial("UDF")`), `udfs = INVALID` — so after `iocInit` a record that
2541    /// has NEVER processed advertises `STAT=UDF SEVR=INVALID`, not
2542    /// `NO_ALARM`. That is what makes an `MS` consumer inherit
2543    /// `LINK`/`INVALID` from a not-yet-processed source, the IOC-startup
2544    /// ordering case MS exists for (softIoc-verified). A record whose
2545    /// `init_record` or device support defines the value clears UDF and the
2546    /// severity goes away on its first process.
2547    ///
2548    /// `name` is used only for the init-failure diagnostics C sends to errlog.
2549    ///
2550    /// Crate-private on purpose: the passes must run against the record's FINAL
2551    /// loaded field set (the initial UDF severity is a function of UDF/STAT/
2552    /// UDFS, and a `.db` `field(VAL,…)` clears UDF at load — C
2553    /// `dbStaticLib.c:2653-2661`). The one caller is the creation sink,
2554    /// [`crate::server::database::PvDatabase::add_loaded_record`], which takes
2555    /// the load and the record together so no path can init a half-loaded
2556    /// record.
2557    /// Hand the record the function registry its pending init pass 1 resolves
2558    /// INAM/SNAM against. The creation sink's line; see [`Self::
2559    /// init_subroutines`].
2560    pub(crate) fn arm_init_subroutines(
2561        &mut self,
2562        registry: Arc<HashMap<String, Arc<SubroutineFn>>>,
2563    ) {
2564        self.init_subroutines = Some(registry);
2565    }
2566
2567    /// C's `if (!pdset) { recGblRecordError(S_dev_noDSET, prec, "init_record");
2568    /// return S_dev_noDSET; }` — whether this record's `init_record` gets past
2569    /// its own first statement.
2570    ///
2571    /// Three terms, each one C's:
2572    /// * [`crate::server::recgbl::dev_sup_refusal`] IS the set of record types
2573    ///   whose `init_record` opens with that test — the same table that
2574    ///   supplies the message, so the refusal and the return can never
2575    ///   disagree about which types make it.
2576    /// * a soft DTYP (`""`, `Soft Channel`, `Raw Soft Channel`, `Async Soft
2577    ///   Channel`) resolves to a dset C always links, so the test passes.
2578    ///   `""` counts because C's DTYP index 0 is the record type's FIRST
2579    ///   `device()` line, which for every soft record type is the soft
2580    ///   channel.
2581    /// * anything else needs a device the resolver produced. `None` after the
2582    ///   creation sink has run its bind is C's `pdevSup == NULL`.
2583    pub(crate) fn init_record_reaches_body(&self) -> bool {
2584        self.device.is_some()
2585            || self.common.dtyp.is_soft()
2586            || crate::server::recgbl::dev_sup_refusal(self.record.record_type()).is_none()
2587    }
2588
2589    /// C `registryFunctionFind` inside `init_record` pass 1, for the record
2590    /// types that make it. Returns whether the pass reached its tail.
2591    ///
2592    /// Unarmed (`init_subroutines` is `None`) means no registry was handed
2593    /// over — an iocsh `dbLoadRecords` merge re-running the passes on a record
2594    /// that already resolved. Nothing to look up again, and C's tail is
2595    /// reached.
2596    fn resolve_init_subroutine(&mut self, name: &str) -> bool {
2597        match self.init_subroutines.take() {
2598            Some(registry) => crate::server::ioc_app::wire_subroutine(self, name, &registry),
2599            None => true,
2600        }
2601    }
2602
2603    pub(crate) fn run_init_passes(&mut self, name: &str) {
2604        // C's `precord->pact = FALSE` — a record cannot be mid-process at init,
2605        // so this release provably frees nothing: no client put has run, so the
2606        // restart list is empty.
2607        debug_assert!(
2608            self.notify_restart_list.is_empty(),
2609            "a record cannot hold a queued put-notify at init"
2610        );
2611        let _ = self.leave_pact();
2612        if self.common.udf != 0
2613            && self.common.stat == crate::server::recgbl::alarm_status::UDF_ALARM
2614        {
2615            self.common.sevr = AlarmSeverity::from_u16(self.common.udfs as u16);
2616        }
2617        // C `<rec>Record.c::init_record`'s FIRST statement, for the 26 record
2618        // types that make it: `if (!pdset) { recGblRecordError(S_dev_noDSET,
2619        // …); return S_dev_noDSET; }` (`aiRecord.c:105-110`,
2620        // `aoRecord.c:107-110`, …). Everything below it — `ao`'s `prec->init =
2621        // TRUE`, `ai`'s and `sub`'s MLST/ALST/LALM seed, `mbbo`'s SDEF fold —
2622        // is unreachable for a record whose dset is NULL, and running the
2623        // passes anyway is what left those cells set where softIoc reads 0.
2624        //
2625        // It is the OWNER's test, not each record's, for one structural
2626        // reason: `Record::init_record` is on the record BODY, which cannot
2627        // see `RecordInstance::device`. Asking every record type to re-derive
2628        // the answer is the per-cell patch this replaces.
2629        if !self.init_record_reaches_body() {
2630            return;
2631        }
2632        if let Err(e) = self.record.init_record(0) {
2633            eprintln!("init_record(0) failed for {name}: {e}");
2634        }
2635        // C `iocInit.c::doResolveLinks` (`:545-570`), the pass BETWEEN the two
2636        // `init_record` calls: for each device link the record type declares,
2637        // `pdsxt->add_record(precord)` and then `dbInitLink` for that same
2638        // link. This is that call, and it keeps C's position — before the link
2639        // it complains about is initialised, after pass 0 has run.
2640        crate::server::builtin_devices::soft_callback::add_record(self, name);
2641        if let Err(e) = self.record.init_record(1) {
2642            eprintln!("init_record(1) failed for {name}: {e}");
2643        }
2644        // C `pdset->common.init_record(prec)` — the driver's own init, which
2645        // every record type calls from INSIDE `init_record` once the dset test
2646        // above has passed (`aiRecord.c:115-124`, `aoRecord.c:121-133`). It
2647        // ran after both passes and after the constant-link seed while device
2648        // support was attached by a whole-database pass; it is here now
2649        // because the dset is bound before the passes. Residual difference
2650        // from C, not closed by this move: C calls it BEFORE the record's own
2651        // tail (`prec->mlst = prec->val`) and the port's tail is inside
2652        // `init_record(1)`, so a driver `init` that defines VAL is still not
2653        // reflected in the trackers seeded a line earlier.
2654        crate::server::device_support::init_device_support(self);
2655        // C `subRecord.c:107-129` / `aSubRecord.c:139-160` — the INAM call and
2656        // the SNAM lookup, also inside pass 1. `false` is C's early return
2657        // (`S_db_BadSub`, or the empty-SNAM `pact = TRUE; return 0`), so the
2658        // tail below is skipped exactly where C skips it.
2659        if !self.resolve_init_subroutine(name) {
2660            return;
2661        }
2662        // The UDF tail of pass 1. `init_record` cannot reach UDF (a common
2663        // field), so the record types whose C `init_record` ends in
2664        // `prec->udf = FALSE` — histogram's `clear_histogram`, aao's constant
2665        // DOL, mbboDirect's B0..B1F fold (epics-base dabcf89) — deliver it
2666        // through this hook instead. It lives HERE, inside the init owner,
2667        // because it is part of the same C pass: a creation path that ran the
2668        // passes but skipped the tail (iocsh `dbLoadRecords` did) left those
2669        // records UDF=1 where C has UDF=0.
2670        // The `post_init_finalize_undef` hook is a cross-crate record-trait API
2671        // over a `bool` (histogram/aao/mbboDirect implement it); bridge the raw
2672        // `u8` carrier through it here at the single init owner.
2673        let mut udf = self.common.udf != 0;
2674        if let Err(e) = self.record.post_init_finalize_undef(&mut udf) {
2675            eprintln!("post_init_finalize_undef failed for {name}: {e}");
2676        }
2677        self.common.udf = udf as u8;
2678        // C `init_record` that ends in `prec->udf = 0; recGblResetAlarms(prec)`
2679        // — the asyn record, defined and no-alarm the moment it loads. The born
2680        // `UDF`/`INVALID` (and the UDF-severity derivation above) are overwritten
2681        // here: at init `nsta`/`nsev` are 0, so `rec_gbl_reset_alarms` transfers
2682        // `STAT`/`SEVR` to `NO_ALARM`. Runs after `post_init_finalize_undef` so
2683        // it is the final word on this record's initial alarm state.
2684        if self.record.init_resets_alarms() {
2685            self.common.udf = 0;
2686            let _ = crate::server::recgbl::rec_gbl_reset_alarms(&mut self.common);
2687        }
2688        // C `init_record` can park a record it cannot process with `prec->pact
2689        // = TRUE`. The only such line in base is `subRecord.c:119-123`, and it
2690        // sits WITH the empty-SNAM report and the return, so the resolution
2691        // step above performs it: a record whose INAM missed returned before
2692        // that line and is not parked however empty its SNAM is (softIoc reads
2693        // `PACT: 0`). This asks the record type for the same transition on the
2694        // paths that reach the tail — an iocsh `dbLoadRecords` merge re-running
2695        // the passes with no registry to resolve against. It is after the
2696        // passes so the `leave_pact()` above cannot undo it, and the park is
2697        // not permanent: a put to a `pact_park_fields()` field re-asks, the way
2698        // C's `special()` does.
2699        if self.record.parks_pact() {
2700            self.enter_pact();
2701        }
2702    }
2703
2704    /// SINGLE OWNER of the DTYP -> soft-output-dset mapping. The dset table
2705    /// decides what a soft OUT-link write carries; no caller may re-derive it.
2706    ///
2707    /// C ships two soft output dsets per output record type and DTYP picks one:
2708    /// `devXxxSoft.c::write_xxx` puts VAL/OVAL on the OUT link, while
2709    /// `devXxxSoftRaw.c::write_xxx` puts the RAW word — `dbPutLink(&prec->out,
2710    /// DBR_LONG, &prec->rval, 1)` (`devAoSoftRaw.c:44`, `devBoSoftRaw.c:65`) or
2711    /// `data = prec->rval & prec->mask` (`devMbboSoftRaw.c:71-75`,
2712    /// `devMbboDirectSoftRaw.c:71-75`).
2713    ///
2714    /// `Record::raw_soft_output_value` IS the SoftRaw column of that table:
2715    /// `Some` exactly for the record types C ships a SoftRaw dset for. A record
2716    /// type C has no SoftRaw dset for keeps the plain soft-channel value —
2717    /// `DTYP="Raw Soft Channel"` on a `longout` is a `.db` error C rejects at
2718    /// init ("no device support"), and the port's lenient reading of it (the
2719    /// same one [`crate::server::device_support::is_soft_dtyp`] already applies
2720    /// on the input side) must not turn the write into a silent no-op.
2721    ///
2722    /// `None` means DTYP names device support that owns the write — real
2723    /// hardware. "Async Soft Channel" is NOT that: C's
2724    /// `devXxxSoftCallback.c::write_xxx` puts the same VAL/OVAL the plain soft
2725    /// dset puts, only through `dbPutLinkAsync` (`devAoSoftCallback.c:49`,
2726    /// `devLoSoftCallback.c:49`), and falls back to a synchronous `dbPutLink`
2727    /// when the link has no LSET. Returning `None` for it made every
2728    /// `DTYP("Async Soft Channel")` output record write nothing at all —
2729    /// measured on `pva2pva/testApp/testpvalink.db:30-35`, whose `longout`
2730    /// drives a pva OUT link that never fired.
2731    pub fn soft_output_value(&self) -> Option<Option<EpicsValue>> {
2732        use crate::server::device_support::SoftDtyp;
2733        match self.common.dtyp.soft()? {
2734            SoftDtyp::Raw => Some(
2735                self.record
2736                    .raw_soft_output_value()
2737                    .or_else(|| self.record.output_link_value()),
2738            ),
2739            SoftDtyp::Plain | SoftDtyp::Async => Some(self.record.output_link_value()),
2740        }
2741    }
2742
2743    /// Set a single `info("key", "value")` tag on this record. Last
2744    /// write wins. Used by the .db loader (`info(...)` directive) and
2745    /// `dbpf`-style tools.
2746    pub fn set_info(&mut self, key: impl Into<String>, value: impl Into<String>) {
2747        self.info.insert(key.into(), value.into());
2748    }
2749
2750    /// Look up a single info tag. Returns `None` when the record has
2751    /// no tag with that key.
2752    pub fn get_info(&self, key: &str) -> Option<&str> {
2753        self.info.get(key).map(|s| s.as_str())
2754    }
2755
2756    /// The value of `field` already published to its `DBE_VALUE`/`DBE_LOG`
2757    /// subscribers, or `None` when the framework has never published one.
2758    /// The read side of the `last_posted` contract — see the field's docs.
2759    pub(crate) fn posted_value(&self, field: &str) -> Option<&EpicsValue> {
2760        self.last_posted.get(field)
2761    }
2762
2763    /// SINGLE OWNER of `last_posted`: record that `value` has been published
2764    /// to `field`'s `DBE_VALUE`/`DBE_LOG` subscribers, so no later cycle
2765    /// change-detects and re-publishes it.
2766    ///
2767    /// Every value-class post — the snapshot builders' change-detected posts,
2768    /// the intermediate async-notify posts, and the put-time
2769    /// [`Self::notify_field_with_origin`] post that C makes from `dbPut`
2770    /// (dbAccess.c:1414) — routes through here. Alarm-only / property-only
2771    /// posts MUST NOT call it: they deliver nothing to a value-class
2772    /// subscriber, so the value is still owed.
2773    pub(crate) fn record_value_post(&mut self, field: &str, value: EpicsValue) {
2774        if let Some(slot) = self.last_posted.get_mut(field) {
2775            *slot = value;
2776        } else {
2777            self.last_posted.insert(field.to_string(), value);
2778        }
2779    }
2780
2781    /// Invalidate the metadata cache. Called after writing any
2782    /// metadata-class field (EGU, PREC, HOPR/LOPR, alarm limits,
2783    /// DRVH/DRVL, enum strings). The next snapshot will rebuild the
2784    /// cache from the new values.
2785    pub fn invalidate_metadata_cache(&self) {
2786        if let Ok(mut guard) = self.metadata_cache.lock() {
2787            *guard = None;
2788        }
2789    }
2790
2791    /// **The** `DBE_PROPERTY` gate: C `dbAccess.c:1330`
2792    /// `paddr->pfldDes->prop`, read from the field's own declaration.
2793    ///
2794    /// C never consults a list of field names — it asks the `.dbd`, per record
2795    /// type, which is why `histogram.ULIM` is a property and `bi.ZSV` (declared
2796    /// `pp(TRUE)`, no `prop`) is not, and why `bi.ZNAM` is one while
2797    /// `busy.ZNAM` is not. Asking [`Self::field_desc`] gives the port the same
2798    /// per-type answer from the same generated `.dbd` tables.
2799    ///
2800    /// A field with no declaration at all — a virtual field (`RTYP`, `TIME`) —
2801    /// has no `dbFldDes` in C either, so it is not property-class.
2802    pub(crate) fn field_posts_property(&self, field: &str) -> bool {
2803        self.field_desc(field).is_some_and(|d| d.prop)
2804    }
2805
2806    /// Hook called by the database after a field is written. If the field is a
2807    /// metadata-cache source, the cache is invalidated so the next snapshot
2808    /// picks up the new value. Posts nothing — a caller that also owes the
2809    /// `DBE_PROPERTY` event uses [`Self::notify_field_written_if_changed`].
2810    ///
2811    /// Field name is automatically uppercased.
2812    pub fn notify_field_written(&self, field: &str) {
2813        let upper = field.to_ascii_uppercase();
2814        if is_metadata_cache_source(&upper) {
2815            self.invalidate_metadata_cache();
2816        }
2817    }
2818
2819    /// Like [`Self::notify_field_written`], plus the `DBE_PROPERTY` post C
2820    /// makes from `dbPut` — and both are skipped when the put did not actually
2821    /// change the field's value. Mirrors epics-base `faac1df1`: property events
2822    /// fire only on real changes, not on idempotent writes (the C path compares
2823    /// `paddr->pfield` against the converted payload before setting the
2824    /// `propertyUpdate` flag).
2825    ///
2826    /// The two effects have independent gates. Invalidation follows
2827    /// `is_metadata_cache_source` (what this port's cache reads); the post
2828    /// follows `Self::field_posts_property` (what the `.dbd` declares). A
2829    /// field can be either without being both.
2830    ///
2831    /// `prev` is the value captured BEFORE the put. Callers that don't need the
2832    /// change-detection (e.g. internal writers that know the field is neither)
2833    /// can keep using [`Self::notify_field_written`].
2834    ///
2835    /// `backing` is what the sweep needs and could not have: the post below
2836    /// names EVERY subscribed field, so it reaches a link-backed one whenever a
2837    /// client is monitoring it, and this method runs under the record's own
2838    /// write lock where the target's lock cannot be taken. The put path that
2839    /// calls it has already resolved one at its no-lock point.
2840    pub fn notify_field_written_if_changed(
2841        &mut self,
2842        field: &str,
2843        prev: Option<&EpicsValue>,
2844        backing: LinkBacking<'_>,
2845    ) {
2846        let upper = field.to_ascii_uppercase();
2847        let cache_source = is_metadata_cache_source(&upper);
2848        let posts_property = self.field_posts_property(&upper);
2849        if !cache_source && !posts_property {
2850            return;
2851        }
2852        // The SAME reader the put's pre-value was captured with
2853        // (`field_io.rs`'s three `dbPut` bodies). `Record::get_field` alone
2854        // sees only the record type's own struct, and this port keeps on
2855        // `CommonFields` a good deal of storage C keeps per record type — the
2856        // whole analog-alarm ladder among it — so a `caput HIHI` on a calc
2857        // compared `None` to `None`, reported "unchanged", and posted neither
2858        // the `DBE_PROPERTY` C sends for a `prop(YES)` field nor the cache
2859        // invalidation the metadata it feeds depends on.
2860        let now = self.resolve_field_stored(&upper);
2861        if prev == now.as_ref() {
2862            return;
2863        }
2864        if cache_source {
2865            self.invalidate_metadata_cache();
2866        }
2867        if posts_property {
2868            // mirror C dbAccess.c:1395-1396 — the gate `if (propertyUpdate &&
2869            // !status)` and the `db_post_events(precord, NULL, DBE_PROPERTY)` it
2870            // guards. The NULL field pointer is what makes it record-wide.
2871            // Collect keys first to avoid a re-entrant immutable borrow on subscribers.
2872            let fields: Vec<String> = self.subscribers.keys().cloned().collect();
2873            for f in fields {
2874                self.notify_field_with_origin(
2875                    &f,
2876                    crate::server::recgbl::EventMask::PROPERTY,
2877                    0,
2878                    backing,
2879                );
2880            }
2881        }
2882    }
2883
2884    /// Returns the cached MetadataSnapshot, building and storing it on
2885    /// the first call (or after invalidation). Used by both
2886    /// `snapshot_for_field` and `make_monitor_snapshot` so the populate
2887    /// cost is paid at most once per metadata-stable interval.
2888    fn cached_metadata(&self) -> MetadataSnapshot {
2889        // Fast path: cache hit
2890        if let Ok(guard) = self.metadata_cache.lock()
2891            && let Some(cached) = guard.as_ref()
2892        {
2893            return cached.clone();
2894        }
2895
2896        // Cache miss: build a fresh metadata snapshot
2897        let mut tmp = super::super::snapshot::Snapshot::new(
2898            EpicsValue::Double(0.0),
2899            0,
2900            0,
2901            std::time::SystemTime::UNIX_EPOCH,
2902        );
2903        self.populate_display_info(&mut tmp);
2904        self.populate_control_info(&mut tmp);
2905        self.populate_enum_info(&mut tmp);
2906
2907        let meta = MetadataSnapshot {
2908            display: tmp.display,
2909            control: tmp.control,
2910            enums: tmp.enums,
2911            alarm: self.explicit_alarm_limits(self.record.record_type()),
2912        };
2913
2914        // Store back; ignore poisoning (cache is best-effort).
2915        if let Ok(mut guard) = self.metadata_cache.lock() {
2916            *guard = Some(meta.clone());
2917        }
2918        meta
2919    }
2920
2921    /// C `dbChannelSpecial(chan) == SPC_NOMOD` — **the single owner of the
2922    /// no-modify declaration**, for every consumer that needs to know whether a
2923    /// field can be written.
2924    ///
2925    /// C declares it once, in the `.dbd`, and reads it in two unrelated places:
2926    ///
2927    /// * `dbPut` (`dbAccess.c:123-126`, via `dbPutSpecial(paddr, 0)`) refuses
2928    ///   the write — the port's `check_no_mod` gate;
2929    /// * `rsrvCheckPut` (`rsrv/camessage.c:2540-2551`) — `if
2930    ///   (dbChannelSpecial(pciu->dbch) == SPC_NOMOD) return 0;` — which feeds
2931    ///   the CA `ACCESS_RIGHTS` write bit (`camessage.c:1154-1156`) as well as
2932    ///   both put paths, so a client sees `Access: read, no write` and never
2933    ///   sends the doomed write.
2934    ///
2935    /// Only the first consumer existed in the port, so every dbCommon NOMOD
2936    /// field advertised WRITE on the wire (`caput N1.SEVR 2` was refused
2937    /// server-side, after the client had already sent it, with an async
2938    /// exception instead of C's clean client-side "Write access denied").
2939    ///
2940    /// Three sources, one answer:
2941    ///
2942    /// 1. the dbCommon `SPC_NOMOD` set below — common fields, so no record's
2943    ///    `field_list` declares them;
2944    /// 2. the record type's **declaration**, resolved by `Self::field_desc` —
2945    ///    the vendored `.dbd` whenever one exists, and only for a record type
2946    ///    that has no `.dbd` at all (`motor`, `optics`, `scaler`, `std`) the
2947    ///    record's own hand-written table, which for those Tier 3 types
2948    ///    genuinely *is* their declaration;
2949    /// 3. [`Record::field_no_mod`] — an SPC_NOMOD a record's `cvt_dbaddr`
2950    ///    raises from its own state (compress VAL under BALG=LIFO,
2951    ///    `compressRecord.c:404-405`), which a static `FieldDesc` cannot
2952    ///    express.
2953    ///
2954    /// `field` may be any case.
2955    pub fn is_no_mod(&self, field: &str) -> bool {
2956        if DBCOMMON_NOMOD.iter().any(|f| f.eq_ignore_ascii_case(field)) {
2957            return true;
2958        }
2959        if self.field_desc(field).is_some_and(|f| f.read_only) {
2960            return true;
2961        }
2962        self.record.field_no_mod(field)
2963    }
2964
2965    /// Check if the record is currently processing (PACT equivalent).
2966    pub fn is_processing(&self) -> bool {
2967        self.pact.load(std::sync::atomic::Ordering::Acquire)
2968    }
2969
2970    /// C `prec->pact = TRUE` — the record goes busy for an async device
2971    /// round-trip, an SDLY simulation defer, or an ODLY reprocess window.
2972    pub fn enter_pact(&self) {
2973        self.pact.store(true, std::sync::atomic::Ordering::Release);
2974    }
2975
2976    /// C `prec->pact = FALSE` — the ONLY release of PACT.
2977    ///
2978    /// The returned [`PactExit`] carries the release's debt to the cycle tail,
2979    /// where a queued put-notify is restarted — the omission the open-coded
2980    /// `processing.store(false)` at the ODLY continuation and the three SIM/SDLY
2981    /// releases made.
2982    ///
2983    /// `#[must_use]` does NOT enforce that debt and never did: the lint fires on
2984    /// an unused *expression*, so a site that binds the token with `let` and then
2985    /// leaves by `?` or an early `return` warns about nothing. The enforcement is
2986    /// `processing::CycleEndGuard`, whose `Drop` pays the tail for every exit
2987    /// that did not.
2988    pub fn leave_pact(&mut self) -> PactExit {
2989        self.pact.store(false, std::sync::atomic::Ordering::Release);
2990        PactExit::new(self.notify_restart_pending())
2991    }
2992
2993    /// The cycle-tail token for a record this cycle did NOT release PACT on.
2994    ///
2995    /// Still consults the queue: a notify parked behind an in-flight wait-set
2996    /// on an idle record is freed by the wait-set completion, and the tail is
2997    /// what promotes it.
2998    pub fn pact_exit_without_release(&self) -> PactExit {
2999        PactExit::new(self.notify_restart_pending())
3000    }
3001
3002    /// C `processNotifyCommon`'s two defer tests (dbNotify.c:213, 225), as one
3003    /// question: may a NEWLY ARRIVING put-notify take this record now?
3004    ///
3005    /// `true` for an in-flight wait-set (`precord->ppn`), for PACT, and for a
3006    /// non-empty restart list — the last so a notify arriving in the window
3007    /// between a completion and the restart check cannot jump the queue.
3008    ///
3009    /// A RESTARTED put is not asked this: it is already the record's owner (C
3010    /// `precord->ppn == ppn`, state `notifyRestartCallbackRequested`, which
3011    /// dbNotify.c:213 exempts by name) and only PACT can stop it — see
3012    /// `Self::requeue_notify_put`.
3013    pub fn notify_put_is_owned(&self) -> bool {
3014        self.notify.is_some() || self.is_processing() || !self.notify_restart_list.is_empty()
3015    }
3016
3017    /// C `processNotifyCommon`'s FIRST defer test alone (dbNotify.c:213):
3018    /// another `processNotify` owns this record, or one is already queued
3019    /// behind it. [`Self::notify_put_is_owned`] folds in the PACT arm
3020    /// (`:225`) as well.
3021    ///
3022    /// A DBF link-field put waits on ownership but NOT on PACT. A bare `sub`
3023    /// with an empty `SNAM` parks PACT=TRUE forever (subRecord.c:119-122), so
3024    /// a link put that waited on the PACT arm there would never be written and
3025    /// `caput <sub>.INPA 0` would read back empty. Ownership carries no such
3026    /// trap: the restart check drains the queue at every cycle end.
3027    pub fn notify_put_has_owner(&self) -> bool {
3028        self.notify.is_some() || !self.notify_restart_list.is_empty()
3029    }
3030
3031    /// C `ellSafeAdd(&precord->ppnr->restartList, &ppn->restartNode)` — the
3032    /// arriving put-notify joins the back of the queue, unwritten.
3033    ///
3034    /// Infallible: C has no "refuse" arm here, and a refusal loses the client's
3035    /// write. Call only under [`Self::notify_put_is_owned`].
3036    /// Take this record's put-notify slot, or queue behind whoever holds it.
3037    ///
3038    /// C `processNotifyCommon` (dbNotify.c:211-231) has exactly two outcomes
3039    /// and no third: the record is free and the notify takes it, or it is
3040    /// owned and the notify joins `precord->ppnr->restartList`. There is no
3041    /// refusal arm — `ECA_PUTCBINPROG` has one sender in all of base, the
3042    /// 60-second put-callback timeout in `write_notify_action`
3043    /// (`rsrv/camessage.c:1701` at R7.0.10).
3044    ///
3045    /// `None` means queued, and the caller MUST NOT process: the replay
3046    /// drives the record and fires the callback, so processing here would
3047    /// run the cycle twice for one client request.
3048    ///
3049    /// Ownership alone decides — NOT [`Self::notify_put_has_owner`]. A
3050    /// non-empty restart list stops a *fresh* arrival at the entry gate, but a
3051    /// replay reaching here has already been popped off that list and must
3052    /// take the slot with its successors still queued behind it, exactly as
3053    /// C `restartCheck` (dbNotify.c:158-168) assigns `precord->ppn = pfirst`
3054    /// while leaving the rest of `restartList` in place.
3055    pub fn install_or_queue_notify(
3056        &mut self,
3057        completion: crate::runtime::sync::oneshot::Sender<()>,
3058    ) -> Option<Arc<NotifyWaitSet>> {
3059        if self.notify.is_some() {
3060            self.queue_notify_put(DeferredNotify::Process { completion });
3061            return None;
3062        }
3063        let notify = NotifyWaitSet::for_entry_record(&self.name, completion);
3064        self.take_notify_slot(notify.clone());
3065        Some(notify)
3066    }
3067
3068    /// Take `ws` into this record's put-notify slot.
3069    ///
3070    /// **The only writer of [`Self::notify`] that installs a set** — the other
3071    /// two ([`Self::abandon_put_notify`], [`Self::release_notify`]) only clear
3072    /// it. Everything that makes a record a member of a wait-set happens here,
3073    /// so C's `precord->ppn = ppn` and its `ellSafeAdd(&waitList, ...)`
3074    /// (`dbNotify.c:226-227`, `:257-258`, `:497-498`) stay the single act they
3075    /// are in C. Split across two call sites, they were what let a member exist
3076    /// that no cancel could name.
3077    fn take_notify_slot(&mut self, ws: Arc<NotifyWaitSet>) {
3078        debug_assert!(
3079            self.notify.is_none(),
3080            "the slot must be tested free in the same critical section"
3081        );
3082        ws.record_joined(&self.name);
3083        self.notify = Some(ws);
3084    }
3085
3086    /// C `dbNotifyAdd` (dbNotify.c:477-501): a link target joins the wait-set
3087    /// of the put-notify driving the chain, so the initiator's completion
3088    /// waits for this record's cycle too.
3089    ///
3090    /// One of the two callers of `take_notify_slot`, the sole writer; the
3091    /// other is [`Self::install_or_queue_notify`]. All three live here so
3092    /// the slot has no assignment site outside this module — an open-coded one
3093    /// elsewhere is how a wait-set came to be installed without the record's
3094    /// write gate.
3095    ///
3096    /// A record already carrying a wait-set keeps it (C's `if (!pto->ppn …)`
3097    /// at `:492`), so this never displaces a live one, and the `enter` is
3098    /// paired with the `leave` the target's own cycle tail performs.
3099    pub fn join_put_notify(&mut self, src: Option<&Arc<NotifyWaitSet>>) {
3100        if self.notify.is_some() {
3101            return;
3102        }
3103        if let Some(ws) = src {
3104            ws.enter();
3105            self.take_notify_slot(ws.clone());
3106        }
3107    }
3108
3109    /// Give up a claim on the slot without ever having processed under it.
3110    ///
3111    /// NOT a completion. `complete_put_notify` (`processing.rs:449`, C
3112    /// `dbNotifyCompletion`) `leave`s the wait-set because the record
3113    /// contributed a cycle to it; an abandoned claim contributed nothing, so
3114    /// the set is dropped whole. Its `pending` never reaches zero, and the
3115    /// client's receiver wakes on the dropped sender — the same release C
3116    /// gives a `dbNotifyCancel`.
3117    ///
3118    /// The caller must be the claim's owner. Nothing else can have cleared or
3119    /// replaced the slot in between: [`Self::install_or_queue_notify`] and
3120    /// [`Self::join_put_notify`] both refuse an occupied slot, and
3121    /// [`Self::take_next_notify_restart`] will not pop while it is occupied,
3122    /// so the assertion below states an invariant rather than guarding a
3123    /// race.
3124    pub(crate) fn abandon_put_notify(&mut self, claimed: &Arc<NotifyWaitSet>) {
3125        let taken = self.notify.take();
3126        debug_assert!(
3127            taken.as_ref().is_some_and(|ws| Arc::ptr_eq(ws, claimed)),
3128            "only the claim owner may clear the put-notify slot"
3129        );
3130    }
3131
3132    /// The set in this record's slot if it can never be answered — the test
3133    /// half of C `dbNotifyCancel` (`dbNotify.c:385-430`), reached from
3134    /// `rsrvFreePutNotify` (`camessage.c:1630-1638`) when a client is torn down
3135    /// with its put-callback still busy.
3136    ///
3137    /// # Invariant (CONTRACT)
3138    ///
3139    /// A record's put-notify slot MUST NOT stay occupied by a notify nobody
3140    /// can be answered from. Without the release such a record is wedged for
3141    /// good: every later put-notify queues on `notify_restart_list` behind a
3142    /// completion that can never arrive and writes nothing — C
3143    /// `processNotifyCommon` tests ownership ABOVE `putCallback` — so the next
3144    /// client hangs too, and the one after it.
3145    ///
3146    /// This is what makes honouring a record type's forward-link gate safe at
3147    /// all. `busy` left at VAL=1 withholds the `ca_put_callback` exactly as C
3148    /// does (`busyRecord.c:271`); the client then gives up and exits, and that
3149    /// exit is the teardown modelled here.
3150    ///
3151    /// Two triggers reach it, and both are needed. The CA server calls it at
3152    /// client teardown, which is C's own moment
3153    /// (`rsrvFreePutNotify`, `camessage.c:1630-1638`), and it also runs at
3154    /// every ownership test, which catches a set whose client died on a
3155    /// transport the teardown hook does not cover. Only the first closes the
3156    /// case where a SECOND client is already queued on a record's restart list
3157    /// when the first dies: nothing then arrives to test ownership, and C's
3158    /// `restartCheck` would have handed that record to the queued client.
3159    ///
3160    /// The set names its own members ([`NotifyWaitSet::joined_records`]), so
3161    /// the sweep reaches a chain target exactly as C's `notifyProcessInProgress`
3162    /// arm does (`dbNotify.c:428-430`) rather than stopping at the entry.
3163    /// That distinction is not cosmetic: a chain target is the likelier victim,
3164    /// because the record whose cycle never ends is precisely the one this
3165    /// exists for — a `busy` left at VAL=1 withholds `recGblFwdLink` by
3166    /// contract, so its slot would otherwise be held by a dead set forever.
3167    ///
3168    /// See [`PvDatabase::cancel_unanswerable_notify`], the owner that runs it
3169    /// across the whole set.
3170    ///
3171    /// [`PvDatabase::cancel_unanswerable_notify`]: crate::server::database::PvDatabase::cancel_unanswerable_notify
3172    pub(crate) fn unanswerable_notify(&self) -> Option<Arc<NotifyWaitSet>> {
3173        self.notify
3174            .as_ref()
3175            .filter(|ws| ws.is_unanswerable())
3176            .cloned()
3177    }
3178
3179    /// Drop this record's claim on `dead` — C `restartCheck`'s
3180    /// `precord->ppn = 0` (`dbNotify.c:157`) as `dbNotifyCancel` reaches it.
3181    ///
3182    /// The record's own slot is the authority on membership, so this is
3183    /// `Arc::ptr_eq`-gated: a name the sweep carries for a record that has
3184    /// since completed and taken a different notify releases nothing.
3185    ///
3186    /// Returns whether the slot was freed, so the caller can promote the
3187    /// restart-list head (C `restartCheck`) exactly as a completion would.
3188    #[must_use = "a freed slot owes the restart list a drain"]
3189    pub(crate) fn release_notify(&mut self, dead: &Arc<NotifyWaitSet>) -> bool {
3190        if self.notify.as_ref().is_some_and(|ws| Arc::ptr_eq(ws, dead)) {
3191            self.notify = None;
3192            return true;
3193        }
3194        false
3195    }
3196
3197    /// Whether a put-notify owns this record — C `precord->ppn != NULL`.
3198    ///
3199    /// The public read of the slot. The wait-set itself stays crate-private so
3200    /// no caller outside this crate can `enter`/`leave` a set it does not own,
3201    /// which is the accounting [`NotifyWaitSet`] exists to keep.
3202    pub fn has_notify(&self) -> bool {
3203        self.notify.is_some()
3204    }
3205
3206    pub fn queue_notify_put(&mut self, put: DeferredNotify) {
3207        debug_assert!(
3208            self.notify_put_is_owned(),
3209            "a put-notify is queued only when the record is owned; otherwise it \
3210             takes the record directly"
3211        );
3212        self.notify_restart_list.push_back(put);
3213    }
3214
3215    /// C `processNotifyCommon`'s `precord->pact` arm reached by a RESTARTED
3216    /// notify (dbNotify.c:225-231): it stays `precord->ppn` and waits for the
3217    /// next completion, so it does NOT fall in behind puts that arrived after
3218    /// it. Back to the head.
3219    ///
3220    /// The only way a promotion can find the record busy is a scan that took
3221    /// PACT between the pop and the replay; the record's advisory write gate,
3222    /// held across both, keeps every other put out of that window.
3223    pub(crate) fn requeue_notify_put(&mut self, put: DeferredNotify) {
3224        debug_assert!(
3225            self.is_processing(),
3226            "a promoted put-notify returns to the head only because the record \
3227             went PACT under it"
3228        );
3229        self.notify_restart_list.push_front(put);
3230    }
3231
3232    /// C `restartCheck` (dbNotify.c:149-170) — promote the queue head once the
3233    /// record is free, or leave it queued for the next completion.
3234    ///
3235    /// **The only drain.** The freedom test lives here rather than at the call
3236    /// site, so promoting onto a record that is still PACT or still carries a
3237    /// wait-set is not expressible.
3238    pub(crate) fn take_next_notify_restart(&mut self) -> Option<DeferredNotify> {
3239        if self.notify.is_some() || self.is_processing() {
3240            return None;
3241        }
3242        self.notify_restart_list.pop_front()
3243    }
3244
3245    /// Does this record owe anyone a restart? The cheap read that keeps the
3246    /// per-cycle restart check off the spawn path when nothing is queued.
3247    pub(crate) fn notify_restart_pending(&self) -> bool {
3248        !self.notify_restart_list.is_empty()
3249    }
3250
3251    /// How many put-notifies are queued behind whoever owns this record — C
3252    /// `ellCount(&precord->ppnr->restartList)`.
3253    ///
3254    /// A count and not a bool because `dbNotifyDump` prints one line per
3255    /// queued entry (`dbNotify.c:678-685`); [`Self::notify_restart_pending`]
3256    /// answers the cheaper question the restart check asks. Read-only: the
3257    /// queue's only drain is still [`Self::take_next_notify_restart`].
3258    pub(crate) fn notify_restart_len(&self) -> usize {
3259        self.notify_restart_list.len()
3260    }
3261
3262    /// Unified field resolution: record fields → common fields → virtual
3263    /// fields — and, for a link field, C `dbGet`'s rendering of it.
3264    ///
3265    /// This is the port's `dbGet` (`dbAccess.c:625-961`): the read every
3266    /// external reader arrives at, whether it came from
3267    /// [`PvDatabase::get_pv`](crate::server::database::PvDatabase::get_pv)
3268    /// on behalf of a CA client, from `dbgf`, or from `dbpr`. C's `dbGet`
3269    /// sends `DBF_INLINK`/`DBF_OUTLINK`/`DBF_FWDLINK` to `getLinkValue`
3270    /// (`:944-947`), which renders the link with `dbGetString` (`:850-856`),
3271    /// so applying that here is what makes every reader agree without any of
3272    /// them knowing the rule.
3273    ///
3274    /// The STORE is still the text — `Record::get_field` — and that is what
3275    /// the link layer parses. The two are not the same value and do not share
3276    /// a name: C likewise reads `precord->inp` directly when it wants the
3277    /// link and `dbGet` when it wants what a client would see.
3278    pub fn resolve_field(&self, name: &str) -> Option<EpicsValue> {
3279        self.resolve_field_upper(&name.to_ascii_uppercase())
3280    }
3281
3282    /// [`Self::resolve_field`] for a name the caller has already normalised
3283    /// — a parsed link's target field, a channel name's field — so the link
3284    /// read path does not allocate an upper-cased copy of a name that is
3285    /// upper-case by construction. `name` must already be upper-case.
3286    pub fn resolve_field_upper(&self, name: &str) -> Option<EpicsValue> {
3287        debug_assert!(
3288            !name.bytes().any(|b| b.is_ascii_lowercase()),
3289            "resolve_field_upper takes a normalised name, got {name:?}"
3290        );
3291        self.resolve_field_upper_at(name, self.field_desc(name))
3292    }
3293
3294    /// [`Self::resolve_field_upper`] with the field's declaration in hand:
3295    /// `desc` is [`Self::field_desc`]'s answer for `name`, which a link
3296    /// target settles once ([`FieldAddr`]) instead of scanning per read.
3297    #[inline]
3298    pub(crate) fn resolve_field_upper_at(
3299        &self,
3300        name: &str,
3301        desc: Option<&'static FieldDesc>,
3302    ) -> Option<EpicsValue> {
3303        let value = self.resolve_field_stored_at(name, desc)?;
3304        Some(self.as_a_reader_sees(name, value))
3305    }
3306
3307    /// [`Self::resolve_field`] without the reader's view — what the field
3308    /// HOLDS, which for a link field is the text C's `dbParseLink` takes
3309    /// (`dbStaticLib.c:2246`) rather than what `dbGetString` renders
3310    /// (`:1906-2050`).
3311    ///
3312    /// `dbpr` needs both of the same field, and in C they come from one
3313    /// address: it prints the link's resolved TYPE in front of the rendered
3314    /// text (`dbTest.c:1205-1224`). Splitting the accessor chain here keeps
3315    /// that one address — a second walk to find the stored text would be a
3316    /// second answer to "which field is this", and the round before this one
3317    /// is what happens when those two disagree.
3318    ///
3319    /// `name` must already be upper-case.
3320    pub fn resolve_field_stored(&self, name: &str) -> Option<EpicsValue> {
3321        self.resolve_field_stored_at(name, self.field_desc(name))
3322    }
3323
3324    /// [`Self::resolve_field_stored`] with `desc` = [`Self::field_desc`]`(name)`.
3325    #[inline]
3326    fn resolve_field_stored_at(
3327        &self,
3328        name: &str,
3329        desc: Option<&'static FieldDesc>,
3330    ) -> Option<EpicsValue> {
3331        let value = match desc {
3332            // C `dbGet`'s validity gate (`dbAccess.c:667-675`): the NAME
3333            // resolves — `dbNameToAddr` finds every field the `.dbd` declares
3334            // — and the READ is what fails, with `S_db_badDbrtype`. The two
3335            // outcomes leave by different doors one level up, in
3336            // `PvDatabase::get_pv`, which turns a declared-but-unresolved
3337            // field into `CaError::BadDbrType` and an undeclared one into
3338            // `ChannelNotFound`.
3339            //
3340            // The gate goes HERE, in front of the accessor chain, rather than
3341            // in whichever accessor would otherwise answer: `declared_default`
3342            // synthesises the declared type's zero for any field with no
3343            // stored value, so leaving the row to reach it served `REC.TIME`
3344            // as `UChar(0)` — a value C has no way to produce.
3345            Some(desc) if desc.unreadable() => return None,
3346            // C `dbFindFieldPart` — the record type's own `.dbd` table, then
3347            // `dbCommon`. Every accessor below reads STORAGE, and this port
3348            // keeps a good deal of storage on `CommonFields` that C keeps per
3349            // record type (`INP`, `OUT`, `SSCN`, the analog-alarm ladder), so
3350            // without the declaration in front of them a `calc` answered
3351            // `.OUT` with an empty string where C answers `PV 'C:GOOD.OUT'
3352            // not found`. The declaration is the namespace, not the storage.
3353            //
3354            // The record's own answer is returned as it lands rather than
3355            // threaded through the fallbacks' `or_else` chain, which moved
3356            // the 32-byte value once per link in the chain on the way out.
3357            Some(_) => match self.record.get_field(name) {
3358                Some(value) => value,
3359                None => self
3360                    .get_common_field(name)
3361                    .or_else(|| self.get_virtual_field(name))
3362                    .or_else(|| self.declared_overrides.get(name).cloned())
3363                    .or_else(|| self.declared_default(name))?,
3364            },
3365            // C `dbNameToAddr` falls through to `dbGetAttributePart` on
3366            // `S_dbLib_fieldNotFound` (`dbAccess.c:672-675`), which is how
3367            // `RTYP` — declared by no record type — reads as the type name.
3368            // `VERS` and any `dbPutAttribute` name need the database's
3369            // attribute map and are answered a level up, in `get_pv`.
3370            None => self.get_virtual_field(name)?,
3371        };
3372        Some(value)
3373    }
3374
3375    /// C `dbGet`'s link arm applied to one resolved field: a link field reads
3376    /// as [`render_link_field`], everything else as itself.
3377    ///
3378    /// The class lookup is behind the string test because only a string-valued
3379    /// field can be a link, and the numeric fields a processing cycle reads
3380    /// (`HASH`, `SIMM`, `SDLY`) must not pay for a declaration scan.
3381    #[inline]
3382    fn as_a_reader_sees(&self, upper_field: &str, value: EpicsValue) -> EpicsValue {
3383        let EpicsValue::String(ref text) = value else {
3384            return value;
3385        };
3386        let Some(class) = crate::types::dbf_link_class(self.record.record_type(), upper_field)
3387        else {
3388            return value;
3389        };
3390        EpicsValue::String(
3391            render_link_field(class, text.as_str_lossy().as_ref())
3392                .as_str()
3393                .into(),
3394        )
3395    }
3396
3397    /// The value a field that is DECLARED by the `.dbd` but has no live store
3398    /// on this record serves: its `initial(...)`, or a type-zero.
3399    ///
3400    /// C makes *every* `.dbd` field addressable — `dbNameToAddr` resolves the
3401    /// field from its `dbFldDes` and `dbGet` reads it out of record memory,
3402    /// which the dbd loader seeded with `initial()` (or left zero). A Rust
3403    /// record implements only the fields it has behaviour for, so a field it
3404    /// declares but never touches — `aSub.OVAL`, `sub.LA`, `sel.HOPR` — had no
3405    /// channel at all: [`Self::resolve_field`]'s three accessors all returned
3406    /// `None` and CA create-channel answered `S_dbLib_recNotFound`.
3407    ///
3408    /// The declared table is the contract for *which* fields exist; this is the
3409    /// last resort for the *value* of one with no runtime accessor, and it is
3410    /// exactly what an unprocessed C record on an empty `.db` returns —
3411    /// [`apply_dbd_initials`](crate::server::db_loader) seeds the same
3412    /// `initial()` into the fields the record *does* store, from the same
3413    /// generated table, so the two paths agree by construction.
3414    fn declared_default(&self, name: &str) -> Option<EpicsValue> {
3415        let desc = self.field_desc(name)?;
3416        // A `runtime_typed` field (`VAL`/`BG`, re-typed from `FTVL`/`SDEF`) is
3417        // record-owned by definition and its placeholder `dbf_type` is not what
3418        // it serves; never synthesise one here — the record itself answers it.
3419        if desc.runtime_typed {
3420            return None;
3421        }
3422        let initial = desc.initial.unwrap_or("");
3423        if let Some(choices) = desc
3424            .menu
3425            .or_else(|| self.record.menu_field_choices(name))
3426            .or_else(|| super::shared_menu_choices(name))
3427        {
3428            // A menu field with no `initial(...)` is index 0, exactly as an
3429            // empty numeric field is 0 below.
3430            if initial.is_empty() {
3431                return Some(EpicsValue::Enum(0));
3432            }
3433            return super::resolve_menu_field_string_db_load(name, choices, desc.dbf_type, initial)
3434                .ok();
3435        }
3436        // `parse` maps an empty string to the declared type's zero, so this one
3437        // call serves both `initial(...)` and no-initial fields.
3438        EpicsValue::parse_bytes(desc.dbf_type, initial.as_bytes()).ok()
3439    }
3440
3441    /// Resolve a field for EPICS `$` long-string (character-array) access.
3442    ///
3443    /// The `$` channel-name modifier (C `dbChannel.c:486-505`) re-views a
3444    /// field as a `DBR_CHAR` array: a `DBF_STRING` field becomes a char
3445    /// array of `field_size` elements, a link field a char array of
3446    /// `PVLINK_STRINGSZ`, and every other field type is rejected with
3447    /// `S_dbLib_fieldNotFound`. pvxs serves that char view as a
3448    /// `form = "String"` long-string `NTScalar` — it reads the `DBR_CHAR`
3449    /// bytes and NUL-terminates them back into a string
3450    /// (`ioc/iocsource.cpp:133-136`, `ioc/channel.cpp:62-74`).
3451    ///
3452    /// Both `DBF_STRING` fields and link fields resolve to an
3453    /// [`EpicsValue::String`] in this database (a link resolves to its
3454    /// textual form, see [`Self::get_common_field`]), so a field is
3455    /// `$`-eligible exactly when it resolves to a string value. Returns
3456    /// that string value for an eligible field, or `None` for a field the
3457    /// `$` modifier cannot view as a char array (the
3458    /// `S_dbLib_fieldNotFound` case) — the single owner of the
3459    /// dbChannel `$`-eligibility rule for the channel-resolution layer.
3460    pub fn resolve_string_view_field(&self, name: &str) -> Option<EpicsValue> {
3461        match self.resolve_field(name)? {
3462            v @ EpicsValue::String(_) => Some(v),
3463            _ => None,
3464        }
3465    }
3466
3467    /// Choice table for a field served as `DBR_ENUM` from a `DBF_MENU`:
3468    /// the record's own record-specific menu
3469    /// ([`Record::menu_field_choices`]),
3470    /// else a shared menu keyed by field name
3471    /// ([`shared_menu_choices`](super::menu_choices::shared_menu_choices)).
3472    /// The choices a `menu()` field serves as its `DBR_ENUM` labels.
3473    ///
3474    /// The `.dbd` declaration is the first and best answer: a generated
3475    /// [`FieldDesc`] carries the field's own `menu(...)` choices, which is what
3476    /// C's `dbGetFieldIndex` -> `pamapdbfType` -> menu lookup resolves. The two
3477    /// hand-maintained fallbacks below are for record types still on a
3478    /// hand-written table; they go away with the last of them.
3479    ///
3480    /// `shared_menu_choices` in particular keys on the field NAME alone, across
3481    /// every record type — which is only correct while no two record types give
3482    /// the same field name different menus. Asking the field's own descriptor
3483    /// first removes that assumption.
3484    fn menu_choices_for(&self, field: &str) -> Option<&'static [&'static str]> {
3485        menu_choices_of(self.record.as_ref(), field)
3486    }
3487
3488    /// The choices this record's `DTYP` selects among — C's `dbDeviceMenu` for
3489    /// the record type, in `.dbd` declaration order.
3490    ///
3491    /// C's DTYP field IS the index into this list, and an unset DTYP is index 0
3492    /// — which is why a bare `record(ai,"X"){}` serves `Soft Channel` and a
3493    /// `record(calc,"X"){}`, whose record type declares no device support at
3494    /// all, serves the empty string.
3495    ///
3496    /// The port stores the device NAME rather than the index, because the name
3497    /// is what the device-support registry dispatches on, and a name registered
3498    /// at runtime by a downstream crate (`asynInt32`) has no `device()` line in
3499    /// any vendored `.dbd`. Such a name is appended as its own slot, so the
3500    /// index and the string still name the SAME device support: there is no
3501    /// value of DTYP that renders as a device this record is not bound to.
3502    /// `None` when the record type declares NO device support at all — C's
3503    /// `dbDeviceMenu *pdevs = paddr->pfldDes->ftPvt; if (!pdevs) goto nostrs;`
3504    /// (`dbAccess.c:176-179`), which clears `DBR_ENUM_STRS` so the client is
3505    /// sent no choice list at all.
3506    ///
3507    /// C keeps that case DISTINCT from a device menu that exists but is empty,
3508    /// and says so at `dbAccess.c:205`: *"indicate option data not available.
3509    /// distinct from no_str==0"*. An empty-but-present menu is still marked,
3510    /// with `no_str = 0`; a missing menu is not marked. Returning `Vec` here
3511    /// and defaulting the missing menu to `[]` collapsed the two, so a
3512    /// `record(calc,"X"){}` — whose record type has no `device()` line — served
3513    /// `value.choices = {0}[]` where QSRV2 omits the leaf entirely.
3514    pub(crate) fn device_choices(&self) -> Option<Vec<PvString>> {
3515        let record_type = self.record.record_type();
3516        // Base's build-time menu (`epics-base-rs/dbd`), then the menus a
3517        // downstream crate whose device support has no vendored `device()` line
3518        // registered at runtime (asyn's `asynInt32`, `asynFloat64`, ...). C's
3519        // `dbDeviceMenu` is the concatenation of every `device()` the loaded
3520        // `.dbd` set declares, in load order — base first, then asyn — so the
3521        // merge appends the contributed choices AFTER the declared ones.
3522        // The C None-vs-empty distinction (`dbAccess.c:176-179` vs `:205`): the
3523        // menu is present iff the loaded `.dbd` set declares ANY `device()` for
3524        // this type. A type base declares none for but asyn does (structurally
3525        // possible, though none of asyn's are such) is therefore present, not
3526        // None; a type neither declares for (calc) stays None.
3527        if super::dbd_generated::device_menu(record_type).is_none()
3528            && super::contributed_device_menu(record_type).is_empty()
3529        {
3530            return None;
3531        }
3532        // `merged_device_menu` = declared + contributed, the SAME source the
3533        // CA-put validation (`coerce_put_value`'s DTYP branch) resolves against,
3534        // so a client can put exactly the DTYP names it can read here.
3535        let mut names: Vec<PvString> = super::merged_device_menu(record_type)
3536            .into_iter()
3537            .map(PvString::from)
3538            .collect();
3539        let dtyp = self.common.dtyp.as_str();
3540        if !dtyp.is_empty() && !names.iter().any(|n| n.as_str_lossy() == dtyp) {
3541            names.push(PvString::from(dtyp));
3542        }
3543        Some(names)
3544    }
3545
3546    /// C `dbPutFieldLink`'s link-type gate (`dbAccess.c:1125-1137`): a link
3547    /// written at RUNTIME is held to the same `dbCanSetLink` rule as one written
3548    /// by the `.db`, against the device support the record's CURRENT `DTYP`
3549    /// binds. Same rule, same owner — [`super::check_link_assignment`]; only the
3550    /// DTYP it is asked about differs (the record's, not the `.db` text's).
3551    ///
3552    /// [`MenuBound::DbLoad`] is exempt, and that is not a hole: on the db-load
3553    /// path C does not check a link as each field is parsed either. It checks
3554    /// once, at `iocInit`, over the record as loaded (`dbStaticLib.c:2178-2231`)
3555    /// — which is why `field(INP,…)` may precede `field(DTYP,…)` in a `.db` and
3556    /// still bind. [`PvDatabase::db_init_record_links`] is that pass, and it
3557    /// reads the record's DTYP off the record itself, so it does not depend on
3558    /// the order the `.db` happened to spell its fields in. Gating here as well
3559    /// would re-introduce exactly that order dependence.
3560    ///
3561    /// [`PvDatabase::db_init_record_links`]: crate::server::database::PvDatabase
3562    fn check_link_assignment(
3563        &self,
3564        upper_field: &str,
3565        text: &str,
3566        bound: MenuBound,
3567    ) -> CaResult<()> {
3568        if matches!(bound, MenuBound::DbLoad) {
3569            return Ok(());
3570        }
3571        super::check_link_assignment(
3572            self.record.record_type(),
3573            Some(self.common.dtyp.as_str()),
3574            upper_field,
3575            text,
3576        )
3577    }
3578
3579    /// The value of the `DTYP` field: the index of the bound device support in
3580    /// [`Self::device_choices`]. An unset DTYP is index 0, exactly as in C.
3581    pub(crate) fn dtyp_index(&self) -> u16 {
3582        let dtyp = self.common.dtyp.as_str();
3583        if dtyp.is_empty() {
3584            return 0;
3585        }
3586        // A record type with no device menu has no slot for any DTYP, so the
3587        // index stays 0 — the same answer the old `unwrap_or(&[])` gave.
3588        self.device_choices()
3589            .unwrap_or_default()
3590            .iter()
3591            .position(|c| c.as_str_lossy() == dtyp)
3592            .unwrap_or(0) as u16
3593    }
3594
3595    /// **The** owner of "what string does this enum-valued field render as" —
3596    /// C's `[DBF_*][DBR_STRING]` conversion row, chosen by the field's DBF
3597    /// class. Every path that renders an enum as a string goes through here:
3598    /// the CA/PVA encoders (via [`EnumInfo::string_form`](crate::server::snapshot::EnumInfo::string_form) on the
3599    /// snapshot this builds) and the db-link read
3600    /// ([`Self::field_as_dbr_string`]). There is exactly one such table per
3601    /// field, and no path may reconstruct a second one.
3602    ///
3603    /// C's dispatch, and this function's, in the same order:
3604    ///
3605    /// * `DBF_MENU` / `DBF_DEVICE` -> `getMenuString` / `getDeviceString`, the
3606    ///   field's own choice list. Asked FIRST, because a menu field on a record
3607    ///   whose `VAL` is an enum (`bo.OMSL`) must render its menu's choices, not
3608    ///   the record's `ZNAM`/`ONAM`.
3609    /// * `DBF_ENUM` `VAL` -> `getEnumString` -> the record's `get_enum_str`
3610    ///   rset ([`Record::enum_string_form`]).
3611    ///
3612    /// `None` when the field has neither — C answers `S_db_noRSET`, an error;
3613    /// the port renders empty.
3614    ///
3615    /// Each class brings its own out-of-range rule with it (see
3616    /// [`EnumOverflow`](crate::server::snapshot::EnumOverflow)); the index is
3617    /// rendered as a number for a `DBF_MENU` and ONLY for a `DBF_MENU`.
3618    pub(crate) fn enum_string_form_for(&self, field: &str) -> Option<EnumStringForm> {
3619        if field.eq_ignore_ascii_case("DTYP") {
3620            // `None` propagates C's `goto nostrs` (`dbAccess.c:178`): a record
3621            // type with no `device()` declaration supplies no choice list, so
3622            // the leaf is omitted rather than marked empty.
3623            return self.device_choices().map(EnumStringForm::device);
3624        }
3625        if let Some(choices) = self.menu_choices_for(field) {
3626            return Some(EnumStringForm::menu(
3627                choices.iter().map(|c| PvString::from(*c)),
3628            ));
3629        }
3630        if field.eq_ignore_ascii_case("VAL") {
3631            return self.record.enum_string_form();
3632        }
3633        None
3634    }
3635
3636    /// Is `field` one of the DBF classes C's soft device support writes as
3637    /// `DBR_STRING`?
3638    ///
3639    /// `devsCalcoutSoft.c:128-130` (and its async twin, :83-85) switches the
3640    /// scalcout OUT put on the TARGET field's DBF type and sends `OSV` — the
3641    /// string result — for seven of them:
3642    ///
3643    /// ```c
3644    /// case DBF_STRING: case DBF_ENUM: case DBF_MENU: case DBF_DEVICE:
3645    /// case DBF_INLINK: case DBF_OUTLINK: case DBF_FWDLINK:
3646    ///     status = dbPutLink(&pscalcout->out, DBR_STRING, &pscalcout->osv, 1);
3647    /// ```
3648    ///
3649    /// [`DbFieldType`] is the port's DBR *wire* type and cannot express
3650    /// `DBF_MENU` / `DBF_DEVICE` — C's DBF class is not the DBR type. The
3651    /// classification therefore lives here, with the record's field metadata,
3652    /// where each class is already known:
3653    ///
3654    /// * `DBF_STRING` and the three link classes — the port stores links and
3655    ///   `DTYP` (C's only `DBF_DEVICE` field) as strings;
3656    /// * `DBF_ENUM` — an enum-typed field;
3657    /// * `DBF_MENU` — a menu-index field, i.e. one this record resolves choice
3658    ///   labels for ([`Self::menu_choices_for`]): `PRIO`, `STAT`, `SEVR`,
3659    ///   `DISS`, `ACKT`, `SCAN`, `IVOA`, `OMSL`, … The index is stored as a
3660    ///   short, so a same-named field that is NOT a menu index (scalcout's
3661    ///   string `OSV` shares a name with the alarm-severity menu) is
3662    ///   classified by its own type, not by the name collision.
3663    ///
3664    /// Everything else (`DBF_DOUBLE`, `DBF_LONG`, `DBF_CHAR`, …) falls to the
3665    /// device support's `default:` arm.
3666    ///
3667    /// The question is about the target field's DECLARED class, so it is asked
3668    /// of the declaration ([`Self::declared_field_type`]) and not of the
3669    /// variant the record stores: C's `switch` is on `dbAddr.field_type`, which
3670    /// `dbNameToAddr` took from the `dbFldDes`. `DBF_MENU` and `DBF_DEVICE` both
3671    /// map to `DbFieldType::Enum` in the generated tables (`mapDBFToDBR`), and
3672    /// the three link classes to `DbFieldType::String`, so the seven C arms are
3673    /// exactly these two.
3674    pub(crate) fn field_puts_as_string(&self, field: &str) -> bool {
3675        let Some(declared) = self.declared_field_type(field) else {
3676            return false;
3677        };
3678        matches!(declared, DbFieldType::String | DbFieldType::Enum)
3679    }
3680
3681    /// The field's value as C `dbGetLink(plink, DBR_STRING, ...)` delivers it —
3682    /// the SOURCE side of an input link read with
3683    /// [`LinkReadAs::String`].
3684    ///
3685    /// C converts at the source, through `dbConvert.c`'s
3686    /// `[field_type][DBR_STRING]` table: a `DBF_ENUM` field goes through
3687    /// `getEnumString` → the record's `get_enum_str` (mbbi's `ZRST`.., bi's
3688    /// `ZNAM`/`ONAM`) and a `DBF_MENU` field through `getMenuString` → the
3689    /// menu's choice string, i.e. the state LABEL in both cases, never the
3690    /// index. Only the record holds those tables, so the render lives here with
3691    /// the field metadata — the link-read owner has an index and nothing to
3692    /// resolve it with.
3693    ///
3694    /// The render goes through [`Self::enum_string_form_for`], the same owner
3695    /// the CA/PVA encoders use, so a link read and a `caget -t` of one field can
3696    /// never disagree about its string.
3697    pub(crate) fn field_as_dbr_string(&self, field: &str) -> Option<PvString> {
3698        let value = self.resolve_field(field)?;
3699        // A `DBF_ENUM` index, and a `DBF_MENU` index (stored as a short),
3700        // render through the field's string source. A short field that is
3701        // neither has no source and stays the plain number C converts it to.
3702        let idx = match value {
3703            EpicsValue::Enum(v) => Some(v),
3704            EpicsValue::Short(v) => u16::try_from(v).ok(),
3705            _ => None,
3706        };
3707        if let Some(idx) = idx
3708            && let Some(form) = self.enum_string_form_for(field)
3709        {
3710            return Some(form.render(idx));
3711        }
3712        value_as_dbr_string(&value)
3713    }
3714
3715    /// The field's declaration — its `dbFldDes`, in C's terms.
3716    ///
3717    /// The `.dbd` is the declaration, so the table generated FROM the `.dbd`
3718    /// ([`dbd_generated::record_fields`](super::dbd_generated::record_fields))
3719    /// is asked first, for every record type that has one. A record's own
3720    /// `Record::field_list` is a
3721    /// hand-written stand-in for that table, and it is consulted only for a
3722    /// record type the `.dbd` does not cover (`subArray`, and the record types
3723    /// the downstream crates add). It cannot be the primary answer: several of
3724    /// those tables are *derived from the record's Rust storage types* — the
3725    /// `#[derive(EpicsRecord)]` records type `longin.ADEL` `DBF_DOUBLE`
3726    /// because the struct member is an `f64`, where the `.dbd` says
3727    /// `DBF_LONG` — and reading the type off the storage is the whole defect
3728    /// this owner exists to close.
3729    ///
3730    /// `dbCommon` last, matching the order [`Self::resolve_field`] reads the
3731    /// value in, so a record-specific field always shadows the common one in
3732    /// both halves.
3733    ///
3734    /// `None` for a field with no declaration at all: a virtual field
3735    /// (`RTYP`, `TIME`, ...), which C answers from dbStaticLib rather than
3736    /// from a `dbFldDes`.
3737    pub(crate) fn field_desc(&self, field: &str) -> Option<&'static FieldDesc> {
3738        field_desc_of(self.record.as_ref(), field)
3739    }
3740
3741    /// Is `field` (already uppercased) a `DBF_NOACCESS` internal name —
3742    /// record-own (`BPTR`, `RPVT`, ...) or `dbCommon` (`MLOK`, `RSET`, ...)?
3743    ///
3744    /// C's `dbNameToAddr` resolves such a name, so a SEARCH for it is
3745    /// answered and the refusal lands at channel creation (`mapDBFToDBR` →
3746    /// `DBR_NOACCESS`). The search gate (`PvDatabase::has_name_no_resolve`)
3747    /// asks this so the port answers the same way; every value path stays
3748    /// closed to these names.
3749    pub(crate) fn resolves_noaccess_name(&self, field: &str) -> bool {
3750        is_dbcommon_noaccess(field) || self.record.noaccess_names().contains(&field)
3751    }
3752
3753    /// The `DBF_*` type `field` is SERVED as — the single source of truth for
3754    /// the type on the wire, on every delivery path.
3755    ///
3756    /// This is the field's DECLARED type ([`FieldDesc::dbf_type`], from the
3757    /// `.dbd`), not the type of whatever variant the record happens to store.
3758    /// C resolves a channel's `field_type` from the `dbFldDes` at
3759    /// name-resolution time (`dbChannelCreate` -> `dbNameToAddr`,
3760    /// `dbAccess.c:184-205`) and every later `dbGet`/`db_post_events` converts
3761    /// the stored bytes to it — the storage is private to the record, the
3762    /// declaration is the contract.
3763    ///
3764    /// Two answers are NOT the declaration:
3765    ///
3766    /// * a [`FieldDesc::runtime_typed`] field — C's `cvt_dbaddr` overwrites
3767    ///   `paddr->field_type` from record state (`FTVL`, `FTA`, `SDEF`), and
3768    ///   this port's `cvt_dbaddr` is the variant the record stores;
3769    /// * a field with no `FieldDesc` at all (a virtual field).
3770    ///
3771    /// In both cases the value's own type is the answer, so this returns
3772    /// `None` and [`Self::project_to_declared_type`] leaves the value alone.
3773    pub fn declared_field_type(&self, field: &str) -> Option<DbFieldType> {
3774        declared_field_type_of(self.record.as_ref(), field)
3775    }
3776
3777    /// Project a field's stored value onto its declared type
3778    /// ([`Self::declared_field_type`]) — the single owner of "what type this
3779    /// field goes on the wire as", run by the CA create-channel path
3780    /// ([`Self::client_field_value`]), the GET path
3781    /// ([`Self::snapshot_for_field`]) and the MONITOR path
3782    /// ([`Self::make_monitor_snapshot`]), so all three announce and serve the
3783    /// same type.
3784    ///
3785    /// The projection is [`EpicsValue::convert_to`], the one value-coercion
3786    /// owner — the same routine `dbGet` converts through. Never re-derive a
3787    /// conversion here: C picks its routine from BOTH the source and the
3788    /// destination type, and only `convert_to` knows that table.
3789    ///
3790    /// Idempotent: a value already of its declared type is short-circuited by
3791    /// `convert_to`, and re-projecting a projected value is a no-op. That is
3792    /// what lets the CA path derive the native type from the value it is about
3793    /// to serve.
3794    pub fn project_to_declared_type(&self, field: &str, value: EpicsValue) -> EpicsValue {
3795        match self.declared_field_type(field) {
3796            Some(declared) => value.convert_to(declared),
3797            None => value,
3798        }
3799    }
3800
3801    /// The client-facing value of `field`: the resolved value projected onto
3802    /// the field's declared type ([`Self::project_to_declared_type`]), so a
3803    /// native type derived from the value — which is what the CA
3804    /// create-channel path does — is the DECLARED type, and matches the
3805    /// GET/MONITOR data byte for byte.
3806    pub fn client_field_value(&self, field: &str) -> Option<EpicsValue> {
3807        let value = self.resolve_field(field)?;
3808        Some(self.project_to_declared_type(field, value))
3809    }
3810
3811    /// Attach a `DBF_MENU` field's `menu()` choice labels to a built snapshot,
3812    /// so the CA/PVA enum encoders present `"NO CONVERSION"` rather than `0`.
3813    ///
3814    /// The VALUE half of the `DBF_MENU` -> `DBR_ENUM` mapping is not here: the
3815    /// `.dbd` declares a menu field `DBF_MENU`, the generator types that
3816    /// `DbFieldType::Enum` (`mapDBFToDBR`), and
3817    /// [`Self::project_to_declared_type`] — which every delivery path runs —
3818    /// makes the served value an [`EpicsValue::Enum`] on that declaration
3819    /// alone. So the label table is all that is left to attach, and it is
3820    /// attached exactly when the served value came out an enum. A same-named
3821    /// field that is NOT a menu index (`scalcout.OSV`, declared `DBF_STRING`,
3822    /// shares a name with the alarm-severity menu) is served as its own
3823    /// declared string and gets no choice table.
3824    fn attach_menu_enum(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
3825        if !matches!(snap.value, EpicsValue::Enum(_)) {
3826            return;
3827        }
3828        // `VAL` is the one field whose two rset slots differ: C's
3829        // `get_enum_strs` (the `DBR_GR_ENUM` labels) is TRIMMED to `no_str`
3830        // while `get_enum_str` (the DBR_STRING form) indexes the untrimmed
3831        // state array. `populate_enum_info` owns that pair; every OTHER
3832        // enum-valued field is a menu or a device, whose one choice list
3833        // answers both (C `getMenuString`/`getDeviceString` index the same
3834        // `papChoiceValue` the GR_ENUM reply carries).
3835        if field.eq_ignore_ascii_case("VAL") {
3836            return;
3837        }
3838        let Some(form) = self.enum_string_form_for(field) else {
3839            return;
3840        };
3841        snap.enums = Some(super::super::snapshot::EnumInfo::with_string_form(
3842            form.slots.clone(),
3843            form,
3844        ));
3845    }
3846
3847    /// Build a Snapshot with full metadata for the given field — for a field
3848    /// **no link backs**.
3849    ///
3850    /// A link-backed field answers `None` here on purpose. Its metadata has to
3851    /// be resolved from the target record, which needs a
3852    /// [`PvDatabase`](crate::server::database::PvDatabase) and, because the
3853    /// port has one lock per record instead of C's per-lock-set recursive
3854    /// mutex, has to happen with no record lock held. That is
3855    /// [`PvDatabase::channel_snapshot_for_field`](crate::server::database::PvDatabase::channel_snapshot_for_field),
3856    /// and it is the only entry point that can serve one. Answering `None`
3857    /// rather than a seeded snapshot is what makes a caller that reached for
3858    /// the wrong door serve nothing instead of something stale.
3859    pub fn snapshot_for_field(&self, field: &str) -> Option<super::super::snapshot::Snapshot> {
3860        if self.link_backed_metadata_field_of(field).is_some() {
3861            return None;
3862        }
3863        self.snapshot_for_field_with(field, LinkBacking::none())
3864    }
3865
3866    /// [`Self::snapshot_for_field`] with the link metadata the caller resolved
3867    /// for this build. `PvDatabase` is the intended caller; see [`LinkBacking`].
3868    pub fn snapshot_for_field_with(
3869        &self,
3870        field: &str,
3871        backing: LinkBacking<'_>,
3872    ) -> Option<super::super::snapshot::Snapshot> {
3873        // The GET path serves the field at its DECLARED type, the same type
3874        // the CA create-channel path announced from `client_field_value` and
3875        // the same one the monitor path posts.
3876        let value = self.client_field_value(field)?;
3877        Some(self.finish_field_snapshot(field, value, backing))
3878    }
3879
3880    /// Which of this record's own link fields, if any, supplies `field`'s
3881    /// metadata — C's `get_linkNumber` question, asked before any lock is
3882    /// dropped so `PvDatabase` knows whether it has to resolve at all.
3883    pub(crate) fn link_backed_metadata_field_of(&self, field: &str) -> Option<String> {
3884        self.record
3885            .link_backed_metadata_field(&field.to_ascii_uppercase())
3886    }
3887
3888    /// The value a channel bound to `field` serves, through the `$` view
3889    /// the channel was bound with.
3890    ///
3891    /// `dbChannelCreate` decides the view ONCE, at bind time
3892    /// (`dbChannel.c:486-505`), and every delivery path then reads through
3893    /// the `dbChannel` it produced; this is that single read. Callers must
3894    /// not re-derive it: resolving the bare field name answers "yes" for
3895    /// `VAL` whatever its type, so a path that does drops the eligibility
3896    /// half of the view entirely and admits `REC.VAL$` on a `DBF_DOUBLE`.
3897    ///
3898    /// `None` is `S_dbLib_fieldNotFound`: the record has no such field, or
3899    /// `$` was applied to a field that cannot be re-viewed as a character
3900    /// array (see [`Self::resolve_string_view_field`]).
3901    pub fn channel_field_value(&self, field: &str, string_view: bool) -> Option<EpicsValue> {
3902        if string_view {
3903            self.resolve_string_view_field(field)
3904        } else {
3905            self.client_field_value(field)
3906        }
3907    }
3908
3909    /// [`Self::snapshot_for_field_with`] through the same `$` view as
3910    /// [`Self::channel_field_value`] — the metadata is the field's either
3911    /// way, only the value is re-viewed.
3912    ///
3913    /// This is the `_with` variant deliberately: the view decides the VALUE,
3914    /// `backing` decides the METADATA, and the two are independent. A caller
3915    /// that has resolved a [`LinkBacking`] passes it straight through, so a
3916    /// link-backed `$` member keeps its target's units/precision.
3917    pub fn channel_snapshot_for_field(
3918        &self,
3919        field: &str,
3920        string_view: bool,
3921        backing: LinkBacking<'_>,
3922    ) -> Option<super::super::snapshot::Snapshot> {
3923        let value = self.channel_field_value(field, string_view)?;
3924        Some(self.finish_field_snapshot(field, value, backing))
3925    }
3926
3927    /// The one finishing pipeline behind both `Snapshot` producers
3928    /// ([`Self::snapshot_for_field`] for GET, [`Self::make_monitor_snapshot`]
3929    /// for updates). Every step that shapes a served snapshot — alarm/utag
3930    /// carry, the metadata cache, per-field routing and RSET overrides, menu
3931    /// enums, property support, the `Q:time:tag` nsec split — runs here, so
3932    /// the two paths cannot drift apart. Upstream pvxs PR #189 is exactly
3933    /// that drift: its subscription callback served unmasked nanoseconds
3934    /// while its GET path applied the nsec mask.
3935    fn finish_field_snapshot(
3936        &self,
3937        field: &str,
3938        value: EpicsValue,
3939        backing: LinkBacking<'_>,
3940    ) -> super::super::snapshot::Snapshot {
3941        let mut snap = super::super::snapshot::Snapshot::new(
3942            value,
3943            self.common.stat,
3944            self.common.sevr as u16,
3945            self.common.time,
3946        );
3947        // Default the served `timeStamp.userTag` to the record's `utag`,
3948        // mirroring pvxs `iocsource.cpp:245` (`auto utag = meta.utag;`).
3949        // The 64-bit `epicsUTag` narrows to the int32 NT wire field by
3950        // truncating to the low 32 bits — pvxs assigns the same uint64
3951        // straight into the `Int32` `timeStamp.userTag`. The `Q:time:tag`
3952        // nsec-LSB split below overrides this when configured, matching
3953        // pvxs `if(info.nsecMask) utag = meta.time.nsec & info.nsecMask;`
3954        // (:246-247 — the test and its assignment).
3955        snap.user_tag = self.common.utag as i32;
3956        // Carry the record's committed alarm message (`common.amsg`) so a
3957        // PVA read serves `alarm.message` from the record's own amsg
3958        // (pvxs `iocsource.cpp:230-236` prefers `meta.amsg`) rather than a
3959        // string re-synthesized from the condition code. Empty for records
3960        // that raise no message (C's plain `recGblSetSevr` clears namsg).
3961        snap.alarm.amsg = self.common.amsg.as_str().to_owned();
3962
3963        // Pull display/control/enums from the metadata cache (build on
3964        // first call, hit thereafter until invalidated by a metadata-class
3965        // field write).
3966        let meta = self.cached_metadata();
3967        let explicit_alarm = meta.alarm;
3968        snap.display = meta.display;
3969        snap.control = meta.control;
3970        snap.enums = meta.enums;
3971
3972        // The cache above is the record's VAL metadata. C routes PER FIELD, so
3973        // a non-VAL-class field does NOT get VAL's limits — see
3974        // [`Self::route_field_metadata`], which owns that decision.
3975        self.route_field_metadata(field, backing, explicit_alarm, &mut snap);
3976
3977        // Per-field RSET metadata (C get_units/get_precision/
3978        // get_graphic_double/get_control_double/get_alarm_double key on
3979        // dbGetFieldIndex) patches the record-level cache for this field.
3980        self.apply_field_metadata_override(field, &mut snap);
3981
3982        // DBF_MENU field (a shared menu such as `SCAN`/`OMSL`/`HHSV`/... or
3983        // a record-specific menu such as `sel.SELM`): carry the menu index
3984        // as DBR_ENUM and attach its `menu()` choice labels. See
3985        // `attach_menu_enum`. This overrides any record VAL enum table
3986        // copied from the metadata cache above, because a menu field
3987        // carries its own menu's choices, not the record's VAL state
3988        // strings.
3989        self.attach_menu_enum(field, &mut snap);
3990
3991        // The metadata VALUES and the mask that says which of them this
3992        // channel actually supplies are assigned by the same owner, from the
3993        // settled value, so they cannot disagree.
3994        self.assign_property_support(field, &mut snap);
3995
3996        // apply `info(Q:time:tag, "nsec:lsb:N")` — pvxs
3997        // `iocsource.cpp:239-248` publishes `nanoseconds & ~nsecMask` and
3998        // moves `nanoseconds & nsecMask` into `timeStamp.userTag`. The
3999        // split is applied to both `snap.timestamp` and `snap.user_tag` so
4000        // downstream encoders (NTScalar `timeStamp`, QSRV groups) all see
4001        // the same shape. A zero mask (tag absent or unparseable) is a
4002        // no-op inside the helper, exactly as pvxs's `if(info.nsecMask)`
4003        // gate is.
4004        crate::server::snapshot::apply_nsec_mask(&mut snap, self.qtime_nsec_mask());
4005
4006        snap
4007    }
4008
4009    /// Resolve `info(Q:time:tag)` to pvxs's `MappingInfo::nsecMask`.
4010    /// Returns 0 (the "no split" mask) when the tag is absent or does not
4011    /// parse — pvxs leaves `nsecMask` at its 0 initialiser in that case.
4012    ///
4013    /// pvxs `ioc/typeutils.cpp:79-88`:
4014    ///
4015    /// ```c
4016    /// if(auto val = ent.info("Q:time:tag")) {
4017    ///     epicsInt32 dig = 0;
4018    ///     if(strncmp(val, "nsec:lsb:", 9)==0 && !epicsParseInt32(&val[9], &dig, 10, nullptr)) {
4019    ///         nsecMask = (uint64_t(1u)<<dig)-1u;
4020    ///     }
4021    /// }
4022    /// ```
4023    ///
4024    /// The prefix test is a byte-exact `strncmp` — no case folding and no
4025    /// whitespace tolerance, so `NSEC:LSB:4` and `nsec: lsb: 4` do NOT
4026    /// match and leave the timestamp alone. There is no bounds clamp
4027    /// either: any `dig` `epicsParseInt32` accepts is shifted verbatim, so
4028    /// `nsec:lsb:31` yields the `0x7FFF_FFFF` mask pvxs actually serves.
4029    fn qtime_nsec_mask(&self) -> u64 {
4030        let Some(rest) = self
4031            .get_info("Q:time:tag")
4032            .and_then(|v| v.strip_prefix("nsec:lsb:"))
4033        else {
4034            return 0;
4035        };
4036        let Some(dig) = epics_parse_int32_base10(rest) else {
4037            return 0;
4038        };
4039        // C shifts `uint64_t(1u)` by an `epicsInt32`. A `dig` outside
4040        // `0..=63` is UB in C++; every ISA EPICS builds on (x86-64 `shlq`,
4041        // aarch64 `lsl`) takes the shift count modulo 64, which is what
4042        // `wrapping_shl` does — so `nsec:lsb:64` disables the split
4043        // (mask 0) and a negative `dig` shifts by `dig & 63`, the same
4044        // masks pvxs produces on those hosts.
4045        1u64.wrapping_shl(dig as u32) - 1
4046    }
4047
4048    /// Populate DisplayInfo from record fields if applicable.
4049    /// Resolve the `Q:form` info-tag value to a `display.form` menu index.
4050    ///
4051    /// pvxs publishes the fixed seven-entry form menu
4052    /// (Default/String/Binary/Decimal/Hex/Exponential/Engineering) for every
4053    /// numeric value and, for the VAL field only, sets `display.form.index`
4054    /// to the slot whose name equals the field's `Q:form` info tag
4055    /// (`iocsource.cpp:42-62`, case-sensitive). Unset or unrecognised ->
4056    /// `None` (form stays 0 = Default), exactly as pvxs leaves the index
4057    /// untouched on no match.
4058    fn q_form_index(&self) -> Option<i16> {
4059        const FORM_NAMES: [&str; 7] = [
4060            "Default",
4061            "String",
4062            "Binary",
4063            "Decimal",
4064            "Hex",
4065            "Exponential",
4066            "Engineering",
4067        ];
4068        let tag = self.info.get("Q:form")?;
4069        FORM_NAMES
4070            .iter()
4071            .position(|name| name == tag)
4072            .map(|i| i as i16)
4073    }
4074
4075    /// Stamp a built snapshot with the property mask THIS channel supplies —
4076    /// [`Record::property_support`] narrowed to the addressed field by C's
4077    /// second gate ([`PropertySupport::narrowed_to_field`]). Called by both
4078    /// snapshot builders once the value has settled (after
4079    /// [`Self::attach_menu_enum`] promoted a `DBF_MENU` field to its
4080    /// `DBR_ENUM` form), so the mask is read off the same value the client
4081    /// receives and no consumer has to re-derive either gate.
4082    fn assign_property_support(&self, field: &str, snap: &mut super::super::snapshot::Snapshot) {
4083        snap.properties = self.record.property_support().narrowed_to_field(
4084            snap.value.db_field_type(),
4085            self.menu_choices_for(field).is_some(),
4086        );
4087    }
4088
4089    /// The property mask a channel on `field` supplies, without building a
4090    /// snapshot — what a PVA server needs to decide which NT leaves it may
4091    /// MARK for a channel it has not read yet (QSRV resolves a group's member
4092    /// masks once, at monitor start, rather than per event).
4093    ///
4094    /// Same two gates, same owner as `Self::assign_property_support`: an
4095    /// unknown field supplies nothing.
4096    pub fn property_support_for_field(&self, field: &str) -> PropertySupport {
4097        let Some(value) = self.client_field_value(field) else {
4098            return PropertySupport::NONE;
4099        };
4100        self.record.property_support().narrowed_to_field(
4101            value.db_field_type(),
4102            self.menu_choices_for(field).is_some(),
4103        )
4104    }
4105
4106    /// The record-level display metadata cache: units, precision and display
4107    /// limits as C's `get_units` / `get_precision` / `get_graphic_double`
4108    /// answer them for the fields their rset lists.
4109    ///
4110    /// Driven by [`Record::property_support`], not by a `match` on the record
4111    /// type. Those were two independent tables answering the same question,
4112    /// and nothing kept them in step: `default_property_support` declares
4113    /// units and precision for twenty-five record types where the arm list
4114    /// here covered nine, so the other sixteen declared the leaf and served
4115    /// `""` / `0` — `sel`, `sub`, `dfanout`, `subArray`, `scalcout`,
4116    /// `acalcout`, `epid`, `scaler`, `swait`, `sseq`, `seq`, `mca`,
4117    /// `histogram`, `transform`, `throttle` and `asyn`. Deriving the cache
4118    /// from the declaration makes "declares the slot" and "supplies the slot"
4119    /// one fact rather than two that can disagree.
4120    ///
4121    /// Precision matters well beyond `caget -d`: it is also what the
4122    /// DBF_DOUBLE to DBR_STRING conversion renders with, in C
4123    /// (`dbConvert.c:783-786` calls `prset->get_precision` with no field-type
4124    /// gate) and here (`codec.rs::convert_value_to_dbr_string`), so a missing
4125    /// slot changed the digits of a plain `caget`.
4126    ///
4127    /// The sources are the same in every C rset that supplies them — `EGU` for
4128    /// `get_units` and `PREC` for `get_precision` — so only the graphic pair
4129    /// needs a per-type table (`graphic_limit_fields`). Per-FIELD departures
4130    /// from the record's own values stay where C puts them, in that record's
4131    /// [`Record::field_metadata_override`], which is applied after this and
4132    /// wins.
4133    fn populate_display_info(&self, snap: &mut super::super::snapshot::Snapshot) {
4134        let slots = self.record.property_support();
4135        if slots.units || slots.precision || slots.graphic_double {
4136            let (upper, lower) = if slots.graphic_double {
4137                let (hi, lo) = super::record_trait::graphic_limit_fields(self.record.record_type());
4138                (self.metadata_limit(hi), self.metadata_limit(lo))
4139            } else {
4140                (0.0, 0.0)
4141            };
4142            snap.display = Some(super::super::snapshot::DisplayInfo {
4143                units: if slots.units {
4144                    self.metadata_units()
4145                } else {
4146                    Default::default()
4147                },
4148                precision: if slots.precision {
4149                    self.metadata_limit("PREC") as i16
4150                } else {
4151                    0
4152                },
4153                upper_disp_limit: upper,
4154                lower_disp_limit: lower,
4155                ..Default::default()
4156            });
4157        }
4158        // Apply the `Q:form` display-format hint. The block above builds
4159        // `snap.display` for every record type that supplies at least one
4160        // display slot — the same set for which pvxs emits
4161        // `display.form.choices`. This cache is record-level (it is the VAL
4162        // field's metadata); the VAL-only rule pvxs applies to
4163        // `display.form.index` (`iocsource.cpp:53`) is enforced per served
4164        // field in `apply_field_metadata_override`.
4165        if let Some(display) = snap.display.as_mut() {
4166            if let Some(form) = self.q_form_index() {
4167                display.form = form;
4168            }
4169        }
4170        // `display.description` from dbCommon DESC — pvxs QSRV fills it
4171        // on every metadata populate (iocsource.cpp:306-310), for every
4172        // record type including those with no other display source. The
4173        // qsrv builders always emit the leaf (defaulting a `None`
4174        // display), so creating the DisplayInfo here changes leaf
4175        // values, never the wire shape. Cache freshness is owned by the
4176        // DESC arm of `put_common_field`, which invalidates without
4177        // posting DBE_PROPERTY (epics-base#785 / UI-106).
4178        snap.display
4179            .get_or_insert_with(Default::default)
4180            .description = self.common.desc.clone();
4181    }
4182
4183    /// The record-level control-limit cache — what C's `get_control_double`
4184    /// answers for the fields its rset lists.
4185    ///
4186    /// Gated on the declared slot for the same reason as
4187    /// [`Self::populate_display_info`], and with the same effect: the arm list
4188    /// this replaces covered thirteen record types where
4189    /// `default_property_support` declares `control_double` for twenty-three,
4190    /// so `sel`, `sub`, `dfanout`, `subArray`, `histogram`, `scalcout`,
4191    /// `acalcout` and `epid` served 0/0 on `VAL` — a channel whose C rset
4192    /// answers the record's own operator range.
4193    ///
4194    /// Which of the record's fields that range comes from is the one thing
4195    /// that varies by type, and it lives in `control_limit_source`.
4196    fn populate_control_info(&self, snap: &mut super::super::snapshot::Snapshot) {
4197        use super::record_trait::ControlLimitSource;
4198
4199        if !self.record.property_support().control_double {
4200            return;
4201        }
4202        let (upper, lower) =
4203            match super::record_trait::control_limit_source(self.record.record_type()) {
4204                ControlLimitSource::Drive => {
4205                    (self.metadata_limit("DRVH"), self.metadata_limit("DRVL"))
4206                }
4207                ControlLimitSource::DriveWhenSet => {
4208                    let (drvh, drvl) = (self.metadata_limit("DRVH"), self.metadata_limit("DRVL"));
4209                    if drvh > drvl {
4210                        (drvh, drvl)
4211                    } else {
4212                        (self.metadata_limit("HOPR"), self.metadata_limit("LOPR"))
4213                    }
4214                }
4215                ControlLimitSource::SoftLimits => {
4216                    (self.metadata_limit("HLM"), self.metadata_limit("LLM"))
4217                }
4218                ControlLimitSource::Operator => {
4219                    (self.metadata_limit("HOPR"), self.metadata_limit("LOPR"))
4220                }
4221            };
4222        snap.control = Some(super::super::snapshot::ControlInfo {
4223            upper_ctrl_limit: upper,
4224            lower_ctrl_limit: lower,
4225        });
4226    }
4227
4228    /// A numeric metadata field (`PREC`, `HOPR`, `DRVH`, ...) as C reads it —
4229    /// straight out of record memory, which for C means every field the `.dbd`
4230    /// declares.
4231    ///
4232    /// Through [`Self::resolve_field`], NOT `Record::get_field`, and that is
4233    /// the whole reason this cache used to read zero for the types it now
4234    /// serves: a Rust record implements only the fields it has behaviour for,
4235    /// so `sel`, `sub`, `dfanout` and their siblings model no `PREC`/`HOPR`
4236    /// cell at all and `get_field` answers `None` for them. Their `.db` values
4237    /// live in `declared_overrides`, and their unset defaults in the `.dbd`
4238    /// `initial()`, both of which only `resolve_field` reaches.
4239    fn metadata_limit(&self, field: &str) -> f64 {
4240        self.resolve_field(field)
4241            .and_then(|v| v.to_f64())
4242            .unwrap_or(0.0)
4243    }
4244
4245    /// `EGU`, the source every ported C `get_units` copies from. Empty for a
4246    /// record type whose `.dbd` declares no `EGU` — `seq` and `histogram` have
4247    /// none, and C writes nothing into the `dbAccess.c:378` seed for either.
4248    fn metadata_units(&self) -> crate::types::PvString {
4249        match self.resolve_field("EGU") {
4250            Some(EpicsValue::String(s)) => s,
4251            _ => Default::default(),
4252        }
4253    }
4254
4255    /// Whether C's `get_units` copies the record's own `EGU` into `field`.
4256    ///
4257    /// The fourth membership question, and the one the port never asked. Units
4258    /// had no per-field step at all: the record-level cache was the entire
4259    /// answer, so every field of a type that supplies the slot was served
4260    /// `EGU` — including the fields whose C rset tests first and writes
4261    /// nothing, leaving the `dbAccess.c:378` empty seed. Measured shape:
4262    /// `caget -d DBR_GR_DOUBLE AI.SMOO` served `EGU` where `aiRecord.c:223-226`
4263    /// deliberately skips the three raw-conversion fields.
4264    ///
4265    /// * `ai`/`ao` (`aiRecord.c:217-232`, `aoRecord.c:284-298`) — a DBF_DOUBLE
4266    ///   field other than the raw-conversion ones, which carry no engineering
4267    ///   units.
4268    /// * `calc`/`calcout`/`sub`/`sel`/`dfanout` (`calcRecord.c:169-182`,
4269    ///   `calcoutRecord.c:425-444`, `subRecord.c:206-219`,
4270    ///   `selRecord.c:136-143`, `dfanoutRecord.c:155-163`) — any DBF_DOUBLE
4271    ///   field.
4272    /// * `longin`/`longout` (`longinRecord.c:183-191`) test DBF_LONG and the
4273    ///   int64 pair (`int64inRecord.c:179-187`) DBF_INT64: the record's own VAL
4274    ///   type, not DOUBLE.
4275    /// * `compress` (`compressRecord.c:449-458`) widens the DBF_DOUBLE test
4276    ///   with `VAL`, whose served type comes from the record rather than the
4277    ///   dbd.
4278    /// * the array types (`waveformRecord.c:220-233`, `aaiRecord.c`,
4279    ///   `aaoRecord.c`, `subArrayRecord.c:202-215`) name `VAL`, `HOPR` and
4280    ///   `LOPR`, and drop `VAL` when `FTVL` makes it strings or enums.
4281    /// * `histogram`, `seq`, `bo`, `table` and `aSub` never write `EGU` at all;
4282    ///   each answers a literal or a link for a named set and nothing
4283    ///   elsewhere, and the literals come from
4284    ///   [`Record::field_metadata_override`].
4285    ///
4286    /// Every other ported type copies `EGU` with no test whatever
4287    /// (`sCalcoutRecord.c:603-609`, `aCalcoutRecord.c:743-749`,
4288    /// `epidRecord.c:217-223`, `mcaRecord.c:884-890`, `motorRecord.cc`'s
4289    /// `default:` arm).
4290    fn units_from_egu(&self, rtype: &str, field: &str) -> bool {
4291        use crate::types::DbFieldType as T;
4292        let f = field.to_ascii_uppercase();
4293        // The link arm is NOT here: `route_field_metadata` asks
4294        // [`Record::link_backed_metadata_field`] first and only falls through
4295        // to this EGU question for a field no link backs. This function
4296        // answers C's `else strncpy(units, prec->egu, ...)` branch alone.
4297        let own = |t: T| self.static_field_type(&f) == Some(t);
4298        match rtype {
4299            "ai" => own(T::Double) && !matches!(f.as_str(), "ASLO" | "AOFF" | "SMOO"),
4300            "ao" => own(T::Double) && !matches!(f.as_str(), "ASLO" | "AOFF"),
4301            "calc" | "calcout" | "sub" | "sel" | "dfanout" => own(T::Double),
4302            "longin" | "longout" => own(T::Long),
4303            "int64in" | "int64out" => own(T::Int64),
4304            "compress" => own(T::Double) || f == "VAL",
4305            "waveform" | "aai" | "aao" | "subArray" => {
4306                matches!(f.as_str(), "HOPR" | "LOPR")
4307                    || (f == "VAL" && !self.ftvl_is_string_or_enum())
4308            }
4309            "histogram" | "seq" | "bo" | "table" | "aSub" => false,
4310            _ => true,
4311        }
4312    }
4313
4314    /// `FTVL` names `DBF_STRING` or `DBF_ENUM` — the two element types for
4315    /// which the array rsets break out of the `VAL` case before copying `EGU`.
4316    /// `menuFtype` is declared in `DBF_` code order, so `0` is STRING and `11`
4317    /// is ENUM.
4318    fn ftvl_is_string_or_enum(&self) -> bool {
4319        matches!(
4320            self.resolve_field("FTVL").and_then(|v| v.to_f64()),
4321            Some(x) if x == 0.0 || x == 11.0
4322        )
4323    }
4324
4325    /// Populate EnumInfo — C rset `get_enum_strs`.
4326    ///
4327    /// The table comes from [`Record::enum_state_strings`], the SAME slot the
4328    /// string-put converter (`dbConvert.c::putStringEnum`) resolves against, so
4329    /// the choice list a client reads and the names it may write are one table
4330    /// by construction. It arrives already trimmed to C's `no_str` (bi/bo/busy
4331    /// drop an empty ONAM behind a set ZNAM — `boRecord.c:342-352`; mbbi/mbbo
4332    /// cut at the last non-empty state — `mbbiRecord.c:262-269`).
4333    ///
4334    /// The `DBR_STRING` half of the same channel is the record's OTHER rset slot
4335    /// (`get_enum_str`), which is not this trimmed list — see
4336    /// [`EnumStringForm`]. A record that has no such slot (every record but
4337    /// bi/bo/busy/mbbi/mbbo, including the downstream crates' own enum records)
4338    /// renders from its label list, which is what C's absent slot amounts to.
4339    fn populate_enum_info(&self, snap: &mut super::super::snapshot::Snapshot) {
4340        if let Some(strings) = self.record.enum_state_strings() {
4341            snap.enums = Some(match self.record.enum_string_form() {
4342                Some(form) => super::super::snapshot::EnumInfo::with_string_form(strings, form),
4343                None => super::super::snapshot::EnumInfo::new(strings),
4344            });
4345        }
4346    }
4347
4348    /// Get a common field value.
4349    pub fn get_common_field(&self, name: &str) -> Option<EpicsValue> {
4350        match name {
4351            "SEVR" => Some(EpicsValue::Short(self.common.sevr as i16)),
4352            "STAT" => Some(EpicsValue::Short(self.common.stat as i16)),
4353            "NSEV" => Some(EpicsValue::Short(self.common.nsev as i16)),
4354            "NSTA" => Some(EpicsValue::Short(self.common.nsta as i16)),
4355            // epics-base PR #568 / #566 — alarm message string.
4356            "AMSG" => Some(EpicsValue::String(self.common.amsg.as_str().into())),
4357            "NAMSG" => Some(EpicsValue::String(self.common.namsg.as_str().into())),
4358            "ACKS" => Some(EpicsValue::Short(self.common.acks as i16)),
4359            // `ACKT` and `PINI` are `DBF_MENU` (`menuYesNo` /`menuPini`,
4360            // `dbCommon.dbd.pod:335,169`), not `DBF_UCHAR`: they carry a menu
4361            // index, which `promote_menu_value` lifts to `DBR_ENUM` with the
4362            // menu's choice strings. Storing them as `Short` is what makes
4363            // them eligible for that promotion — see `promote_menu_value`.
4364            "ACKT" => Some(EpicsValue::Short(if self.common.ackt { 1 } else { 0 })),
4365            // DBF_UCHAR: served as UChar (declared type) so the raw put byte
4366            // round-trips to the wire — see the DISP/TPRO comment above.
4367            "UDF" => Some(EpicsValue::UChar(self.common.udf)),
4368            "UDFS" => Some(EpicsValue::Short(self.common.udfs)),
4369            "SCAN" => Some(EpicsValue::Enum(self.common.scan.to_u16())),
4370            "SSCN" => Some(EpicsValue::Enum(self.common.sscn.to_u16())),
4371            // `OLDSIMM` is `DBF_MENU`/`menu(menuSimm)`, stored as the menu index
4372            // and promoted to `DBR_ENUM` with the NO/YES/RAW labels by
4373            // `promote_menu_value` (shared registry — the saved copy is ALWAYS
4374            // menuSimm, unlike the live SIMM). Written only by the simulation
4375            // owner (`rec_gbl_save_simm`); `special(SPC_NOMOD)` for clients.
4376            "OLDSIMM" => Some(EpicsValue::Short(self.common.oldsimm)),
4377            "PINI" => Some(EpicsValue::Short(self.common.pini)),
4378            // DISP/TPRO/RPRO/UDF are `DBF_UCHAR` in `dbCommon.dbd`. Serve them
4379            // as `UChar` — their DECLARED type — so `project_to_declared_type`
4380            // is identity and the raw put byte reaches the wire untouched (C
4381            // stores the byte and `caget` renders `DBR_CHAR` signed: 255 → -1).
4382            // Serving `Char` here instead routed the value through the lossy
4383            // `Char → UChar` projection (signed −1 clamped to 0), so a
4384            // `caput DISP 255` read back as 0 rather than C's -1. `BKPT` is
4385            // `DBF_NOACCESS`: no `FieldDesc`, no projection, served `Char`.
4386            "TPRO" => Some(EpicsValue::UChar(self.common.tpro)),
4387            "BKPT" => Some(EpicsValue::Char(self.common.bkpt.get())),
4388            "FLNK" => Some(EpicsValue::String(self.common.flnk.clone().into())),
4389            // A record type whose C `.dbd` has no INP has no `.INP` channel
4390            // either — C's dbChannel resolution is the dbd, so `dbgf HI.INP` on
4391            // a histogram answers "PV 'HI.INP' not found". The port keeps INP on
4392            // `CommonFields` for every record, so `declares_inp_link()` is what
4393            // stands in for the dbd, and it must gate the read side as well as
4394            // the write side (`put_common_field`) — otherwise the field is
4395            // unloadable and unwritable yet still resolves as a channel.
4396            "INP" if self.record.declares_inp_link() => {
4397                Some(EpicsValue::String(self.common.inp.clone().into()))
4398            }
4399            "OUT" => Some(EpicsValue::String(self.common.out.clone().into())),
4400            // C's DTYP is `DBF_DEVICE`: an epicsEnum16 index into the record
4401            // type's device menu, NOT the name. The name is what this port
4402            // stores and dispatches on; the index is what the wire carries.
4403            "DTYP" => Some(EpicsValue::Enum(self.dtyp_index())),
4404            "TSE" => Some(EpicsValue::Short(self.common.tse)),
4405            "TSEL" => Some(EpicsValue::String(self.common.tsel.clone().into())),
4406            // C `UTAG` is DBF_UINT64 — exposed natively as the unsigned
4407            // 64-bit value variant so values above i64::MAX round-trip.
4408            "UTAG" => Some(EpicsValue::UInt64(self.common.utag)),
4409            "ASG" => Some(EpicsValue::String(self.common.asg.clone().into())),
4410            "ASL" => Some(EpicsValue::Char(self.common.asl)),
4411            "DESC" => Some(EpicsValue::String(self.common.desc.clone())),
4412            "PHAS" => Some(EpicsValue::Short(self.common.phas)),
4413            "EVNT" => Some(EpicsValue::String(self.common.evnt.clone().into())),
4414            "PRIO" => Some(EpicsValue::Short(self.common.prio)),
4415            "DISV" => Some(EpicsValue::Short(self.common.disv)),
4416            "DISA" => Some(EpicsValue::Short(self.common.disa)),
4417            "SDIS" => Some(EpicsValue::String(self.common.sdis.clone().into())),
4418            "DISS" => Some(EpicsValue::Short(self.common.diss)),
4419            "HYST" => Some(EpicsValue::Double(self.common.hyst)),
4420            "LCNT" => Some(EpicsValue::Short(self.common.lcnt)),
4421            "DISP" => Some(EpicsValue::UChar(self.common.disp)),
4422            "PUTF" => Some(EpicsValue::Char(if self.common.putf { 1 } else { 0 })),
4423            "RPRO" => Some(EpicsValue::UChar(self.common.rpro)),
4424            "PACT" => Some(EpicsValue::Char(if self.is_processing() { 1 } else { 0 })),
4425            // C `dbCommon.dbd`: `field(PROC,DBF_UCHAR)` — the raw put byte is
4426            // retained in `prec->proc` and served back SIGNED as `DBR_CHAR`
4427            // (`caput PROC 255` → `caget` = -1), exactly like DISP/RPRO. The
4428            // `pp(TRUE)` force-process is orthogonal: writing PROC still
4429            // reprocesses the record (put-path intercept), but the byte sticks.
4430            "PROC" => Some(EpicsValue::UChar(self.common.proc_field)),
4431            // Analog alarm fields
4432            "HIHI" => self
4433                .common
4434                .analog_alarm
4435                .as_ref()
4436                .map(|a| a.hihi.to_epics_value()),
4437            "HIGH" => self
4438                .common
4439                .analog_alarm
4440                .as_ref()
4441                .map(|a| a.high.to_epics_value()),
4442            "LOW" => self
4443                .common
4444                .analog_alarm
4445                .as_ref()
4446                .map(|a| a.low.to_epics_value()),
4447            "LOLO" => self
4448                .common
4449                .analog_alarm
4450                .as_ref()
4451                .map(|a| a.lolo.to_epics_value()),
4452            "HHSV" => self
4453                .common
4454                .analog_alarm
4455                .as_ref()
4456                .map(|a| EpicsValue::Short(a.hhsv)),
4457            "HSV" => self
4458                .common
4459                .analog_alarm
4460                .as_ref()
4461                .map(|a| EpicsValue::Short(a.hsv)),
4462            "LSV" => self
4463                .common
4464                .analog_alarm
4465                .as_ref()
4466                .map(|a| EpicsValue::Short(a.lsv)),
4467            "LLSV" => self
4468                .common
4469                .analog_alarm
4470                .as_ref()
4471                .map(|a| EpicsValue::Short(a.llsv)),
4472            // swait OUTN is aliased to common.out
4473            "OUTN" => {
4474                if self.record.record_type() == "swait" {
4475                    Some(EpicsValue::String(self.common.out.clone().into()))
4476                } else {
4477                    None
4478                }
4479            }
4480            _ => None,
4481        }
4482    }
4483
4484    /// `true` when the record type declares `name` in its own `field_list`,
4485    /// i.e. the record stores the field itself and owns whatever behaviour
4486    /// hangs off it.
4487    ///
4488    /// This separates the two meanings a link field carries. `common.inp` /
4489    /// `common.out` is the link *text* — always the value the `.db` file
4490    /// wrote, for every record type, because C device support reads
4491    /// `prec->inp` / `prec->out` at `init_record` no matter which layer owns
4492    /// the field ([`crate::server::db_loader::apply_fields`] keeps it
4493    /// populated). `parsed_inp` / `parsed_out` is the *framework's* dispatch
4494    /// of that link, and is armed only for a record type that does NOT
4495    /// declare the field: a record that declares it drives the link itself
4496    /// (`multi_output_links` for `acalcout`/`scalcout`, device support for
4497    /// `motorRecord`/`scalerRecord`, or its own `process`). Arming the
4498    /// framework path for those too would write the link twice per cycle.
4499    fn record_declares_field(&self, name: &str) -> bool {
4500        self.record.implements_field(name)
4501    }
4502
4503    /// Set a common field value from a runtime `dbPut` (CA/PVA/`dbpf`/link).
4504    /// Returns what scan index changes are needed.
4505    ///
4506    /// A `DBF_MENU` common field's string is converted by C's runtime
4507    /// converter, `dbConvert.c::putStringMenu` — see `MenuBound::DbPut`.
4508    pub fn put_common_field(
4509        &mut self,
4510        name: &str,
4511        value: EpicsValue,
4512    ) -> CaResult<CommonFieldPutResult> {
4513        self.put_common_field_bounded(name, value, MenuBound::DbPut)
4514    }
4515
4516    /// **The single owner of a record's SCAN transition** — C `dbPutField` on
4517    /// SCAN, which is `scanDelete(precord)` … `scanAdd(precord)`
4518    /// (`dbAccess.c::dbPutSpecial` SPC_SCAN, dbScan.c:236-248).
4519    ///
4520    /// Two callers reach it, and they are the two C sites that move a record
4521    /// between scan lists: a `SCAN` put ([`Self::put_common_field`]) and the
4522    /// simulation-mode scan swap (`recGblCheckSimm`, recGbl.c:427-437, which
4523    /// calls exactly the same `scanDelete`/`scanAdd` pair). Returns the delta
4524    /// for the scan-index owner (`PvDatabase::update_scan_index`) to apply once
4525    /// the record lock is down; [`CommonFieldPutResult::NoChange`] when the scan
4526    /// did not move.
4527    pub fn set_scan(&mut self, new_scan: ScanType) -> CommonFieldPutResult {
4528        let old_scan = self.common.scan;
4529        self.common.scan = new_scan;
4530        if old_scan == new_scan {
4531            return CommonFieldPutResult::NoChange;
4532        }
4533        // C `scanDelete`/`scanAdd` call the record's device support
4534        // `get_ioint_info(1)` / `get_ioint_info(0)`. Only a change of I/O Intr
4535        // *membership* reaches those; a Passive→"1 second" move calls neither.
4536        let was_io_intr = old_scan == ScanType::IoIntr;
4537        let is_io_intr = new_scan == ScanType::IoIntr;
4538        if was_io_intr != is_io_intr {
4539            self.record.set_io_intr_scan(is_io_intr);
4540        }
4541        CommonFieldPutResult::ScanChanged {
4542            old_scan,
4543            new_scan,
4544            phas: self.common.phas,
4545        }
4546    }
4547
4548    /// C `recGblSaveSimm` (`recGbl.c:421-425`) — latch the CURRENT simulation
4549    /// mode into OLDSIMM:
4550    ///
4551    /// ```c
4552    /// void recGblSaveSimm(const epicsEnum16 sscn,
4553    ///     epicsEnum16 *poldsimm, const epicsEnum16 simm) {
4554    ///     if (sscn == USHRT_MAX) return;
4555    ///     *poldsimm = simm;
4556    /// }
4557    /// ```
4558    ///
4559    /// **The only writer of `CommonFields::oldsimm`.** Must run BEFORE the SIMM
4560    /// value moves — C calls it from `special(SPC_MOD)` pass 0 (before the put)
4561    /// and from `recGblGetSimm`/`recGblInitSimm` before the SIML read. The
4562    /// `sscn == 65535` guard is C's: with SSCN unset there is no scan to swap
4563    /// to, so the latch is not even taken (and [`Self::rec_gbl_check_simm`]
4564    /// bails on the same test, so the stale OLDSIMM is never read).
4565    ///
4566    /// A record type with no SSCN/OLDSIMM in its C dbd (`busy`, `swait`) passes
4567    /// neither pointer to any recGbl helper: no-op here.
4568    pub fn rec_gbl_save_simm(&mut self) {
4569        if !self.record.uses_recgbl_simm_helpers() {
4570            return;
4571        }
4572        // C `recGblSaveSimm`: `if (*psscn == USHRT_MAX) return;` — the literal
4573        // sentinel, not "any index outside the menu".
4574        if self.common.sscn.is_unset() {
4575            return;
4576        }
4577        if let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") {
4578            self.common.oldsimm = simm;
4579        }
4580    }
4581
4582    /// C `recGblCheckSimm` (`recGbl.c:427-437`) — on a SIMM transition, swap the
4583    /// record's SCAN with SSCN:
4584    ///
4585    /// ```c
4586    /// void recGblCheckSimm(struct dbCommon *pcommon, epicsEnum16 *psscn,
4587    ///     const epicsEnum16 oldsimm, const epicsEnum16 simm) {
4588    ///     if (*psscn == USHRT_MAX) return;
4589    ///     if (simm != oldsimm) {
4590    ///         epicsUInt16 scan = pcommon->scan;
4591    ///         scanDelete(pcommon);
4592    ///         pcommon->scan = *psscn;
4593    ///         scanAdd(pcommon);
4594    ///         *psscn = scan;
4595    ///     }
4596    /// }
4597    /// ```
4598    ///
4599    /// This is what makes SSCN mean anything at all: a record configured
4600    /// `field(SCAN,"1 second") field(SSCN,"Passive")` stops periodic scanning
4601    /// the moment SIMM leaves NO, and resumes it when SIMM goes back — with the
4602    /// two fields having traded places each time. Both are a genuine swap, not
4603    /// an assignment: SSCN ends up holding the scan the record just left.
4604    ///
4605    /// **The only writer of the SIMM-driven SCAN/SSCN swap.** The scan-list
4606    /// move itself goes through the single SCAN owner [`Self::set_scan`], whose
4607    /// [`CommonFieldPutResult`] the caller hands to
4608    /// `PvDatabase::update_scan_index` once the record lock is down. Runs AFTER
4609    /// the SIMM value moved — C `special(SPC_MOD)` pass 1, and the tail of
4610    /// `recGblGetSimm`/`recGblInitSimm`.
4611    pub fn rec_gbl_check_simm(&mut self) -> CommonFieldPutResult {
4612        if !self.record.uses_recgbl_simm_helpers() {
4613            return CommonFieldPutResult::NoChange;
4614        }
4615        let Some(sim_scan) = self.common.sscn.scan() else {
4616            // `*psscn == USHRT_MAX` — SSCN unset, no swap. An SSCN that is
4617            // merely ILLEGAL still swaps: C assigns it into SCAN and `scanAdd`
4618            // then declines to scan the record.
4619            return CommonFieldPutResult::NoChange;
4620        };
4621        let Some(EpicsValue::Short(simm)) = self.record.get_field("SIMM") else {
4622            return CommonFieldPutResult::NoChange;
4623        };
4624        if simm == self.common.oldsimm {
4625            return CommonFieldPutResult::NoChange;
4626        }
4627        let previous_scan = self.common.scan;
4628        let result = self.set_scan(sim_scan);
4629        self.common.sscn = SimModeScan::from_scan(previous_scan);
4630        result
4631    }
4632
4633    /// C `dbAccess.c::putAckt` (`:1285-1300`) — the **only** writer of ACKT.
4634    ///
4635    /// Reached from `dbPut` for a `DBR_PUT_ACKT` request *type*
4636    /// (`dbAccess.c:1331-1332`), ABOVE the `SPC_NOMOD` gate that refuses every
4637    /// ordinary put to the field. Posts exactly what C posts: the ACKT change,
4638    /// the ACKS it may lower, and the record-wide `DBE_ALARM` — and only when
4639    /// `ackt` actually changed (C returns 0 early otherwise).
4640    pub fn put_ackt(&mut self, value: u16, backing: LinkBacking<'_>) {
4641        let new_ackt = value != 0;
4642        if new_ackt == self.common.ackt {
4643            return;
4644        }
4645        use crate::server::recgbl::EventMask;
4646        let ack_mask = EventMask::VALUE | EventMask::ALARM;
4647        self.common.ackt = new_ackt;
4648        self.cleanup_subscribers();
4649        self.notify_field_backed("ACKT", ack_mask, backing);
4650        // C `:1294-1297`: turning transient acknowledgement off lowers a
4651        // sticky ACKS down to the current SEVR — an alarm that has already
4652        // cleared must not keep a higher unacknowledged severity.
4653        if !new_ackt && self.common.acks > self.common.sevr {
4654            self.common.acks = self.common.sevr;
4655            self.notify_field_backed("ACKS", ack_mask, backing);
4656        }
4657        self.notify_record_alarm(backing);
4658    }
4659
4660    /// C `dbAccess.c::putAcks` (`:1302-1315`) — the **only** runtime writer of
4661    /// ACKS. Reached from `dbPut` for a `DBR_PUT_ACKS` request type, ABOVE the
4662    /// `SPC_NOMOD` gate.
4663    ///
4664    /// The acknowledged severity is compared against the STORED unacknowledged
4665    /// severity `acks`, not the current `sevr`: an operator acknowledging at
4666    /// the severity that was latched into ACKS clears it even after `sevr` has
4667    /// since dropped. A too-low acknowledgement changes nothing and posts
4668    /// nothing; an acknowledgement of an already-clear ACKS still posts, which
4669    /// is C's literal `if (*psev >= precord->acks)` (0 >= 0 holds).
4670    pub fn put_acks(&mut self, value: u16, backing: LinkBacking<'_>) {
4671        let sev = AlarmSeverity::from_u16(value);
4672        if sev < self.common.acks {
4673            return;
4674        }
4675        use crate::server::recgbl::EventMask;
4676        self.common.acks = AlarmSeverity::NoAlarm;
4677        self.cleanup_subscribers();
4678        self.notify_field_backed("ACKS", EventMask::VALUE | EventMask::ALARM, backing);
4679        self.notify_record_alarm(backing);
4680    }
4681
4682    /// Set a common field value from the `.db` loader, which in C is a
4683    /// different converter with a different out-of-menu bound
4684    /// (`dbStaticRun.c::dbPutStringNum`; see `MenuBound::DbLoad`). It is what
4685    /// lets `field(SSCN,"65535")` — the menuScan "use SCAN" sentinel, out of
4686    /// the menu's 0-9 range — load, while `caput REC.SSCN 65535` is refused at
4687    /// runtime exactly as C refuses it.
4688    pub fn put_common_field_db_load(
4689        &mut self,
4690        name: &str,
4691        value: EpicsValue,
4692    ) -> CaResult<CommonFieldPutResult> {
4693        self.put_common_field_bounded(name, value, MenuBound::DbLoad)
4694    }
4695
4696    fn put_common_field_bounded(
4697        &mut self,
4698        name: &str,
4699        value: EpicsValue,
4700        bound: MenuBound,
4701    ) -> CaResult<CommonFieldPutResult> {
4702        let name = name.to_ascii_uppercase();
4703        self.record.validate_put(&name, &value)?;
4704        self.record.special(&name, false)?;
4705        // The db loader hands every common field to this path as a raw
4706        // `EpicsValue::String` (no per-field `FieldDesc` to parse against).
4707        // Coerce it to the field's canonical numeric/menu type up front so the
4708        // typed arms below apply a `field(PHAS, "1")` / `field(PRIO, "HIGH")`
4709        // directive instead of silently dropping it at IOC load. String-typed
4710        // and already-typed values pass through unchanged.
4711        let declared = declared_field_type_of(self.record.as_ref(), &name);
4712        let value = match coerce_common_field(&name, value, bound, declared)? {
4713            Converted::Stored(v) => v,
4714            // C's converter returned success without storing (`cvt_st_ul`'s
4715            // skipped store): the field keeps its old value, so no arm below
4716            // runs and no SCAN/PHAS transition happened.
4717            Converted::Unchanged => return Ok(CommonFieldPutResult::NoChange),
4718        };
4719        // C `dbPutString`/`dbPutField` route every link field's text through
4720        // `dbParseLink`, whose brace arm hands it to `dbJLinkParse`
4721        // (`dbStaticLib.c:2280-2286`); an unusable JSON link is
4722        // `S_dbLib_badField` and the field never takes the value. This is the
4723        // one funnel every common link field crosses — SDIS, TSEL, FLNK, DOL,
4724        // SIML, SIOL, INP, OUT — on both the db-load and the runtime put path,
4725        // so the rule holds without being restated per field.
4726        if let EpicsValue::String(ref s) = value {
4727            let text = s.as_str_lossy();
4728            if text.trim_start().starts_with('{')
4729                && crate::types::dbf_link_class(self.record.record_type(), &name).is_some()
4730            {
4731                super::check_json_link_text(&text)?;
4732            }
4733        }
4734        match name.as_str() {
4735            // `special(SPC_NOMOD)` — C `dbPutSpecial` refuses the put with
4736            // `S_db_noMod` (dbAccess.c:123-127). OLDSIMM is written only by the
4737            // simulation-mode owner (`rec_gbl_save_simm`).
4738            "OLDSIMM" => return Err(CaError::ReadOnlyField(name)),
4739            "SEVR" => {
4740                if let EpicsValue::Short(v) = value {
4741                    self.common.sevr = AlarmSeverity::from_u16(v as u16);
4742                }
4743            }
4744            "STAT" => {
4745                if let EpicsValue::Short(v) = value {
4746                    self.common.stat = v as u16;
4747                }
4748            }
4749            "NSEV" => {
4750                if let EpicsValue::Short(v) = value {
4751                    self.common.nsev = AlarmSeverity::from_u16(v as u16);
4752                }
4753            }
4754            "NSTA" => {
4755                if let EpicsValue::Short(v) = value {
4756                    self.common.nsta = v as u16;
4757                }
4758            }
4759            "AMSG" => {
4760                if let EpicsValue::String(s) = value {
4761                    self.common.amsg.set(&s.as_str_lossy());
4762                }
4763            }
4764            "NAMSG" => {
4765                if let EpicsValue::String(s) = value {
4766                    self.common.namsg.set(&s.as_str_lossy());
4767                }
4768            }
4769            // ACKS/ACKT carry NO acknowledgement semantics here. They are
4770            // `special(SPC_NOMOD)` in `dbCommon.dbd:150-159`, so no runtime put
4771            // reaches this arm — the gate refuses it. C's acknowledgement is
4772            // driven by the DBR *request type* (`DBR_PUT_ACKS`/`ACKT`), which
4773            // `dbPut` intercepts ABOVE the SPC_NOMOD gate and hands to
4774            // [`Self::put_acks`] / [`Self::put_ackt`]. What is left here is the
4775            // `dbLoadRecords` / `dbStaticLib` load path (`field(ACKT,"YES")`),
4776            // which stores the value verbatim — C `dbPutString` never crosses
4777            // `dbPut`.
4778            "ACKS" => {
4779                if let EpicsValue::Short(v) = value {
4780                    self.common.acks = AlarmSeverity::from_u16(v as u16);
4781                }
4782            }
4783            "ACKT" => match value {
4784                EpicsValue::Char(v) => self.common.ackt = v != 0,
4785                EpicsValue::Short(v) => self.common.ackt = v != 0,
4786                _ => return Ok(CommonFieldPutResult::NoChange),
4787            },
4788            "UDF" => {
4789                // Store the raw put byte (C keeps the epicsUInt8 verbatim); a
4790                // record that re-derives UDF on process overwrites it, one that
4791                // sources nothing this cycle keeps it (put-defect cluster #3).
4792                // The calc family reads UDF straight from `common` too
4793                // (`clears_udf() == false`), so the stored byte stands.
4794                if let EpicsValue::Char(v) = value {
4795                    self.common.udf = v;
4796                }
4797            }
4798            "UDFS" => {
4799                self.common.udfs = menu_ordinal_raw(&value);
4800            }
4801            // The `String` form never reaches these three menu arms:
4802            // `coerce_common_field` has already run it through the one
4803            // menu converter, which either produced an `Enum` index or failed
4804            // the put with `S_db_badChoice`.
4805            "SCAN" => {
4806                let new_scan = match &value {
4807                    EpicsValue::Short(v) => ScanType::from_u16(*v as u16),
4808                    EpicsValue::Enum(v) => ScanType::from_u16(*v),
4809                    _ => return Ok(CommonFieldPutResult::NoChange),
4810                };
4811                let result = self.set_scan(new_scan);
4812                if !matches!(result, CommonFieldPutResult::NoChange) {
4813                    self.record.on_put(&name);
4814                    self.record.special(&name, true)?;
4815                    return Ok(result);
4816                }
4817            }
4818            "SSCN" => {
4819                let new_sscn = match &value {
4820                    EpicsValue::Short(v) => SimModeScan::from_u16(*v as u16),
4821                    EpicsValue::Enum(v) => SimModeScan::from_u16(*v),
4822                    _ => return Ok(CommonFieldPutResult::NoChange),
4823                };
4824                self.common.sscn = new_sscn;
4825            }
4826            // `PINI` is `menu(menuPini)` — the six choices NO/YES/RUN/RUNNING/
4827            // PAUSE/PAUSED (`menuPini.dbd.pod:59-65`). Resolved exactly like
4828            // `SCAN`: a menu label or a bare index, never a truthiness test.
4829            // The pre-fix `bool` arm collapsed `RUN` (index 2) to `false`, so
4830            // `caput REC.PINI RUN` *disabled* PINI instead of selecting the
4831            // iocRun pass.
4832            "PINI" => {
4833                // Store the RAW ordinal (see [`CommonFields::pini`]): C's numeric
4834                // menu put keeps `(epicsEnum16)`, so an out-of-range `caput
4835                // REC.PINI 6` / `-1` round-trips and simply matches no lifecycle
4836                // pass in `doRecordPini`. A `String` label is already resolved to
4837                // `Enum` by `coerce_common_field` (menuPini via `putStringMenu`).
4838                self.common.pini = match &value {
4839                    EpicsValue::Short(v) => *v,
4840                    EpicsValue::Char(v) => *v as i16,
4841                    EpicsValue::Enum(v) => *v as i16,
4842                    _ => return Ok(CommonFieldPutResult::NoChange),
4843                };
4844            }
4845            "TPRO" => {
4846                if let EpicsValue::Char(v) = value {
4847                    self.common.tpro = v;
4848                }
4849            }
4850            "BKPT" => {
4851                if let EpicsValue::Char(v) = value {
4852                    self.common.bkpt.set(v);
4853                }
4854            }
4855            "FLNK" => {
4856                if let EpicsValue::String(s) = value {
4857                    self.common.flnk = s.as_str_lossy().into_owned();
4858                    self.parsed_flnk = parse_forward_link_v2(&self.common.flnk);
4859                }
4860            }
4861            "INP" => {
4862                // A record type whose C `.dbd` has no INP must refuse it, the
4863                // way C's dbd does ("field not found" at load, record inert) —
4864                // `histogram`'s input link is SVL, not INP
4865                // (histogramRecord.dbd.pod:212). Without this the port accepts a
4866                // `field(INP,...)` no C IOC can load.
4867                if !self.record.declares_inp_link() {
4868                    return Err(CaError::FieldNotFound("INP".to_string()));
4869                }
4870                if let EpicsValue::String(s) = value {
4871                    self.check_link_assignment("INP", &s.as_str_lossy(), bound)?;
4872                    self.common.inp = s.as_str_lossy().into_owned();
4873                    if !self.record_declares_field("INP") {
4874                        self.parsed_inp = parse_link_v2(&self.common.inp);
4875                    }
4876                }
4877            }
4878            "OUT" => {
4879                if let EpicsValue::String(s) = value {
4880                    let s = s.as_str_lossy();
4881                    self.check_link_assignment("OUT", &s, bound)?;
4882                    // C `dbParseLink` (dbStaticLib.c:2382-2386) discards a
4883                    // CP/CPP modifier on a DBF_OUTLINK and warns once, naming
4884                    // the holder record, its field and the target. The discard
4885                    // itself is owned by `parse_output_link_v2` below; only the
4886                    // diagnostic lives here, where the record name exists and
4887                    // the link text is being (re)loaded rather than re-parsed
4888                    // per process cycle.
4889                    if out_link_discards_cp(&s) {
4890                        tracing::warn!(
4891                            target: "epics_base_rs::record",
4892                            record = %self.name,
4893                            field = "OUT",
4894                            link = %s,
4895                            "Discarding CP/CPP modifier in CA output link"
4896                        );
4897                    }
4898                    self.common.out = s.into_owned();
4899                    // C `dbDbPutValue` (dbDbLink.c:386-389): an OUT
4900                    // link processes its target only on an explicit
4901                    // ` PP` token (or a `.PROC` destination). A bare
4902                    // OUT link is NPP — `parse_output_link_v2`
4903                    // downgrades the modifier-less `ProcessPassive`
4904                    // default that `parse_link_v2` would otherwise
4905                    // apply.
4906                    if !self.record_declares_field("OUT") {
4907                        self.parsed_out = parse_output_link_v2(&self.common.out);
4908                    }
4909                    // C `longoutRecord.c::special` (PR #6c573b4 part 2)
4910                    // and similar OOCH-style hooks need `after=true`
4911                    // to fire after the link has actually moved. The
4912                    // earlier `validate_put` + `special(name, false)`
4913                    // pair only covered the before-side.
4914                    self.record.special(&name, true)?;
4915                }
4916            }
4917            // Two shapes reach DTYP and both name a device support:
4918            //
4919            // * the `.db` loader hands over the NAME verbatim, and it may be a
4920            //   name registered at runtime by a downstream crate ("asynInt32")
4921            //   that no vendored `.dbd` declares — C would reject that at load,
4922            //   the port's registry accepts it (Tier 3);
4923            // * a `dbPut` arrives as the menu INDEX, because `DBF_DEVICE` is
4924            //   served as `DBR_ENUM` and `coerce_put_value` already resolved an
4925            //   incoming label through the device menu (C `putStringMenu`,
4926            //   which fails `S_db_badChoice` on a name the menu does not have).
4927            //
4928            // The index is meaningful against the MERGED device menu (static
4929            // `device()` declarations + runtime-contributed device support) —
4930            // the exact list `coerce_put_value` bounded it by. Resolving it
4931            // against the static-only menu here would drop every contributed
4932            // name (asyn's "asynInt32", scaler-rs's "Asyn Scaler") back to
4933            // NoChange, leaving DTYP unset after a valid put.
4934            "DTYP" => match value {
4935                EpicsValue::String(s) => self.common.dtyp = s.as_str_lossy().into_owned().into(),
4936                EpicsValue::Enum(i) => {
4937                    let merged = super::merged_device_menu(self.record.record_type());
4938                    match merged.get(i as usize) {
4939                        Some(name) => self.common.dtyp = (*name).into(),
4940                        None => return Ok(CommonFieldPutResult::NoChange),
4941                    }
4942                }
4943                _ => return Ok(CommonFieldPutResult::NoChange),
4944            },
4945            "TSE" => {
4946                if let EpicsValue::Short(v) = value {
4947                    self.common.tse = v;
4948                }
4949            }
4950            "TSEL" => {
4951                if let EpicsValue::String(s) = value {
4952                    self.common.tsel = s.as_str_lossy().into_owned();
4953                    self.parsed_tsel = parse_link_v2(&self.common.tsel);
4954                }
4955            }
4956            "UTAG" => {
4957                // C UTAG is DBF_UINT64 — accept any integer-shaped value and
4958                // store the unsigned 64-bit tag. The db loader feeds every
4959                // common field as EpicsValue::String, so parse field(UTAG, "N")
4960                // rather than dropping it silently at IOC load; a CA write to
4961                // this u64 field crosses as DBR_DOUBLE (CA has no uint64 wire
4962                // type), so accept Double too.
4963                match value {
4964                    EpicsValue::UInt64(v) => self.common.utag = v,
4965                    EpicsValue::Int64(v) => self.common.utag = v as u64,
4966                    EpicsValue::Long(v) => self.common.utag = v as u64,
4967                    EpicsValue::Short(v) => self.common.utag = v as u64,
4968                    EpicsValue::Enum(v) => self.common.utag = v as u64,
4969                    EpicsValue::Char(v) => self.common.utag = v as u64,
4970                    EpicsValue::Double(v) => self.common.utag = v as u64,
4971                    EpicsValue::String(s) => {
4972                        if let Ok(EpicsValue::UInt64(v)) =
4973                            EpicsValue::parse(DbFieldType::UInt64, s.as_str_lossy().trim())
4974                        {
4975                            self.common.utag = v;
4976                        }
4977                    }
4978                    _ => {}
4979                }
4980            }
4981            "ASG" => {
4982                if let EpicsValue::String(s) = value {
4983                    self.common.asg = s.as_str_lossy().into_owned();
4984                }
4985            }
4986            "ASL" => {
4987                // C dbCommon.ASL is `epicsUInt32` in the .dbd but
4988                // only ever 0 or 1; accept Char / Short / Long for
4989                // the common put paths and clamp to {0, 1}.
4990                // db_loader feeds every common field as
4991                // `EpicsValue::String`; also accept that so a
4992                // `.db` `field(ASL, "1")` directive isn't silently
4993                // ignored at IOC load.
4994                let n: i64 = match value {
4995                    EpicsValue::Char(v) => v as i64,
4996                    EpicsValue::Short(v) => v as i64,
4997                    EpicsValue::Long(v) => v as i64,
4998                    EpicsValue::Int64(v) => v,
4999                    EpicsValue::String(s) => s.as_str_lossy().trim().parse().unwrap_or(0),
5000                    _ => return Ok(CommonFieldPutResult::NoChange),
5001                };
5002                self.common.asl = if n != 0 { 1 } else { 0 };
5003            }
5004            "DESC" => {
5005                if let EpicsValue::String(s) = value {
5006                    // DBF_STRING data field — store the bytes verbatim so a
5007                    // non-UTF-8 DESC round-trips unchanged.
5008                    if self.common.desc != s {
5009                        self.common.desc = s;
5010                        // DESC feeds `display.description` (a metadata-cache
5011                        // source) but is not property-class — C never marks
5012                        // it prop(YES) (epics-base#785) — so refresh the
5013                        // cache here at the write owner without posting
5014                        // DBE_PROPERTY: the pvxs behavior (fresh on the next
5015                        // metadata build, no event).
5016                        self.invalidate_metadata_cache();
5017                    }
5018                }
5019            }
5020            "PHAS" => {
5021                if let EpicsValue::Short(v) = value {
5022                    let old_phas = self.common.phas;
5023                    self.common.phas = v;
5024                    // Only a record that IS in a scan list can be re-sorted
5025                    // within one; the same gate the index owner applies.
5026                    if old_phas != v && self.common.scan.scan_list().is_some() {
5027                        let scan = self.common.scan;
5028                        self.record.on_put(&name);
5029                        self.record.special(&name, true)?;
5030                        return Ok(CommonFieldPutResult::PhasChanged {
5031                            scan,
5032                            old_phas,
5033                            new_phas: v,
5034                        });
5035                    }
5036                }
5037            }
5038            "EVNT" => {
5039                // C `EVNT` is DBF_STRING (event name). Accept a
5040                // string directly; accept a numeric value too for
5041                // backward compatibility (numeric events / a calc
5042                // record driving EVNT) by formatting it as a string.
5043                match value {
5044                    EpicsValue::String(s) => self.common.evnt = s.as_str_lossy().into_owned(),
5045                    EpicsValue::Short(v) => self.common.evnt = v.to_string(),
5046                    EpicsValue::Long(v) => self.common.evnt = v.to_string(),
5047                    EpicsValue::Enum(v) => self.common.evnt = v.to_string(),
5048                    EpicsValue::Double(v) => {
5049                        // Match C `eventNameToHandle`: a double with
5050                        // an integer part is treated as that integer.
5051                        self.common.evnt = (v as i64).to_string();
5052                    }
5053                    _ => {}
5054                }
5055            }
5056            "PRIO" => {
5057                if let EpicsValue::Short(v) = value {
5058                    self.common.prio = v;
5059                }
5060            }
5061            "DISV" => {
5062                if let EpicsValue::Short(v) = value {
5063                    self.common.disv = v;
5064                }
5065            }
5066            "DISA" => {
5067                if let EpicsValue::Short(v) = value {
5068                    self.common.disa = v;
5069                }
5070            }
5071            "SDIS" => {
5072                if let EpicsValue::String(s) = value {
5073                    self.common.sdis = s.as_str_lossy().into_owned();
5074                    self.parsed_sdis = parse_link_v2(&self.common.sdis);
5075                }
5076            }
5077            "DISS" => {
5078                self.common.diss = menu_ordinal_raw(&value);
5079            }
5080            "HYST" => {
5081                if let Some(v) = value.to_f64() {
5082                    self.common.hyst = v;
5083                }
5084            }
5085            "LCNT" => {
5086                if let EpicsValue::Short(v) = value {
5087                    self.common.lcnt = v;
5088                }
5089            }
5090            "DISP" => {
5091                if let EpicsValue::Char(v) = value {
5092                    self.common.disp = v;
5093                }
5094            }
5095            "PUTF" => return Err(CaError::ReadOnlyField("PUTF".into())),
5096            "RPRO" => {
5097                if let EpicsValue::Char(v) = value {
5098                    self.common.rpro = v;
5099                }
5100            }
5101            "PACT" => return Err(CaError::ReadOnlyField("PACT".into())),
5102            // C `dbPut` stores the raw byte in `prec->proc` (retained across
5103            // processing — C never resets it); `coerce_common_field` has
5104            // already projected the put onto `DBF_UCHAR` (→ `Char`). The
5105            // `pp(TRUE)` reprocess is driven separately by the put-path
5106            // force-process intercept, so this arm ONLY records the byte.
5107            "PROC" => {
5108                if let EpicsValue::Char(v) = value {
5109                    self.common.proc_field = v;
5110                }
5111            }
5112            // Analog alarm limits. The DB-load String was already coerced to
5113            // the field's DECLARED `.dbd` type by `coerce_common_field` — the
5114            // one owner of "what type does this common field hold" — so every
5115            // writer (`.db` load, `caput`, a link) lands here with a numeric
5116            // value in the record's own alarm domain, `epicsInt64` included.
5117            "HIHI" => {
5118                if let (Some(v), Some(a)) = (
5119                    AlarmLimit::from_stored(&value),
5120                    self.common.analog_alarm.as_mut(),
5121                ) {
5122                    a.hihi = v;
5123                }
5124            }
5125            "HIGH" => {
5126                if let (Some(v), Some(a)) = (
5127                    AlarmLimit::from_stored(&value),
5128                    self.common.analog_alarm.as_mut(),
5129                ) {
5130                    a.high = v;
5131                }
5132            }
5133            "LOW" => {
5134                if let (Some(v), Some(a)) = (
5135                    AlarmLimit::from_stored(&value),
5136                    self.common.analog_alarm.as_mut(),
5137                ) {
5138                    a.low = v;
5139                }
5140            }
5141            "LOLO" => {
5142                if let (Some(v), Some(a)) = (
5143                    AlarmLimit::from_stored(&value),
5144                    self.common.analog_alarm.as_mut(),
5145                ) {
5146                    a.lolo = v;
5147                }
5148            }
5149            "HHSV" => {
5150                if let Some(a) = &mut self.common.analog_alarm {
5151                    a.hhsv = menu_ordinal_raw(&value);
5152                }
5153            }
5154            "HSV" => {
5155                if let Some(a) = &mut self.common.analog_alarm {
5156                    a.hsv = menu_ordinal_raw(&value);
5157                }
5158            }
5159            "LSV" => {
5160                if let Some(a) = &mut self.common.analog_alarm {
5161                    a.lsv = menu_ordinal_raw(&value);
5162                }
5163            }
5164            "LLSV" => {
5165                if let Some(a) = &mut self.common.analog_alarm {
5166                    a.llsv = menu_ordinal_raw(&value);
5167                }
5168            }
5169            // swait-specific: OUTN is the output link name for swait records.
5170            // Mirrors to common.out so the processing framework dispatches it.
5171            "OUTN" => {
5172                if self.record.record_type() != "swait" {
5173                    // No OUTN field on any other record type — the same
5174                    // `S_dbLib_fieldNotFound` the catch-all below reports.
5175                    return Err(self.unknown_field_error(name));
5176                }
5177                if let EpicsValue::String(s) = value {
5178                    self.common.out = s.as_str_lossy().into_owned();
5179                    // Bare OUT link is NPP — see the "OUT" arm.
5180                    self.parsed_out = parse_output_link_v2(&self.common.out);
5181                }
5182            }
5183            // C `dbNameToAddr` (dbAccess.c:660-676) resolves the field part
5184            // with `dbFindFieldPart`, then falls back to `dbGetAttributePart`.
5185            // A name that is neither a record field, nor a dbCommon field, nor
5186            // an attribute resolves to nothing (`S_dbLib_fieldNotFound`), so
5187            // `dbPutField` is never reached and the caller reports the error —
5188            // `dbpf` prints "PV '%s' not found" and returns -1 (dbTest.c:787-795).
5189            // Returning success here made a put to a misspelled field a silent
5190            // no-op.
5191            //
5192            // But a field the record's `.dbd` DECLARES and no arm above stored
5193            // is NOT unknown: C `dbPut` writes it into record memory even when
5194            // no record code reads it back (`caput dfanout.HOPR 10`). Land it in
5195            // the per-instance declared-override store — the write analog of
5196            // `declared_default` — so the put is accepted and a later read
5197            // reflects it. `put_declared_override` still returns
5198            // `unknown_field_error` for a name with no `dbFldDes`, so a
5199            // misspelled field is refused exactly as before.
5200            _ => return self.put_declared_override(&name, value, bound),
5201        }
5202        self.record.on_put(&name);
5203        // C `dbPut` (dbAccess.c:1399-1405) returns the after-put
5204        // `dbPutSpecial(paddr, 1)` status to the caller — the stored value
5205        // stays, but the monitor post and the process are skipped and the
5206        // client sees the failure. Never drop it.
5207        self.record.special(&name, true)?;
5208        Ok(CommonFieldPutResult::NoChange)
5209    }
5210
5211    /// The error C reports for a write to a field name that
5212    /// [`Self::put_common_field`] does not own.
5213    ///
5214    /// Two C outcomes, split by whether the name resolves at all:
5215    ///
5216    /// - A record *attribute* (`NAME`, `RTYP`) resolves — `dbGetAttributePart`
5217    ///   succeeds — but the write is refused: `NAME` is `special(SPC_NOMOD)`
5218    ///   (dbCommon.dbd:13-17) so `dbPutSpecial` pass 0 returns `S_db_noMod`
5219    ///   (dbAccess.c:123-124), and an attribute address carries
5220    ///   `special == SPC_ATTRIBUTE`, which `dbPutField` rejects with the same
5221    ///   `S_db_noMod` (dbAccess.c:1252-1253).
5222    /// - Anything else does not resolve: `S_dbLib_fieldNotFound`.
5223    fn unknown_field_error(&self, name: String) -> CaError {
5224        if self.get_virtual_field(&name).is_some() {
5225            CaError::ReadOnlyField(name)
5226        } else {
5227            CaError::FieldNotFound(name)
5228        }
5229    }
5230
5231    /// Store a put to a field the record's `.dbd` DECLARES but the record
5232    /// models no storage for — the WRITE owner of [`Self::declared_overrides`]
5233    /// and the write analog of [`Self::declared_default`].
5234    ///
5235    /// Reached only from [`Self::put_common_field_bounded`]'s catch-all, i.e.
5236    /// after both `Record::put_field` (returned `FieldNotFound`) and every
5237    /// `dbCommon` arm above have declined the field. Three gates, mirroring
5238    /// C `dbNameToAddr`/`dbPut`:
5239    ///
5240    /// * NO `dbFldDes` (`field_desc` is `None`) — the name is not a field of
5241    ///   this record type at all. C resolves nothing and `dbPutField` reports
5242    ///   `S_dbLib_fieldNotFound`; return [`Self::unknown_field_error`] (which
5243    ///   also renders `NAME`/`RTYP` as the read-only attributes they are).
5244    /// * `special(SPC_NOMOD)` — a declared field that is immutable
5245    ///   ([`Self::is_no_mod`]: the `.dbd` `read_only`/attribute bit or the
5246    ///   record's runtime `field_no_mod`). C refuses the put with `S_db_noMod`;
5247    ///   the runtime dispatch already gates this via `field_io::check_no_mod`,
5248    ///   but the db-load path does not, so enforce it here too — never store an
5249    ///   SPC_NOMOD field in the override map.
5250    /// * [`FieldDesc::runtime_typed`] — a field whose served type C's
5251    ///   `cvt_dbaddr` re-derives from record state (`waveform.VAL` from `FTVL`,
5252    ///   `aSub.A` from `FTA`). Such a field is record-owned by definition, so
5253    ///   its `put_field` should have taken the put; if it somehow reached here
5254    ///   the override store must not shadow it (`declared_default` skips it for
5255    ///   the same reason). Treat as not-found rather than store a value under
5256    ///   the wrong type.
5257    /// * PARTIALLY modeled — `Record::get_field` serves the field but no
5258    ///   `put_field` arm accepts it (`calcout.PVAL` → `self.pval`). The record
5259    ///   owns the read path, so the write belongs in its own `put_field`, not a
5260    ///   shadow cell; refuse here rather than store a value `resolve_field`
5261    ///   would never reach. See the inline note on the `get_field` guard.
5262    ///
5263    /// Otherwise coerce the incoming value to the field's C-declared DBF type
5264    /// through the one write-side value-coercion owner
5265    /// ([`coerce_put_value`](crate::server::record::coerce_put_value)) — so a
5266    /// `.db`/`caput` string parses with C's range rules (`caput REC.PREC 99999`
5267    /// into a `DBF_SHORT` is refused, not wrapped) and a menu label resolves
5268    /// against the field's own choices — and store it. Returns
5269    /// [`CommonFieldPutResult::NoChange`]: there is no scan/phas/alarm side
5270    /// effect for a metadata field with no record behaviour, and the caller's
5271    /// value-field monitor post reads the stored value back through
5272    /// [`Self::resolve_field`].
5273    fn put_declared_override(
5274        &mut self,
5275        name: &str,
5276        value: EpicsValue,
5277        bound: MenuBound,
5278    ) -> CaResult<CommonFieldPutResult> {
5279        let Some(desc) = self.field_desc(name) else {
5280            return Err(self.unknown_field_error(name.to_string()));
5281        };
5282        if desc.runtime_typed {
5283            return Err(self.unknown_field_error(name.to_string()));
5284        }
5285        if matches!(bound, MenuBound::DbPut) && self.is_no_mod(name) {
5286            // C `dbPutSpecial` pass 0 refuses SPC_NOMOD with `S_db_noMod`
5287            // (dbAccess.c:123-127) — and `dbPutSpecial` is reached only from
5288            // `dbPutField`/`dbPut`, the RUNTIME path. `dbLoadRecords` writes
5289            // through dbStatic's `dbPutString` (dbStaticLib.c:2570), which
5290            // consults `special` for `SPC_CALC` alone; SPC_NOMOD appears in
5291            // that layer only as a filter on `dbLexRoutines.c:1285`'s
5292            // misspelled-field guesser, never as a refusal of a field the
5293            // `.db` names outright. Refusing both paths dropped every
5294            // `field(<SPC_NOMOD>,…)` directive with a stderr line —
5295            // `mca`'s SIOL/SIML, `sub`'s LA..LU, `sel`'s LA..NLST,
5296            // `scalcout`'s PA..MLST, `asyn`'s AINP/NORD/ERRS and `swait`'s
5297            // VERS — so a simulated `mca` could not be given a SIOL at all.
5298            return Err(CaError::ReadOnlyField(name.to_string()));
5299        }
5300        // The override is the WRITABLE TWIN of `declared_default`, and
5301        // `declared_default` is `resolve_field`'s fallback ONLY when the record
5302        // itself serves nothing (`Record::get_field` is `None`). If the record
5303        // DOES serve this field (`get_field` is `Some`), it is not unmodeled —
5304        // it is PARTIALLY modeled: a getter into record memory (e.g.
5305        // `calcout.PVAL` → `self.pval`, which `process()` also writes) but no
5306        // matching `put_field` arm. Storing here would place the value in a
5307        // second cell that `resolve_field` never reaches (`get_field` shadows
5308        // the override) and that no `process()` keeps in step — a silent write
5309        // loss. Such a field's put belongs in the record's OWN `put_field`
5310        // (a per-record setter, a distinct change); refuse it here rather than
5311        // half-accept it, so `resolve_field` stays single-valued. A field the
5312        // record does not serve at all falls through to be stored.
5313        if self.record.get_field(name).is_some() {
5314            return Err(self.unknown_field_error(name.to_string()));
5315        }
5316        let target = desc.dbf_type;
5317        match crate::server::record::coerce_put_value(self.record.as_ref(), name, target, value)? {
5318            Converted::Stored(coerced) => {
5319                self.declared_overrides
5320                    .insert(name.to_ascii_uppercase(), coerced);
5321            }
5322            // Nothing stored: the override keeps whatever it held.
5323            Converted::Unchanged => {}
5324        }
5325        Ok(CommonFieldPutResult::NoChange)
5326    }
5327
5328    /// Get virtual fields (NAME, RTYP).
5329    pub fn get_virtual_field(&self, name: &str) -> Option<EpicsValue> {
5330        match name {
5331            "NAME" => Some(EpicsValue::String(self.name.clone().into())),
5332            "RTYP" => Some(EpicsValue::String(
5333                self.record.record_type().to_string().into(),
5334            )),
5335            _ => None,
5336        }
5337    }
5338
5339    /// Evaluate alarms based on record type and current value.
5340    /// Uses rec_gbl_set_sevr to accumulate into nsta/nsev.
5341    ///
5342    /// CALC_ALARM is NOT raised here. C raises it inside the record's own
5343    /// `process()` (`calcRecord.c:121-123`, `calcoutRecord.c:238-241`,
5344    /// `sCalcoutRecord.c:357-363`, `aCalcoutRecord.c:304-305`,
5345    /// `swaitRecord.c:409-410`), and in the port [`Record::check_alarms`] — which
5346    /// runs immediately before this — is that owner. It used to be raised here
5347    /// instead, keyed on a hardcoded `rtype` list plus a `CALC_ALARM` pseudo-field
5348    /// no DBD declares; swait is what that construction cost: it carried the flag
5349    /// but was not on the list, so a failed `calcPerform` alarmed nowhere.
5350    pub fn evaluate_alarms(&mut self) {
5351        use crate::server::recgbl;
5352
5353        // Check UDF first — but only for record types whose C support carries
5354        // the `if (prec->udf) recGblSetSevr(..., UDF_ALARM, ...)` guard. C has
5355        // no central UDF alarm; see `Record::raises_udf_alarm`.
5356        debug_assert!(
5357            self.udf_alarm.is_some() == self.record.raises_udf_alarm(),
5358            "a record that changes its UDF_ALARM guard after construction \
5359             cannot be served from ProcessPlan"
5360        );
5361        if let Some(udf) = self.udf_alarm {
5362            recgbl::rec_gbl_check_udf(&mut self.common, udf.exact_one, udf.severity, udf.message);
5363        }
5364
5365        // The analog-alarm SLOT is the enumeration — a record has the ladder iff
5366        // `new_boxed` gave it a config, which is the one place the C `.dbd`
5367        // survey lives. A second `match rtype` here was the same list written
5368        // twice, and the two could disagree: scalcout was in neither, so its ten
5369        // C alarm fields could not even be put.
5370        //
5371        // bi / bo / busy / mbbi / mbbo STATE+COS (and mbbo SOFT) alarm evaluation
5372        // lives in each record's `Record::check_alarms` hook (C `checkAlarms`);
5373        // those records carry no analog config, so they never reach here and
5374        // cannot double-raise.
5375        if let Some(alarm_cfg) = self.common.analog_alarm {
5376            // VAL goes down in the variant the record stores it in, not
5377            // flattened to `f64`: it is what picks the ladder's comparison
5378            // domain, and `Int64(v) as f64` had already rounded the value
5379            // before the first comparison ran.
5380            let Some(input) = self.record.analog_alarm_input() else {
5381                return;
5382            };
5383            self.evaluate_analog_alarm(input, &alarm_cfg);
5384        }
5385    }
5386
5387    fn evaluate_analog_alarm(&mut self, input: super::AnalogAlarmInput, cfg: &AnalogAlarmConfig) {
5388        use crate::server::recgbl::{self, alarm_status};
5389
5390        // C `checkAlarms` returns immediately on a UDF cycle: it raises
5391        // `UDF_ALARM`/`UDFS` (already done by `rec_gbl_check_udf` in
5392        // `evaluate_alarms`), zeroes `AFVL` on the AFTC-capable records, and
5393        // returns BEFORE the range check — so `LALM` is left untouched and
5394        // `AFVL` is not filtered this cycle. The identical guard appears in
5395        // every record that shares this arm (`aiRecord.c:319-323`,
5396        // `aoRecord.c:383-386`, `longinRecord.c:274-278`,
5397        // `longoutRecord.c:317-320`, `int64inRecord.c:267-271`,
5398        // `int64outRecord.c:298-301`, `calcRecord.c:300-304`,
5399        // `calcoutRecord.c:563-566`). AFTC-capable records (ai/longin/
5400        // int64in/calc) carry `AFVL` and zero it (`prec->afvl = 0`); the
5401        // out records (ao/longout/int64out/calcout) have no `AFVL` and just
5402        // return. Running the range check here would drift `LALM` to `val`
5403        // (NaN on an undefined cycle) and filter `AFVL` — both observable.
5404        if self.common.udf != 0 {
5405            if let Some((_, afvl)) = self.record.alarm_filter_cells() {
5406                if afvl != 0.0 {
5407                    self.record.store_alarm_filter_value(0.0);
5408                }
5409            }
5410            return;
5411        }
5412
5413        // One rule for every ladder input: a record that DECLARES the field
5414        // owns it, because `Record::put_field` absorbs the client's put before
5415        // `put_common_field` ever runs, and only an undeclared field falls
5416        // through to `CommonFields`. MDEL/ADEL/MLST/ALST in
5417        // `check_monitor_deadbands` already read this way; HYST did not, so
5418        // `int64in`/`int64out`'s `pub hyst` swallowed every put while the
5419        // hysteresis compared against a permanent 0.0 — with `caget .HYST`
5420        // reading the value back, which is what made it silent.
5421        //
5422        // `common.hyst` stays an `f64` and stays exact: after the limits moved
5423        // to the declared type its only remaining readers are the
5424        // `DBF_DOUBLE` records and longin/longout, and every `epicsInt32` is
5425        // an `f64` exactly.
5426        let super::AnalogAlarmInput { val, hyst, lalm } = input;
5427
5428        // C-style per-level hysteresis: alarm fires if val passes the level,
5429        // OR if we were already at that alarm level (lalm == alev) and val
5430        // hasn't retreated past the hysteresis margin.
5431        //
5432        // `alarm_range` is the C-style integer level: 1=Lolo, 2=Low,
5433        // 3=Normal, 4=High, 5=Hihi. Required for the calc-record AFTC
5434        // filter (`calcRecord.c::checkAlarms:339-381`) which filters
5435        // on the range level (not on severity) and re-maps back.
5436        // C's `checkAlarms` enables each level with a NONZERO test on the raw
5437        // severity ordinal (`if (prec->hhsv && …)`) and passes that raw ordinal
5438        // to `recGblSetSevr`; `recGblResetAlarms` then clamps the resulting
5439        // *severity* to `INVALID_ALARM` while the *status* keeps the level. So an
5440        // out-of-range selector (`HHSV = 4`) still fires HIHI and lands
5441        // SEVR=INVALID/STAT=HIHI — reproduced by testing `!= 0` and mapping the
5442        // ordinal through [`AlarmSeverity::from_u16`] (which clamps `>= 3` to
5443        // `Invalid`).
5444        let sevs = [cfg.hhsv, cfg.llsv, cfg.hsv, cfg.lsv];
5445        let mut alarm_range = match val {
5446            // The `DBF_LONG`/`DBF_INT64` records: a limit, a hysteresis and a
5447            // LALM all land here as the exact `epicsInt64` C compares.
5448            AlarmLimit::Long(_) | AlarmLimit::Int64(_) => {
5449                let v = val.as_i128();
5450                super::alarm::analog_alarm_range(
5451                    v,
5452                    hyst.map_or(self.common.hyst as i128, AlarmLimit::as_i128),
5453                    lalm.map_or(v, AlarmLimit::as_i128),
5454                    [
5455                        cfg.hihi.as_i128(),
5456                        cfg.lolo.as_i128(),
5457                        cfg.high.as_i128(),
5458                        cfg.low.as_i128(),
5459                    ],
5460                    sevs,
5461                )
5462            }
5463            AlarmLimit::Double(v) => super::alarm::analog_alarm_range(
5464                v,
5465                hyst.map_or(self.common.hyst, AlarmLimit::as_f64),
5466                lalm.map_or(v, AlarmLimit::as_f64),
5467                [
5468                    cfg.hihi.as_f64(),
5469                    cfg.lolo.as_f64(),
5470                    cfg.high.as_f64(),
5471                    cfg.low.as_f64(),
5472                ],
5473                sevs,
5474            ),
5475        };
5476
5477        // C `range_stat[]` (`int64inRecord.c:250-253`) plus the severity and
5478        // `alev` each range selects. ONE table, because C reaches the same
5479        // mapping twice — once out of the ladder and once out of the AFTC
5480        // filter's `switch (alarmRange)` (`:326-346`).
5481        let resolve = |range: u16| -> (AlarmSeverity, u16, Option<AlarmLimit>) {
5482            match range {
5483                5 => (
5484                    AlarmSeverity::from_u16(cfg.hhsv as u16),
5485                    alarm_status::HIHI_ALARM,
5486                    Some(cfg.hihi),
5487                ),
5488                4 => (
5489                    AlarmSeverity::from_u16(cfg.hsv as u16),
5490                    alarm_status::HIGH_ALARM,
5491                    Some(cfg.high),
5492                ),
5493                2 => (
5494                    AlarmSeverity::from_u16(cfg.lsv as u16),
5495                    alarm_status::LOW_ALARM,
5496                    Some(cfg.low),
5497                ),
5498                1 => (
5499                    AlarmSeverity::from_u16(cfg.llsv as u16),
5500                    alarm_status::LOLO_ALARM,
5501                    Some(cfg.lolo),
5502                ),
5503                _ => (AlarmSeverity::NoAlarm, alarm_status::NO_ALARM, None),
5504            }
5505        };
5506
5507        // C parity: the alarm-range AFTC low-pass filter
5508        // (`{ai,longin,int64in,calc}Record.c::checkAlarms`) smooths the
5509        // integer `alarmRange` and re-maps. Only records that carry the
5510        // AFTC/AFVL fields run it — `ao`/`longout`/`int64out`/`calcout`
5511        // have no AFTC field (confirmed via the respective `.dbd.pod`),
5512        // so they are excluded.
5513        if let Some((aftc, afvl)) = self.record.alarm_filter_cells() {
5514            if aftc > 0.0 {
5515                let now = crate::runtime::general_time::get_current();
5516                let (filtered_range, new_afvl) = crate::server::records::alarm_filter::aftc_filter(
5517                    alarm_range,
5518                    aftc,
5519                    afvl,
5520                    self.common.time,
5521                    now,
5522                );
5523                self.record.store_alarm_filter_value(new_afvl);
5524                // C re-maps through the SAME `switch (alarmRange)` the ladder
5525                // fell out of, so the filter changes only the range and
5526                // `resolve` below answers for both.
5527                alarm_range = filtered_range;
5528            } else {
5529                // aftc <= 0 disables the filter. C `checkAlarms`
5530                // (e.g. aiRecord.c:356,401) initialises the local
5531                // `afvl = 0` and unconditionally stores `prec->afvl =
5532                // afvl` at the end, so a disabled filter drives AFVL to
5533                // 0. Mirror that here so a stale accumulator from a prior
5534                // `aftc > 0` run cannot mis-seed the filter if AFTC is
5535                // re-enabled later.
5536                if afvl != 0.0 {
5537                    self.record.store_alarm_filter_value(0.0);
5538                }
5539            }
5540        }
5541        let (new_sevr, new_stat, alev) = resolve(alarm_range);
5542
5543        if new_sevr != AlarmSeverity::NoAlarm {
5544            // C `aiRecord.c:405-406` — the latch is armed to the THRESHOLD, and
5545            // only when `recGblSetSevr` returns TRUE. A level that fires while a
5546            // higher-or-equal severity is already pending (an MS input link, a
5547            // SIMM alarm, a device INVALID) raises nothing, so C leaves LALM
5548            // where it was; arming it there would let the next cycle's
5549            // `lalm == alev && val >= alev - hyst` clause hold an alarm C has
5550            // already cleared.
5551            if recgbl::rec_gbl_set_sevr(&mut self.common, new_stat, new_sevr) {
5552                self.record.store_analog_lalm(alev.unwrap_or(val));
5553            }
5554        } else {
5555            // No alarm condition: reset LALM to current value. C `aiRecord.c:409`
5556            // does this unconditionally — only the alarm arm is gated.
5557            self.record.store_analog_lalm(val);
5558        }
5559    }
5560
5561    /// Invoke the registered subroutine (`sub`/`aSub` `SNAM`) if one is
5562    /// bound, before the record's `process()` body runs.
5563    ///
5564    /// C `subRecord.c::do_sub` / `aSubRecord.c::do_sub` call the named
5565    /// subroutine on EVERY `process()`. The function registry lives on the
5566    /// framework (`RecordInstance::subroutine`), not on the record, so the
5567    /// record's own `process()` is a no-op for these two types and the
5568    /// framework must drive the call. This is the SINGLE owner of that call
5569    /// for every dispatch path: the main engine
5570    /// (`process_record_with_links_inner`, the SCAN / event / CA-put-to-PP /
5571    /// FLNK path) and the by-name `process_local` (`db.process_record`,
5572    /// QSRV group / foreign-call path) both route through here, so a
5573    /// `sub`/`aSub` runs identically regardless of how it is processed.
5574    /// Previously only `process_local` invoked the subroutine, so on the
5575    /// main engine path `VAL`/`VALA..VALU`/`OUTA..OUTU` never updated.
5576    /// The cycle's status is delivered to the record on EVERY exit path — see
5577    /// [`Record::set_subroutine_status`], which aSub's OUT-link gate reads. The
5578    /// delivery is factored out of the body below so a future early return
5579    /// cannot skip it: the body returns the status, this wrapper publishes it.
5580    pub(crate) fn run_registered_subroutine(&mut self) -> CaResult<()> {
5581        let outcome = self.run_subroutine_body();
5582        // A subroutine that errored out has no C counterpart (a C subroutine
5583        // returns a `long`); it is a failed cycle, so it takes the non-zero
5584        // arm — no outputs.
5585        let status = *outcome.as_ref().unwrap_or(&SUBROUTINE_STATUS_ERROR);
5586        self.record.set_subroutine_status(status);
5587        outcome.map(|_| ())
5588    }
5589
5590    /// Returns C `process`'s `status` for this cycle: 0 only when `do_sub` ran
5591    /// and returned 0.
5592    ///
5593    /// This is C `process`'s
5594    /// `if (!status) { status = do_sub(prec); prec->val = status; }`
5595    /// (aSubRecord.c:216-224, subRecord.c:142-147). The VAL publish is HERE
5596    /// rather than inside [`Self::do_sub`] precisely because C puts it here:
5597    /// every `do_sub` exit — empty SNAM, unregistered SNAM, the subroutine's
5598    /// own return — publishes its status as aSub's VAL from this one site, and
5599    /// only the pre-`do_sub` skip (a failed `fetch_values`) leaves VAL alone.
5600    fn run_subroutine_body(&mut self) -> CaResult<i64> {
5601        // aSub `LFLG=READ`: a `SUBL` re-resolution that found a bad/unregistered
5602        // name (C `fetch_values` -> `S_db_BadSub`) or failed to read the link
5603        // signals "skip do_sub this cycle" — C `process` runs `do_sub` only on
5604        // `!status`. The framework's failed input-link fetch arms the same flag.
5605        // One-shot: taken (cleared) whether or not a subroutine is set, so it
5606        // never leaks into the next cycle. The single consumer of the flag,
5607        // shared by every process path.
5608        if std::mem::take(&mut self.suppress_subroutine_run) {
5609            return Ok(SUBROUTINE_STATUS_SKIPPED);
5610        }
5611
5612        // Every record type reaches this call on the process path, but only
5613        // `sub` and `aSub` have a `do_sub` in their rset at all. For all the
5614        // others "no subroutine is bound" is their permanent normal state, not
5615        // an unresolved SNAM, so they must not take `do_sub`'s bad-sub exit.
5616        let Some(kind) = SubroutineKind::of(self.record.record_type()) else {
5617            return Ok(SUBROUTINE_STATUS_SKIPPED);
5618        };
5619
5620        let status = self.do_sub(kind)?;
5621
5622        // aSub publishes the status as VAL (C `aSubRecord.c:224`
5623        // `prec->val = status`). The subroutine's computed outputs live in
5624        // VALA..VALU, so VAL is the return code and overwrites whatever the
5625        // closure may have written to VAL. `sub` does NOT do this — its VAL
5626        // is the value the subroutine computed. aSub VAL is DBF_LONG
5627        // (epicsInt32); the status is a C `long` truncated into it.
5628        if kind == SubroutineKind::ASub {
5629            let _ = self
5630                .record
5631                .put_field("VAL", EpicsValue::Long(status as i32));
5632        }
5633        Ok(status)
5634    }
5635
5636    /// C `do_sub` — `aSubRecord.c:454-473` and `subRecord.c:420-437`, which
5637    /// differ in exactly two places and agree everywhere else:
5638    ///
5639    /// * aSub short-circuits an EMPTY SNAM to `return 0` BEFORE the null-pointer
5640    ///   check (`if (prec->snam[0] == 0) return 0;`), so a bare
5641    ///   `record(aSub,"X"){}` is a no-op that completes with status 0, not a
5642    ///   bad-sub. `sub` has no such branch — it cannot reach here with an empty
5643    ///   SNAM because `init_record` parks PACT
5644    ///   (`Record::init_record_parks_pact`, subRecord.c:119-123).
5645    /// * an unresolved subroutine raises `BAD_SUB_ALARM` at `INVALID_ALARM` in
5646    ///   both, but aSub returns `S_db_BadSub` (which `run_subroutine_body`
5647    ///   publishes as VAL and aSub's OUT gate reads as "push nothing") while
5648    ///   `sub` returns 0.
5649    ///
5650    /// The raise is per-cycle, not a one-shot init diagnostic: C `iocInit`
5651    /// discards `init_record`'s status (iocInit.c:569-570), so the record loads
5652    /// and scans and every process cycle re-raises BAD_SUB/INVALID.
5653    fn do_sub(&mut self, kind: SubroutineKind) -> CaResult<i64> {
5654        use crate::server::recgbl::{self, alarm_status};
5655
5656        // Clone the Arc so the borrow on `self.subroutine` is released
5657        // before we mutate `self.record` / `self.common` below.
5658        let Some(sub_fn) = self.subroutine.clone() else {
5659            let snam_empty = matches!(
5660                self.record.get_field("SNAM"),
5661                Some(EpicsValue::String(s)) if s.is_empty()
5662            );
5663            if kind == SubroutineKind::ASub && snam_empty {
5664                return Ok(0);
5665            }
5666            recgbl::rec_gbl_set_sevr(
5667                &mut self.common,
5668                alarm_status::BAD_SUB_ALARM,
5669                AlarmSeverity::Invalid,
5670            );
5671            return Ok(match kind {
5672                SubroutineKind::ASub => S_DB_BAD_SUB,
5673                SubroutineKind::Sub => 0,
5674            });
5675        };
5676        // C `do_sub` returns the subroutine's `long` status.
5677        let status = sub_fn(&mut *self.record)?;
5678
5679        // A negative status raises SOFT_ALARM at the record's BRSV severity
5680        // (C `do_sub`: `if (status < 0) recGblSetSevr(SOFT_ALARM,
5681        // prec->brsv)`). It accumulates into nsta/nsev for this cycle's
5682        // recGblResetAlarms commit and runs before checkAlarms, so a higher
5683        // analog severity (e.g. the shared analog-alarm owner) still wins via
5684        // the raise-only rule. BRSV defaults to NO_ALARM, under which
5685        // recGblSetSevr is a no-op.
5686        if status < 0 {
5687            let brsv = self
5688                .record
5689                .get_field("BRSV")
5690                .and_then(|v| v.to_f64())
5691                .map(|f| AlarmSeverity::from_u16(f as u16))
5692                .unwrap_or(AlarmSeverity::NoAlarm);
5693            recgbl::rec_gbl_set_sevr(&mut self.common, alarm_status::SOFT_ALARM, brsv);
5694        } else {
5695            // C `do_sub`'s `else` arm — the ONE place either flavour writes UDF,
5696            // reached only where the subroutine actually ran and returned `>= 0`.
5697            // aSub takes `prec->udf = FALSE` (`aSubRecord.c:470`); `sub` takes
5698            // `prec->udf = isnan(prec->val)` (`subRecord.c:434`).
5699            //
5700            // `sub`'s derive is the same expression the framework's per-cycle
5701            // blanket computes, made HERE instead of there so that the cycles
5702            // which run no subroutine — the unresolved-SNAM `BAD_SUB_ALARM`
5703            // return above, a failed `fetch_values` (`suppress_subroutine_run`),
5704            // a negative status — leave UDF at its previous value, exactly as C
5705            // does. Both flavours therefore opt out of the blanket
5706            // (`Record::clears_udf` == false).
5707            self.common.udf = match kind {
5708                SubroutineKind::ASub => 0,
5709                SubroutineKind::Sub => self.record.value_is_undefined() as u8,
5710            };
5711        }
5712        Ok(status)
5713    }
5714
5715    /// The single owner of a process cycle's SUBSCRIBER posts — C `monitor()`'s
5716    /// "post every subscribed field this cycle touched" loop.
5717    ///
5718    /// Every processing path (`process_record_with_links_inner`, the deferred
5719    /// async-completion path, the simulation path, and [`Self::process_local`])
5720    /// calls this; none of them may reimplement the rules, because a rule that
5721    /// holds on one path and not another is a monitor that fires on a scan cycle
5722    /// but not on an async completion. The per-field mask resolvers
5723    /// ([`AuxPostMask`], [`crate::server::record::value_gate`]) were already
5724    /// single-owned for the same reason — this is the loop around them.
5725    ///
5726    /// It also UPDATES `last_posted` for every post the cycle makes — including
5727    /// a post to a field nobody subscribes to, which it does not return — and
5728    /// it TAKES the
5729    /// record's per-cycle post mask ([`Record::take_cycle_posted_fields`]), so
5730    /// it must run exactly once per cycle.
5731    ///
5732    /// The rules, in order:
5733    ///
5734    /// * The deadband field (default VAL), the
5735    ///   [`recgbl::RECGBL_POSTED_ALARM_FIELDS`](crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS)
5736    ///   (SEVR/STAT/AMSG/ACKS) and UDF are emitted by the caller with their own
5737    ///   C masks and are skipped here.
5738    /// * [`Record::event_posted_fields`] post from their own event path
5739    ///   (waveform HASH) — never from change detection.
5740    /// * [`Record::process_posted_fields`], when declared, is the closed set of
5741    ///   fields a process cycle may post at all.
5742    /// * A secondary value field ([`Record::fields_posted_with_value_mask`])
5743    ///   carries VAL's monitor mask, gated per its [`ValuePostGate`](super::ValuePostGate).
5744    /// * A CHANGED field carries [`AuxPostMask::mask_for`] — unless it is a
5745    ///   [`Record::fields_posted_only_when_marked`] field, which C never
5746    ///   change-detects (aCalcout AA..LL) and which therefore posts from its
5747    ///   mark alone.
5748    /// * An UNCHANGED field posts only if the record marked it this cycle:
5749    ///   statically ([`Record::force_posted_fields`]), per-cycle
5750    ///   ([`Record::take_cycle_posted_fields`]), on the alarm transition
5751    ///   ([`Record::alarm_cycle_monitored_fields`]), or in the DBE_LOG sweep
5752    ///   ([`Record::log_swept_fields`]).
5753    pub(crate) fn collect_subscriber_posts(
5754        &mut self,
5755        snapshot: &mut ProcessSnapshot,
5756        deadband_mask: EventMask,
5757        alarm_bits: EventMask,
5758        include_val: bool,
5759    ) {
5760        use crate::server::record::{CyclePostMask, ValuePostGate, value_gate};
5761
5762        // TAKE — this also clears the state it answers from (C's
5763        // `pcalc->newm = 0`), which is why this loop may run only once per cycle.
5764        let mut cycle_posted = self.record.take_cycle_posted_fields();
5765        // The record-lifetime sibling: C's `firstCalcPosted == 0` term, which
5766        // iocInit's per-cycle drain must not be able to eat. Merged here so
5767        // both reach the same branch with the same mask mapping.
5768        let first_cycle = self.record.take_first_monitor_cycle();
5769        if !first_cycle.is_empty() {
5770            cycle_posted.extend(first_cycle);
5771        }
5772        let MonitorPlan {
5773            deadband_field,
5774            value_masked,
5775            aux_post,
5776            ..
5777        } = self.monitor_plan;
5778        // C `if (prec->omod) monitor_mask |= (DBE_VALUE|DBE_LOG)` — the guard
5779        // `OnChangeForced` fields sit behind, which the record may open on a
5780        // cycle where VAL's own mask is shut. TAKEn, like `cycle_posted`, so
5781        // this loop may run only once per cycle.
5782        let secondary_guard = deadband_mask | self.record.take_secondary_value_mask();
5783
5784        // C aoRecord.c:536-549: the secondary block runs once per cycle, from
5785        // inside `if (monitor_mask)`, and each field's own `oraw != rval` test
5786        // is welded to the `oraw = rval` that follows its `db_post_events`.
5787        // Decided by the record's own old copy, not this walk's `last_posted`
5788        // change detection, and taken whether or not anyone is subscribed —
5789        // so the bookkeeping must not depend on who is watching. Decided
5790        // HERE, before the walk reads any field, so the record's old copies
5791        // are already advanced by the time the walk looks at the record;
5792        // the posts themselves land after the walk, in the order C emits
5793        // them. Which fields fired is kept as a bit per `value_masked` index
5794        // rather than a list, so the cycle allocates nothing for it.
5795        let forced_mask = (!secondary_guard.is_empty())
5796            .then_some(secondary_guard | EventMask::VALUE | EventMask::LOG);
5797        let mut forced_fired: u64 = 0;
5798        if forced_mask.is_some() {
5799            debug_assert!(
5800                value_masked.len() <= u64::BITS as usize,
5801                "fields_posted_with_value_mask is wider than the fired bitmask"
5802            );
5803            for (index, (name, gate)) in value_masked.iter().enumerate() {
5804                if *gate == ValuePostGate::OnChangeForced
5805                    && self.record.take_secondary_value_change(name)
5806                {
5807                    forced_fired |= 1 << index;
5808                }
5809            }
5810        }
5811        // Nothing is subscribed, nothing has ever been published and no
5812        // forced post fired, so the walk below has no field to reach and
5813        // nothing to advance. The record's declared post sets are the walk's
5814        // inputs alone, so asking for them, six more trips through the
5815        // vtable, is asked only here. Everything a cycle owes whether or not
5816        // anyone is watching — the TAKEs above and the record's own old-copy
5817        // advance — has already run.
5818        if self.subscribers.is_empty() && self.last_posted.is_empty() && forced_fired == 0 {
5819            return;
5820        }
5821        // Every post this walk adds sits at or past this index, so the
5822        // published-state advance at the tail covers exactly those.
5823        let first_post = snapshot.len();
5824
5825        // C's default for a change-detected auxiliary post:
5826        // `monitor_mask | DBE_VALUE | DBE_LOG` (calcRecord.c:420, subRecord.c:400;
5827        // motor `DBE_VAL_LOG` for marked fields, motorRecord.cc:3522-3645).
5828        let aux_mask = alarm_bits | EventMask::VALUE | EventMask::LOG;
5829        let alarm_fanout: &[&str] = if alarm_bits.is_empty() {
5830            &[]
5831        } else {
5832            self.record.alarm_cycle_monitored_fields()
5833        };
5834        let force_fields = self.record.force_posted_fields();
5835        let log_swept = self.record.log_swept_fields();
5836        // C change-detects nothing about these fields; only the record's own
5837        // per-cycle mark may post them (aCalcout AA..LL — no PAA..PLL previous
5838        // copy exists to compare against).
5839        let marked_only = self.record.fields_posted_only_when_marked();
5840        let event_posted = self.record.event_posted_fields();
5841        let process_posted = self.record.process_posted_fields();
5842
5843        // The walk covers every field whose published value is tracked — each
5844        // subscribed field, and each field a post has already published — not
5845        // only the fields someone watches now. C `monitor()` decides from the
5846        // record's own state and `db_post_events` with no subscriber leaves
5847        // that state advanced, so what the record has published must not
5848        // depend on who is watching: a move finished with no `.DMOV` monitor
5849        // left `last_posted` at the move-start 0 while the field read 1, and
5850        // the next subscriber's move-start 0 compared equal and was dropped.
5851        // A field with no entry has never been published; `add_subscriber`
5852        // seeds it from the value the subscriber is handed.
5853        let tracked = self.subscribers.keys().chain(
5854            self.last_posted
5855                .keys()
5856                .filter(|field| !self.subscribers.contains_key(*field)),
5857        );
5858        for field in tracked {
5859            if field == deadband_field
5860                // SEVR/STAT/AMSG/ACKS are posted by `recGblResetAlarms` itself,
5861                // each with its own C mask (recGbl.c:202-222) — the caller emits
5862                // them from `alarm_field_posts`. A second, change-detected copy
5863                // here would double-post with a mask C never uses for them
5864                // (`alarm_bits | DBE_VALUE | DBE_LOG` instead of C's DBE_VALUE
5865                // on ACKS). UDF is excluded for the opposite reason: NO C
5866                // `monitor()` posts it at all, so a processing cycle that
5867                // redefines VAL must emit no `.UDF` event (a caput to `.UDF`
5868                // still posts, through the generic put path).
5869                || crate::server::recgbl::RECGBL_POSTED_ALARM_FIELDS.contains(&field.as_str())
5870                || field == "UDF"
5871                || event_posted.contains(&field.as_str())
5872                || !process_posted.is_none_or(|allowed| allowed.contains(&field.as_str()))
5873            {
5874                continue;
5875            }
5876            let Some(val) = self.resolve_field(field) else {
5877                continue;
5878            };
5879            let changed = match self.posted_value(field) {
5880                Some(prev) => prev != &val,
5881                None => true,
5882            };
5883            if let Some(gate) = value_gate(value_masked, field) {
5884                // C posts this secondary value field with VAL's own monitor_mask,
5885                // from inside the guard that decides whether VAL posts at all —
5886                // never a forced DBE_VALUE|DBE_LOG. `ValuePostGate` says whether C
5887                // also re-tests the field's own value inside that guard (ai RVAL,
5888                // aiRecord.c:462) or posts it whenever the guard fires (timestamp
5889                // RVAL, timestampRecord.c:160).
5890                let post = match gate {
5891                    ValuePostGate::OnChange => changed && !deadband_mask.is_empty(),
5892                    ValuePostGate::WithValue => include_val,
5893                    // Decided once per cycle in `forced_fired` above, against
5894                    // the record's own old copy — never here, where the answer
5895                    // would depend on this loop's `last_posted` cache and on
5896                    // the field having a subscriber.
5897                    ValuePostGate::OnChangeForced => false,
5898                };
5899                if post {
5900                    snapshot.push((field.clone().into(), val.clone(), deadband_mask));
5901                }
5902            } else if changed && !marked_only.contains(&field.as_str()) {
5903                snapshot.push((
5904                    field.clone().into(),
5905                    val.clone(),
5906                    aux_post.mask_for(field, alarm_bits, deadband_mask),
5907                ));
5908            } else if force_fields.contains(&field.as_str()) {
5909                // C `monitor()` posts a statically re-marked field with
5910                // `monitor_mask | DBE_VAL_LOG` even when unchanged.
5911                snapshot.push((field.clone().into(), val.clone(), aux_mask));
5912            } else if cycle_posted.iter().any(|(name, _)| *name == field) {
5913                // One event per MARK, each with the mask of the C call site that
5914                // made it (`CyclePostMask`) — a field marked twice (aCalcout's
5915                // AMASK `afterCalc` post AND its NEWM `monitor()` post) is posted
5916                // twice, exactly as C posts it from both loops.
5917                for (_, cycle_mask) in cycle_posted.iter().filter(|(name, _)| *name == field) {
5918                    let mask = match cycle_mask {
5919                        CyclePostMask::Value => EventMask::VALUE,
5920                        CyclePostMask::ValueLog => EventMask::VALUE | EventMask::LOG,
5921                        CyclePostMask::MonitorValueLog => aux_mask,
5922                    };
5923                    snapshot.push((field.clone().into(), val.clone(), mask));
5924                }
5925            } else if alarm_fanout.contains(&field.as_str()) {
5926                // C motor `monitor()` (motorRecord.cc:3456-3646) posts every listed
5927                // field once `monitor_mask != 0`, so a DBE_ALARM-only subscriber
5928                // observes the alarm moment on any of them.
5929                snapshot.push((field.clone().into(), val.clone(), alarm_bits));
5930            }
5931            // C `scalerRecord.c::monitor():757-773` posts EVERY S1..Snch with a
5932            // literal DBE_LOG on every cycle it runs (it runs when `ss == IDLE`,
5933            // scalerRecord.c:510). That sweep is INDEPENDENT of the change post,
5934            // not an alternative to it: on the count-completion cycle `ss` is
5935            // IDLE and `updateCounts()` has ALREADY posted each changed Sn with
5936            // DBE_VALUE (:582), so C emits two events for that field in that one
5937            // cycle — DBE_VALUE, then DBE_LOG. Making this an `else if` on
5938            // `changed` dropped the DBE_LOG half exactly when it matters: a
5939            // DBE_LOG-only archiver would never receive the final counts.
5940            //
5941            // The sweep carries the ALARM-transition bits too. DEVIATION from C,
5942            // deliberate — CBUG-B19. C's `monitor()` opens with
5943            // `monitor_mask = recGblResetAlarms(pscal); monitor_mask |=
5944            // (DBE_VALUE|DBE_LOG);` and then posts with a LITERAL `DBE_LOG`
5945            // (scalerRecord.c:764-771) — `monitor_mask` is assigned, OR-ed, and
5946            // never read. Those two lines are dead, and their only plausible use
5947            // was as the third `db_post_events` argument.
5948            // `recGblResetAlarms` returns the alarm-transition mask that every
5949            // other record ORs into its value posts, so discarding it drops the
5950            // alarm bit: a client subscribed to `Sn` with DBE_ALARM receives
5951            // NOTHING on an alarm-severity transition of the record.
5952            //
5953            // The DBE_VALUE half of C's dead `|=` is deliberately NOT
5954            // resurrected: this sweep is unconditional, so adding VALUE would
5955            // fire a value event at every VALUE subscriber on every idle scan,
5956            // changed or not — that would be a new defect, not a fix. The value
5957            // path is separately served by the change post (C's `updateCounts()`
5958            // DBE_VALUE at `:582`).
5959            if log_swept.contains(&field.as_str()) {
5960                snapshot.push((field.clone().into(), val, EventMask::LOG | alarm_bits));
5961            }
5962        }
5963        // A guarded secondary post lands whether or not the field has a
5964        // subscriber, exactly as C calls `db_post_events` (aoRecord.c:541);
5965        // the ones nobody watches are dropped below, after the published
5966        // values have advanced.
5967        if let Some(forced_mask) = forced_mask {
5968            for (index, (name, _)) in value_masked.iter().enumerate() {
5969                if forced_fired & (1 << index) == 0 {
5970                    continue;
5971                }
5972                if let Some(val) = self.resolve_field(name) {
5973                    snapshot.push((Cow::Borrowed(name), val, forced_mask));
5974                }
5975            }
5976        }
5977        // Every post the cycle made is published whether or not anyone
5978        // watches the field: advance the published values first, then hand on
5979        // only the posts a subscriber receives. C's `db_post_events` with no
5980        // subscriber delivers nothing, and still leaves the record's state
5981        // advanced. `snapshot` is the caller's, not a field of `self`, so the
5982        // walk's pushes and this advance do not contend for the record.
5983        for (field, val, _) in snapshot.iter().skip(first_post) {
5984            self.record_value_post(field, val.clone());
5985        }
5986        let mut index = 0;
5987        snapshot.retain(|(field, _, _)| {
5988            let walked = index >= first_post;
5989            index += 1;
5990            !walked
5991                || self
5992                    .subscribers
5993                    .get(field.as_ref())
5994                    .is_some_and(|subs| !subs.is_empty())
5995        });
5996    }
5997
5998    /// The posts an `AsyncPendingNotify` pass publishes — the single owner both
5999    /// dispatch paths call (`processing.rs`'s engine and [`Self::process_local`]),
6000    /// so a mid-async post cannot obey one rule on one path and another rule on
6001    /// the other.
6002    ///
6003    /// Each post carries `DBE_VALUE|DBE_LOG`: C motor's mid-move
6004    /// `db_post_events` calls use `DBE_VAL_LOG` (motorRecord.cc:2606 DMOV, and
6005    /// every other `do_work` post), and no alarm transition ran on this pending
6006    /// pass.
6007    ///
6008    /// The deadband field is NOT change-detected against `last_posted` here.
6009    /// Whether it posts, with which mask, and where MLST/ALST land belong to
6010    /// [`Self::value_include_classes`] and [`Self::deadband_post`] on this pass
6011    /// as on every other — C motor `monitor()` runs on the move-start pass too
6012    /// (motorRecord.cc:1507) and posts RBV only on an MDEL/ADEL crossing,
6013    /// moving `mlst` to RBV (motorRecord.cc:3468-3507). `deadband_post`
6014    /// deliberately does not advance `last_posted`: MLST/ALST are where that
6015    /// field's published value lives, so change-detecting it here compared
6016    /// against a cache nothing maintains and re-posted the PREVIOUS readback at
6017    /// every move start.
6018    pub(crate) fn collect_notify_posts(
6019        &mut self,
6020        fields: Vec<(String, EpicsValue)>,
6021    ) -> ProcessSnapshot {
6022        let deadband_field = self.record.monitor_deadband_field();
6023        let mut posts = ProcessSnapshot::new();
6024        for (name, val) in fields {
6025            if name == deadband_field {
6026                // The record's own value, not the notify's copy of it: C posts
6027                // the field itself (`db_post_events(pmr, &pmr->rbv, ...)`).
6028                let (include_val, include_archive) = self.value_include_classes();
6029                // No alarm bits: `recGblResetAlarms` has not run on this pending
6030                // pass, so the post carries only the classes MDEL/ADEL fired.
6031                let deadband = self.deadband_post(EventMask::NONE, include_val, include_archive);
6032                if let Some((field, value)) = deadband.field {
6033                    posts.push((field.into(), value, deadband.mask));
6034                }
6035                continue;
6036            }
6037            if self.posted_value(&name).is_none_or(|prev| prev != &val) {
6038                self.record_value_post(&name, val.clone());
6039                posts.push((name.into(), val, EventMask::VALUE | EventMask::LOG));
6040            }
6041        }
6042        posts
6043    }
6044
6045    /// Basic process: process record, evaluate alarms, timestamp, build snapshot.
6046    /// This does NOT handle links — see process_with_context in database.rs.
6047    ///
6048    /// Returns the value/log snapshot plus a list of alarm-field posts
6049    /// (`SEVR`/`STAT`/`AMSG`/`ACKS`) with their individual C event masks.
6050    /// `SEVR` is posted `DBE_VALUE` only; `STAT`/`AMSG` carry `DBE_ALARM`
6051    /// (sevr/amsg change) and/or `DBE_VALUE` (stat change). The caller
6052    /// must fire these via `notify_field` so a `DBE_VALUE`-only `.SEVR`
6053    /// subscriber is not missed on an alarm-only change and a
6054    /// `DBE_ALARM`-only subscriber is not wrongly notified — C parity
6055    /// with `recGblResetAlarms` (recGbl.c:202-222), matching the
6056    /// `processing.rs` link path.
6057    pub fn process_local(
6058        &mut self,
6059    ) -> CaResult<(
6060        ProcessSnapshot,
6061        Vec<(&'static str, crate::server::recgbl::EventMask)>,
6062    )> {
6063        use crate::server::recgbl::{self, EventMask};
6064        const LCNT_ALARM_THRESHOLD: i16 = 10;
6065
6066        if self.pact.swap(true, std::sync::atomic::Ordering::AcqRel) {
6067            // C `dbProcess` PACT-active guard (dbAccess.c:544-557):
6068            //
6069            //   if ((precord->stat == SCAN_ALARM) ||
6070            //       (precord->lcnt++ < MAX_LOCK) ||
6071            //       (precord->sevr >= INVALID_ALARM)) goto all_done;
6072            //   recGblSetSevrMsg(precord, SCAN_ALARM, INVALID_ALARM,
6073            //                    "Async in progress");
6074            //
6075            // The alarm fires EXACTLY ONCE — on the attempt whose
6076            // pre-increment lcnt equals MAX_LOCK — and is then blocked
6077            // by the stat == SCAN_ALARM / sevr >= INVALID bails, the
6078            // same shape as the link path
6079            // (`process_record_with_links_inner`). The pre-fix guard
6080            // here used post-increment `lcnt >= threshold` with no
6081            // already-raised bail, so every reentrant attempt past the
6082            // threshold re-posted the unchanged SEVR/STAT/VAL (and the
6083            // first fire came one attempt early); it also wrote
6084            // sevr/stat directly, skipping `recGblSetSevrMsg` +
6085            // `recGblResetAlarms` — losing the "Async in progress"
6086            // AMSG and the acks bookkeeping the reset performs.
6087            let already_scan_alarm = self.common.stat == recgbl::alarm_status::SCAN_ALARM;
6088            let already_invalid = self.common.sevr >= AlarmSeverity::Invalid;
6089            let lcnt_before = self.common.lcnt;
6090            self.common.lcnt = lcnt_before.saturating_add(1);
6091            if already_scan_alarm || lcnt_before < LCNT_ALARM_THRESHOLD || already_invalid {
6092                return Ok((ProcessSnapshot::new(), Vec::new()));
6093            }
6094            recgbl::rec_gbl_set_sevr_msg(
6095                &mut self.common,
6096                recgbl::alarm_status::SCAN_ALARM,
6097                AlarmSeverity::Invalid,
6098                "Async in progress",
6099            );
6100            let _ = recgbl::rec_gbl_reset_alarms(&mut self.common);
6101            // Per-field C masks (recGbl.c:202-222): this guard only
6102            // runs on a fresh SCAN_ALARM/INVALID raise, so sevr AND
6103            // stat both moved — SEVR posts DBE_VALUE, STAT/AMSG post
6104            // the shared `stat_mask` = DBE_ALARM|DBE_VALUE, VAL posts
6105            // DBE_VALUE|DBE_LOG plus `val_mask` = DBE_ALARM.
6106            let stat_mask = EventMask::ALARM | EventMask::VALUE;
6107            let mut changed_fields = crate::server::record::ProcessSnapshot::new();
6108            if let Some(val) = self.record.val() {
6109                changed_fields.push((
6110                    "VAL".into(),
6111                    val,
6112                    EventMask::VALUE | EventMask::LOG | EventMask::ALARM,
6113                ));
6114            }
6115            changed_fields.push((
6116                "SEVR".into(),
6117                EpicsValue::Short(self.common.sevr as i16),
6118                EventMask::VALUE,
6119            ));
6120            changed_fields.push((
6121                "STAT".into(),
6122                EpicsValue::Short(self.common.stat as i16),
6123                stat_mask,
6124            ));
6125            // AMSG carries "Async in progress" alongside the STAT
6126            // transition (C recGbl.c posts STAT and AMSG together
6127            // when any alarm field moved).
6128            changed_fields.push((
6129                "AMSG".into(),
6130                EpicsValue::String(self.common.amsg.as_str().into()),
6131                stat_mask,
6132            ));
6133            return Ok((changed_fields, Vec::new()));
6134        }
6135        self.common.lcnt = 0;
6136        // RAII guard that resets `self.pact` to false on drop — both for the
6137        // normal exit path and for any `?` early return. The guard holds a raw
6138        // pointer rather than a reference because we still need `self` mutably
6139        // while the guard is alive (the record body below mutates other `self`
6140        // fields).
6141        //
6142        // This is the one PACT release that does not go through `leave_pact`,
6143        // and it provably owes no restart: `process_local` holds `&mut self` for
6144        // the whole PACT window, and a put-notify is queued only through
6145        // `queue_notify_put`, which needs that same `&mut`. So nothing can join
6146        // the restart list inside the window, and the `swap(true)` above proved
6147        // the record was idle on entry.
6148        debug_assert!(
6149            self.notify_restart_list.is_empty(),
6150            "a queued put-notify implies the record was owned, which the swap \
6151             above proved it was not"
6152        );
6153        struct ProcessGuard(*const AtomicBool);
6154        // SAFETY: AtomicBool is Sync; raw pointers don't auto-derive
6155        // Send. We hand-roll Send because the ptr targets a field of
6156        // `self`, which the caller already proves can be borrowed
6157        // through this code path. The pointer is only ever read for an
6158        // atomic store, never written, dereferenced for raw access, or
6159        // escaped from this scope.
6160        unsafe impl Send for ProcessGuard {}
6161        impl Drop for ProcessGuard {
6162            fn drop(&mut self) {
6163                // SAFETY: `self.0` was constructed from
6164                // `&self.pact as *const AtomicBool` below, where
6165                // `self` is the live RecordInstance whose lifetime
6166                // strictly outlives `_guard`. RecordInstance is
6167                // !Unpin-equivalent in practice (we never move it
6168                // while held in the database's `Arc<RwLock<_>>`), so
6169                // the pointer remains valid until Drop runs.
6170                unsafe { &*self.0 }.store(false, std::sync::atomic::Ordering::Release);
6171            }
6172        }
6173        let _guard = ProcessGuard(&self.pact as *const AtomicBool);
6174
6175        // Call subroutine if registered (for sub/aSub records). Single owner
6176        // shared with the main engine path — see `run_registered_subroutine`.
6177        self.run_registered_subroutine()?;
6178        // Soft-Channel input records must skip the RVAL->VAL convert
6179        // (C `devAiSoft.c` `read_ai` returns 2 = "don't convert" for
6180        // every Soft-Channel input record, incl. one with a constant /
6181        // unset INP). Without this, `process_local` on a soft input
6182        // with a preset VAL — e.g. NaN — would run `convert()` and
6183        // clobber it, after which the UDF check below would see a
6184        // defined value and wrongly clear UDF. The
6185        // `processing.rs` link path already does this; `process_local`
6186        // is the separate foreign-call path (`db.process_record`) and
6187        // needs the same skip. `SoftDtyp::Raw` is excluded below and still
6188        // runs convert.
6189        //
6190        // Gated on `soft_channel_skips_convert()` — identical to the
6191        // `processing.rs` link path — so this only suppresses the
6192        // `RVAL → VAL` convert step. `set_device_did_compute` is an
6193        // overloaded hook: `ai/bi/mbbi/mbbi_direct` read it as
6194        // "skip convert" (override true), but `epid` reads it as
6195        // "skip the whole built-in PID compute" (keeps default false).
6196        // Without this gate, a Soft-Channel `epid` driven through
6197        // `process_local` (`db.process_record`, e.g. QSRV group proc
6198        // members) would skip `do_pid()` entirely — the regression
6199        // d1032fe5 fixed on the `processing.rs` path only.
6200        {
6201            // The same "does the input dset return 2" question the
6202            // `processing.rs` link path asks — Plain and Async, not Raw.
6203            let is_soft = matches!(
6204                self.common.dtyp.soft(),
6205                Some(
6206                    crate::server::device_support::SoftDtyp::Plain
6207                        | crate::server::device_support::SoftDtyp::Async
6208                )
6209            );
6210            let is_output = self.record.can_device_write();
6211            if is_soft && !is_output && self.record.soft_channel_skips_convert() {
6212                self.record.set_device_did_compute(true);
6213            }
6214        }
6215        // Push framework-owned common state (UDF/PHAS/TSE/TSEL) so the
6216        // record's process() can see it — same as the processing.rs link
6217        // path. `process_local` is the foreign-call path
6218        // (`db.process_record`); without this a record driven through it
6219        // (e.g. QSRV group-process members) would not see UDF/TSE.
6220        {
6221            let ctx = self.common.process_context();
6222            self.record.set_process_context(&ctx);
6223        }
6224        let outcome = self.record.process()?;
6225        let process_result = outcome.result;
6226        // Note: process_local() does not execute ProcessActions — those are
6227        // handled by the full process_record_with_links() path in processing.rs.
6228        //
6229        // It must still apply `post_write_fields`. There are no link writes
6230        // here for them to be ordered against, so the ordering rule is
6231        // satisfied trivially; what is NOT optional is applying them at all —
6232        // a record that hands its completion-flag clear to the framework
6233        // (sseq's `busy`, scaler's `cnt`) would otherwise stay busy forever on
6234        // this path. Same store-then-`DBE_VALUE`-post as
6235        // `PvDatabase::publish_post_write_fields`.
6236        for (field, value) in outcome.post_write_fields {
6237            if self.record.put_field_internal(&field, value).is_ok() {
6238                self.notify_field_written(&field);
6239                self.notify_field(&field, crate::server::recgbl::EventMask::VALUE);
6240            }
6241        }
6242
6243        // If the record reports it modified a metadata-class field during
6244        // process(), invalidate the metadata cache so the next snapshot
6245        // rebuilds from the new values. Default impl returns false, so
6246        // most records pay zero cost here.
6247        if self.record.took_metadata_change() {
6248            self.invalidate_metadata_cache();
6249            // mirror C db_post_events(precord, NULL, DBE_PROPERTY) after record processing.
6250            // `none()` and not a parameter, alone among the `DBE_PROPERTY`
6251            // sweeps: this function is the link-LESS process path by its own
6252            // contract above, and every production process cycle goes through
6253            // `PvDatabase::process_record_with_links`, which resolves. The
6254            // claim is therefore "no caller of `process_local` has a
6255            // link-backed subscriber", and a caller that acquires one must
6256            // move to the link path rather than pass a backing here.
6257            let fields: Vec<String> = self.subscribers.keys().cloned().collect();
6258            for f in fields {
6259                self.notify_field_with_origin(
6260                    &f,
6261                    crate::server::recgbl::EventMask::PROPERTY,
6262                    0,
6263                    LinkBacking::none(),
6264                );
6265            }
6266        }
6267
6268        if process_result == RecordProcessResult::AsyncPending {
6269            // Async: PACT stays set, no further processing this cycle
6270            // Don't clear processing flag (guard won't run — we leak it intentionally)
6271            std::mem::forget(_guard);
6272            return Ok((ProcessSnapshot::new(), Vec::new()));
6273        }
6274        if let RecordProcessResult::AsyncPendingNotify(fields) = process_result {
6275            // Intermediate notification (e.g. DMOV=0 at move start).
6276            // Unlike AsyncPending, we DO release the processing flag so
6277            // subsequent I/O Intr cycles can continue processing normally.
6278            self.common.time = crate::runtime::general_time::get_current();
6279            // The pass's posts, through the owner this path shares with the
6280            // engine (`Self::collect_notify_posts`).
6281            let changed_fields = self.collect_notify_posts(fields);
6282            // _guard drops here, clearing the processing flag
6283            return Ok((changed_fields, Vec::new()));
6284        }
6285        if process_result == RecordProcessResult::CompleteNoEmit {
6286            // The record accumulated this cycle without emitting (compress
6287            // `status == 1`). C `compressRecord.c:365` runs the completion
6288            // epilogue (udf clear, timestamp, monitor, FLNK) only on an emit
6289            // cycle (`if (status != 1)`), so a non-emitting cycle must publish
6290            // nothing — skip the epilogue and return an empty snapshot, exactly
6291            // as the production engine path does in `processing.rs`. This keeps
6292            // the emit-gate uniform across both process-dispatch paths so the
6293            // invariant holds by construction, not by "process_local never
6294            // produces it". CompleteNoEmit is synchronous (PACT already
6295            // cleared); the `_guard` drops here, clearing the processing flag.
6296            return Ok((ProcessSnapshot::new(), Vec::new()));
6297        }
6298
6299        // `CompleteDeferOutput` (swait ODLY delay-start) is NOT special-cased
6300        // here: it deliberately shares the Complete value-side snapshot builder
6301        // below. C `swaitRecord.c::process` posts the value side (`monitor()`,
6302        // line 475) on the delaying cycle, so building the snapshot now is the
6303        // correct, parity-matching behavior — unlike `CompleteNoEmit` above,
6304        // whose fall-through would wrongly emit. The variant's *other* halves —
6305        // holding PACT across the delay and deferring OUT/OEVT/FLNK to the
6306        // `ReprocessAfter` continuation — are the engine path's responsibility
6307        // (`processing.rs::process_record_with_links_inner`); `process_local` is
6308        // a body-only test helper that dispatches no FLNK/output and no
6309        // `ProcessAction`, and no test drives a swait ODLY record through it. So
6310        // the invariant still holds by construction across both dispatch paths:
6311        // both publish the value side here, both leave the output side to the
6312        // engine.
6313
6314        // UDF update before alarm evaluation — C parity (see
6315        // `processing.rs`). A NaN / undefined value keeps UDF true so
6316        // `recGblCheckUDF` raises UDF_ALARM this cycle instead of the
6317        // record reporting a stale/garbage value with no alarm.
6318        if self.record.clears_udf() {
6319            self.common.udf = self.record.value_is_undefined() as u8;
6320        }
6321        // Per-record alarm hook (C `checkAlarms()`).
6322        self.record.check_alarms(&mut self.common);
6323
6324        // Evaluate alarms (accumulates into nsta/nsev)
6325        self.evaluate_alarms();
6326
6327        self.common.time = crate::runtime::general_time::get_current();
6328        // UDF already updated above — do not clear unconditionally.
6329
6330        let MonitorOutcome {
6331            snapshot,
6332            alarm_posts,
6333        } = self.monitor_cycle();
6334
6335        Ok((snapshot, alarm_posts.to_vec()))
6336    }
6337
6338    /// Check MDEL/ADEL deadbands for VAL monitor/archive filtering.
6339    /// Returns `(monitor_trigger, archive_trigger)`.
6340    ///
6341    /// Updates `MLST`/`ALST` (record-owned) and the `CommonFields`
6342    /// `mlst/alst` shadow when a trigger fires. Records without
6343    /// MDEL/ADEL (e.g. motor) default to deadband=0 (any actual
6344    /// change triggers).
6345    ///
6346    /// Delegates the comparison to the free function [`check_deadband`]
6347    /// below, which ports C `recGblCheckDeadband` (recGbl.c:345-370).
6348    /// `None` there is "this record type carries no MLST/ALST cell and
6349    /// nothing has been posted yet", the only state C does not have.
6350    /// The single owner of the deadband field's monitor post — C `monitor()`'s
6351    /// `db_post_events(&prec->val, monitor_mask)`, the one post every record
6352    /// makes for the value it deadbands.
6353    ///
6354    /// [`Self::check_deadband_ext`] decides WHETHER the MDEL/ADEL classes fired;
6355    /// this decides what the resulting post looks like, and it is the only place
6356    /// that assembles that mask. The three `processing.rs` snapshot builders and
6357    /// the `notify_monitors` path all route through here, so a record's mask rule
6358    /// cannot hold on one processing path and not another.
6359    ///
6360    /// Two record hooks strip C's `DBE_LOG` from the post:
6361    ///
6362    /// * [`Record::value_only_change_fields`] — C posts a literal `DBE_VALUE`
6363    ///   (scaler VAL, scalerRecord.c:478).
6364    /// * [`Record::fields_posted_with_monitor_mask`] — C posts
6365    ///   `monitor_mask | DBE_VALUE` (event VAL, eventRecord.c:163). `monitor_mask`
6366    ///   there is `recGblResetAlarms`'s return, i.e. the alarm bits alone, so the
6367    ///   post carries `DBE_VALUE` (+ `DBE_ALARM` when the alarm moved) and never
6368    ///   the archive `DBE_LOG` — an event's VAL reaches a `DBE_LOG` archiver on
6369    ///   no cycle at all.
6370    ///
6371    /// [`DeadbandPost::field`] is `None` when no class fired, i.e. when C's
6372    /// `if (monitor_mask)` guard would skip the post.
6373    /// C `monitor()`'s VALUE / LOG gate for the primary-value post —
6374    /// `(include_val, include_archive)`, the single owner every processing path
6375    /// feeds into [`Self::deadband_post`] and [`Self::collect_subscriber_posts`].
6376    /// Keeping it in one place is what stops the rule from holding on the
6377    /// synchronous path but not the async-continuation / put-notify paths.
6378    pub(crate) fn value_include_classes(&mut self) -> (bool, bool) {
6379        // fanout/seq "trigger" records post VAL only with the alarm events
6380        // `recGblResetAlarms` returns, never DBE_VALUE/DBE_LOG — see
6381        // `Record::process_posts_value_monitor`. The alarm bits still reach VAL
6382        // via `deadband_post`'s `alarm_bits`, so an alarm transition still posts
6383        // it; only the value/archive classes are suppressed.
6384        if !self.monitor_plan.posts_value_monitor {
6385            return (false, false);
6386        }
6387        match self.record.monitor_value_changed() {
6388            // lsi/lso post VALUE|LOG only when the string actually changed (C
6389            // `lsiRecord.c`/`lsoRecord.c` monitor: `len != olen || memcmp(oval,
6390            // val, len)`); they have no MDEL/ADEL deadband to express that, so
6391            // the gate is explicit. The MPST/APST `menuPost` "Always" override
6392            // OR-adds DBE_VALUE / DBE_LOG even on an unchanged cycle (C monitor:
6393            // `if (mpst == menuPost_Always) events |= DBE_VALUE; if (apst ==
6394            // menuPost_Always) events |= DBE_LOG;`).
6395            Some(changed) => {
6396                let (val_always, archive_always) = self.record.monitor_always_post();
6397                (changed || val_always, changed || archive_always)
6398            }
6399            None => {
6400                if self.monitor_plan.uses_deadband {
6401                    self.check_deadband_ext()
6402                } else {
6403                    // Binary records (bi/bo/busy/mbbi/mbbo): always post monitors
6404                    (true, true)
6405                }
6406            }
6407        }
6408    }
6409
6410    pub(crate) fn deadband_post(
6411        &self,
6412        alarm_bits: EventMask,
6413        include_val: bool,
6414        include_archive: bool,
6415    ) -> DeadbandPost {
6416        let MonitorPlan {
6417            deadband_field: field,
6418            log_suppressed,
6419            ..
6420        } = self.monitor_plan;
6421
6422        let mut mask = alarm_bits;
6423        if include_val {
6424            mask |= EventMask::VALUE;
6425        }
6426        if include_archive && !log_suppressed {
6427            mask |= EventMask::LOG;
6428        }
6429
6430        // The closed set applies to THIS post too. `process_posted_fields` is
6431        // "the CLOSED set of fields a process cycle of this record may post" —
6432        // and the deadband post is a post. A record whose C `monitor()` never
6433        // names the deadband field must not have one invented for it: transform
6434        // `monitor()` (transformRecord.c:786-809) walks A..P and posts no VAL
6435        // at all — VAL is an inert dummy (`:422`) — so an alarm cycle, whose
6436        // `alarm_bits` alone make `mask` non-empty, was firing a `.VAL` monitor
6437        // C never sends. Gating here rather than at each builder keeps the
6438        // single owner of the deadband post the single enforcer of the set.
6439        let in_closed_set = self
6440            .record
6441            .process_posted_fields()
6442            .is_none_or(|allowed| allowed.contains(&field));
6443
6444        let value = if mask.is_empty() || !in_closed_set {
6445            None
6446        } else if field == "VAL" {
6447            self.record.val()
6448        } else {
6449            self.resolve_field(field)
6450        };
6451        DeadbandPost {
6452            mask,
6453            field: value.map(|v| (field, v)),
6454        }
6455    }
6456
6457    pub fn check_deadband_ext(&mut self) -> (bool, bool) {
6458        // C waveform/aai/aao `monitor()` (waveformRecord.c:291-326) replaces
6459        // the analog MDEL/ADEL deadband with the MPST/APST "Always vs On
6460        // Change" mechanism: the record hashes its array content and posts
6461        // `DBE_VALUE`/`DBE_LOG` either always or only when the hash changed,
6462        // and posts `HASH` (`DBE_VALUE`) on a hash change. The record owns
6463        // the hash compute + `HASH` update; `array_hash_changed` carries the
6464        // event to the snapshot builders, which post `HASH` (the field is
6465        // excluded from the generic change-detection loop via
6466        // `event_posted_fields`).
6467        if let Some(post) = self.record.array_monitor_post() {
6468            self.array_hash_changed = post.hash_changed;
6469            return (post.post_value, post.post_archive);
6470        }
6471        self.array_hash_changed = false;
6472
6473        // The deadband is evaluated against `monitor_deadband_value()`,
6474        // not `val()` directly: a record whose monitored quantity is
6475        // not its primary value (e.g. the motor record, VAL=setpoint /
6476        // RBV=readback — C `monitor()` deadbands RBV) overrides that
6477        // hook. Default is `val()`, so other records are unaffected.
6478        let Some(val) = self.record.monitor_deadband_value() else {
6479            return (true, true);
6480        };
6481
6482        // The four cells in one ask — see `Record::monitor_deadband_cells`.
6483        // `None` for MLST/ALST survives to `check_deadband` as the "nothing
6484        // posted yet" state: record types that carry no MLST/ALST cell (sel,
6485        // scalcout) have nowhere to hold a last-posted value, and fall back to
6486        // the `CommonFields` shadow.
6487        let cells = self.record.monitor_deadband_cells();
6488        let mdel = cells.mdel.unwrap_or(0.0);
6489        let adel = cells.adel.unwrap_or(0.0);
6490        let mlst = cells.mlst.or(self.common.mlst);
6491        let alst = cells.alst.or(self.common.alst);
6492
6493        let monitor_trigger = check_deadband(val, mlst, mdel);
6494        let archive_trigger = check_deadband(val, alst, adel);
6495
6496        if monitor_trigger || archive_trigger {
6497            self.record
6498                .store_monitor_last_posted(val, monitor_trigger, archive_trigger);
6499        }
6500        if archive_trigger {
6501            self.common.alst = Some(val);
6502        }
6503        if monitor_trigger {
6504            self.common.mlst = Some(val);
6505        }
6506
6507        (monitor_trigger, archive_trigger)
6508    }
6509
6510    /// Build a Snapshot for a given value, populated with the record's display
6511    /// metadata and the link metadata the poster resolved for this batch. Uses
6512    /// the metadata cache so the populate cost is paid at most once per
6513    /// metadata-stable interval (cf. `cached_metadata`).
6514    ///
6515    /// There is deliberately no `backing`-less form. One existed, defaulting to
6516    /// [`LinkBacking::none`], and it made "nothing was resolved" the thing a
6517    /// caller says by saying nothing — which is how the `DBE_PROPERTY` sweep
6518    /// came to post `CALC.A` with the calc's own precision (see
6519    /// `link_backed_metadata_is_read_live.rs`). A caller with nothing to
6520    /// resolve still writes `LinkBacking::none()`, and then it is a claim a
6521    /// reviewer can see and check.
6522    ///
6523    /// The monitor path reaches the same one consumer the GET path does
6524    /// (`finish_field_snapshot` -> `route_field_metadata`), so it carried the
6525    /// same defect: measured on the wire, a `camonitor -s` on a `calc`'s `A`
6526    /// after `caput TARGET.PREC 4` with the source never processed printed
6527    /// `5.0` where C printed `5.0000`. The resolve cannot happen here — the
6528    /// post runs with the record's own lock held — so the caller that owns the
6529    /// process/put cycle resolves it at a point where no lock is held and
6530    /// hands it in.
6531    ///
6532    /// Returns `None` when the backing DECLINED and this field is link-backed.
6533    /// The poster's gate ("nobody is subscribed, so skip the resolve") is asked
6534    /// under a read lock the walk then releases, and a subscriber can attach
6535    /// before the post takes the write lock. That one event would otherwise go
6536    /// out carrying the record's own seed where the link's metadata belongs.
6537    /// C closes the same window by lock discipline: `db_add_event` takes
6538    /// `dbScanLock` on the record's lock set (`dbEvent.c:730`), which
6539    /// `dbProcess` holds for the whole cycle, so a monitor attaching mid-cycle
6540    /// joins after it and receives events from the NEXT process, never that
6541    /// one. Refusing here reproduces that: the late subscriber's first event
6542    /// comes from the next cycle, and its initial value came from the
6543    /// create-channel GET, which resolves on the ungated door.
6544    pub fn make_monitor_snapshot(
6545        &self,
6546        field: &str,
6547        value: EpicsValue,
6548        backing: LinkBacking<'_>,
6549    ) -> Option<super::super::snapshot::Snapshot> {
6550        // A monitor update is posted from the record's own change-detection
6551        // loop, which hands over the STORED variant. Project it onto the
6552        // field's declared type here, at the same owner the GET path and the
6553        // CA create-channel path use, or a client that was told `DBR_ENUM` at
6554        // create time would be posted a `DBR_SHORT` update.
6555        // The poster's obligation, checked rather than trusted: a link-backed
6556        // field carries its target's units/precision/limits, and only a caller
6557        // holding no record lock can resolve them. A `LinkBacking::none()`
6558        // here would silently serve the slot's C seed instead — the X2 defect
6559        // in its new clothes. Debug-only because it is a property of the call
6560        // graph, not of the data: every path either resolves or provably
6561        // posts no link-backed field, and the suite is what proves it.
6562        debug_assert!(
6563            !backing.is_unresolved()
6564                || self
6565                    .record
6566                    .link_backed_metadata_field(&field.to_ascii_uppercase())
6567                    .is_none(),
6568            "{}: monitor post of link-backed field {field} with nothing resolved \
6569             — the poster must call PvDatabase::resolve_link_backed_metadata \
6570             at a point where it holds no record lock",
6571            self.name
6572        );
6573        // The window above. Cheap enum test first — a declined backing only
6574        // ever comes from the poster door, so every other caller pays one
6575        // discriminant compare and never the field lookup.
6576        if backing.is_declined()
6577            && self
6578                .record
6579                .link_backed_metadata_field(&field.to_ascii_uppercase())
6580                .is_some()
6581        {
6582            return None;
6583        }
6584        let value = self.project_to_declared_type(field, value);
6585        Some(self.finish_field_snapshot(field, value, backing))
6586    }
6587
6588    /// Apply a record's per-field metadata override (C RSET
6589    /// `get_units`/`get_precision`/`get_graphic_double`/
6590    /// `get_control_double`/`get_alarm_double`, all keyed by field)
6591    /// over the cached record-level metadata. Shared by the GET and
6592    /// monitor snapshot builders. Computed live on every call — never
6593    /// cached — so overrides derived from fields outside the
6594    /// [`is_metadata_cache_source`] set cannot go stale.
6595    ///
6596    /// This is also where the record-level `Q:form` info tag is narrowed to
6597    /// the served field: QSRV assigns `display.form.index` only when the
6598    /// channel addresses the VAL field (`IOCSource::initialize` gates it on
6599    /// `dbIsValueField(dbChannelFldDes(chan))`, `iocsource.cpp:53`; the form
6600    /// *menu*, `form.choices`, is published for every field). The metadata
6601    /// cache is per-record, so a channel on `REC.RVAL` of a record carrying
6602    /// `info(Q:form, "Hex")` used to report Hex where pvxs reports Default.
6603    /// Both `Snapshot` producers (`snapshot_for_field` for GET,
6604    /// `make_monitor_snapshot` for updates) run this one owner, so
6605    /// `DisplayInfo::form` means exactly one thing on every path: the form
6606    /// index that applies to THIS field.
6607    fn apply_field_metadata_override(
6608        &self,
6609        field: &str,
6610        snap: &mut super::super::snapshot::Snapshot,
6611    ) {
6612        if let Some(display) = snap.display.as_mut()
6613            && !crate::server::database::is_value_field(field)
6614        {
6615            display.form = 0;
6616        }
6617        let Some(ov) = self.record.field_metadata_override(field) else {
6618            return;
6619        };
6620        if ov.units.is_some()
6621            || ov.precision.is_some()
6622            || ov.disp_limits.is_some()
6623            || ov.alarm_limits.is_some()
6624        {
6625            let d = snap.display.get_or_insert_with(Default::default);
6626            if let Some(units) = ov.units {
6627                d.units = units;
6628            }
6629            if let Some(precision) = ov.precision {
6630                d.precision = precision;
6631            }
6632            if let Some((upper, lower)) = ov.disp_limits {
6633                d.upper_disp_limit = upper;
6634                d.lower_disp_limit = lower;
6635            }
6636            if let Some((hihi, high, low, lolo)) = ov.alarm_limits {
6637                d.upper_alarm_limit = hihi;
6638                d.upper_warning_limit = high;
6639                d.lower_warning_limit = low;
6640                d.lower_alarm_limit = lolo;
6641            }
6642        }
6643        if let Some((upper, lower)) = ov.ctrl_limits {
6644            let c = snap.control.get_or_insert_with(Default::default);
6645            c.upper_ctrl_limit = upper;
6646            c.lower_ctrl_limit = lower;
6647        }
6648    }
6649
6650    /// C's rset metadata slots route **per field**, on `dbGetFieldIndex`. The
6651    /// port's metadata cache is the record's VAL metadata, and serving it to
6652    /// every field is what made a non-VAL field report VAL's limits.
6653    ///
6654    /// Every base record's `get_control_double` / `get_alarm_double` has the
6655    /// same two-arm shape: a listed set of field indices that take the
6656    /// record's own limits, and a `default:` arm that hands the field to
6657    /// `recGblGetControlDouble` / `recGblGetAlarmDouble` — the field TYPE's
6658    /// numeric range, and four NaN. This routes the `default:` arm; a listed
6659    /// field keeps the cache, which already holds exactly the record's own
6660    /// limits (and already distinguishes `ao`'s DRVH/DRVL from `ai`'s
6661    /// HOPR/LOPR).
6662    ///
6663    /// The three slots' listed sets are **different**, and each has its own
6664    /// owner here: [`Self::control_explicit_field`],
6665    /// [`Self::graphic_explicit_field`] and [`Self::alarm_explicit_field`].
6666    /// They are separate switches over separate field lists in C, so the
6667    /// membership question is asked once per slot, never once for both — and
6668    /// each list varies by record TYPE, so it is asked once per type too.
6669    ///
6670    /// Measured on a real `softIocPVX` against `record(calc,"X"){}`:
6671    /// `.PHAS` (DBF_SHORT, unlisted) serves control ±32767 — the SHRT range —
6672    /// while `.VAL` and `.HIHI` (both listed) serve 0/0 from HOPR/LOPR.
6673    ///
6674    /// Display (graphic) limits differ from control in one way: the `default:`
6675    /// arm of `get_graphic_double` tries a LINK first (`calcRecord.c`
6676    /// `get_linkNumber` → `dbGetGraphicLimits`) and only falls to `recGbl` for
6677    /// a field that backs no link. A constant (unset) link has no metadata
6678    /// getters, so the `dbAccess.c:216` 0/0 seed stands — measured: `CALC.A`
6679    /// serves display 0/0 but control ±1e300. [`Record::link_backed_metadata_field`]
6680    /// carries that per-record C knowledge.
6681    ///
6682    /// Units and precision are routed here too, and they are NOT the
6683    /// `get_*_double` shape: neither has a `recGbl` range arm, so a field the
6684    /// type's switch does not name keeps `dbAccess.c`'s memset — empty units,
6685    /// and the precision seed. They were built into the record-level cache
6686    /// until `subArray`/`sel`/`sub`/`dfanout` were measured serving `""`/`0`
6687    /// against C's EGU/PREC, which is the same dual meaning the alarm leaves
6688    /// had: whether a field carried the record's own value depended on a
6689    /// `match rtype` in a different function.
6690    ///
6691    /// The last arm is not the same for every record type — a slot can also
6692    /// fall through WITHOUT delegating, keeping the seed. That fact is one bit
6693    /// per record type, read from its C source: `control_default_arm`.
6694    fn route_field_metadata(
6695        &self,
6696        field: &str,
6697        backing: LinkBacking<'_>,
6698        explicit_alarm: (f64, f64, f64, f64),
6699        snap: &mut super::super::snapshot::Snapshot,
6700    ) {
6701        // The rset slots this record type actually supplies. A NULL slot makes
6702        // `dbAccess.c` clear the option bit, so the leaf is never served and
6703        // there is nothing to route — minting a value here would put a
6704        // fabricated number into the struct while `Snapshot::properties` says
6705        // the slot is absent, and the two wires read different halves of that
6706        // disagreement: CA goes through the mask-gated accessors
6707        // (`codec.rs::get_limits` calls `graphic_limits()`/`alarm_limits()`/
6708        // `control_limits()`, each `then_some`-gated), PVA reads the struct
6709        // straight (`native_source.rs:226`) under its own leaf mask.
6710        let slots = self.record.property_support();
6711        let rtype = self.record.record_type();
6712        let f = field.to_ascii_uppercase();
6713
6714        // C's `get_linkNumber` question, asked ONCE per snapshot: which of this
6715        // record's own link fields, if any, supplies this field's metadata.
6716        // Four of the six slots consult it — `aSubRecord.c:306-404` is the
6717        // complete specimen — and control does not, because
6718        // `dbGetControlLimits` has no caller anywhere in base.
6719        let link_backed = self.record.link_backed_metadata_field(&f);
6720        // Resolved for THIS build and handed in — see [`LinkBacking`]. Reading
6721        // a value the record had stored is what made a `caput SRC.EGU` invisible
6722        // to a passive source's clients until the source next processed.
6723        let link_meta = link_backed.as_ref().and_then(|lf| backing.metadata(lf));
6724
6725        // C `get_units`'s "no case" arm — the field the rset tests for and
6726        // declines to write, leaving `dbAccess.c:378`'s zeroed buffer. The
6727        // record-level cache holds `EGU` for every type that supplies the
6728        // slot, so this is the step that takes it back off the fields C never
6729        // gives it to. See [`Self::units_from_egu`].
6730        if slots.units {
6731            if link_backed.is_some() {
6732                // C's link arm — `dbGetUnits(&prec->inpa + n, ...)`, which
6733                // writes only what the TARGET record supplies. A constant or
6734                // unresolved link supplies nothing and `dbAccess.c:378`'s
6735                // zeroed buffer stands.
6736                snap.display.get_or_insert_with(Default::default).units = link_meta
6737                    .and_then(|m| m.units.as_deref())
6738                    .map(crate::types::PvString::from)
6739                    .unwrap_or_default();
6740            } else if !self.units_from_egu(rtype, &f) {
6741                snap.display.get_or_insert_with(Default::default).units = Default::default();
6742            }
6743        }
6744
6745        // C `get_precision`'s link arm, which the port had no arm for at all.
6746        // All five link-routing types seed `*pprecision = prec->prec` — the
6747        // record's own PREC, already in the metadata cache — and overwrite it
6748        // only when `dbGetPrecision` on the backing link SUCCEEDS
6749        // (`calcRecord.c:184-203`, `aSubRecord.c:323-348`). So an unresolved or
6750        // constant link means "leave the cache alone", which is the `None`
6751        // arm here.
6752        if slots.precision {
6753            let link_precision = link_meta.and_then(|m| m.precision);
6754            if link_backed.is_some()
6755                && let Some(precision) = link_precision
6756            {
6757                snap.display.get_or_insert_with(Default::default).precision = precision;
6758            }
6759            // C `get_precision`'s SHARED TAIL — `recGblGetPrec`
6760            // (`recGbl.c:119-144`), which every one of these bodies hands the
6761            // fields it did not name. For a field that can carry a precision
6762            // (`dbAccess.c:388-389` gates `DBR_PRECISION` on
6763            // `DBF_FLOAT`/`DBF_DOUBLE`) the tail does one thing: clamp a PREC
6764            // outside `0..=15` to 15. Applied here rather than per record so
6765            // "does this field take the tail" has one owner —
6766            // [`Self::precision_explicit_field`] — instead of thirty
6767            // `field_metadata_override`s that each have to remember it.
6768            //
6769            // Gated on the field being float or double so the tail runs
6770            // exactly where C's does: `dbAccess.c:388-389` refuses to call
6771            // `get_precision` at all for any other type, which is what keeps
6772            // `recGblGetPrec`'s integer arm (`*precision = 0`) unobservable —
6773            // it must stay unobservable here too.
6774            let field_type = self.static_field_type(field);
6775            if matches!(
6776                field_type,
6777                Some(crate::types::DbFieldType::Float | crate::types::DbFieldType::Double)
6778            ) && !Self::precision_explicit_field(
6779                rtype,
6780                &f,
6781                link_backed.is_some(),
6782                link_precision.is_some(),
6783            ) {
6784                let d = snap.display.get_or_insert_with(Default::default);
6785                d.precision = crate::server::recgbl::rec_gbl_get_prec(field_type, d.precision);
6786            }
6787        }
6788
6789        // C `get_control_double`'s last arm. No base record routes control
6790        // through a link: `dbGetControlLimits` has zero callers in all of
6791        // base, so unlike display this arm needs no link branch.
6792        if slots.control_double && !Self::control_explicit_field(rtype, field) {
6793            let (upper, lower) =
6794                match super::record_trait::control_default_arm(self.record.record_type()) {
6795                    // `recGblGetControlDouble` → `getMaxRangeValues(field_type)`.
6796                    // A type with no case in C's switch (STRING/MENU/DEVICE/links)
6797                    // is written by nothing, leaving the `dbAccess.c:256` seed —
6798                    // which is 0/0, exactly what `unwrap_or` supplies.
6799                    super::record_trait::RsetDefaultArm::RecGblRange => {
6800                        self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
6801                    }
6802                    // The slot exists but writes nothing here, so the same
6803                    // `dbAccess.c:256` seed stands. Modelled as a value rather
6804                    // than as `None`: C's option bit is ON (the slot is supplied
6805                    // and returned 0), so the leaf IS served — carrying the seed.
6806                    super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
6807                };
6808            snap.control = Some(super::super::snapshot::ControlInfo {
6809                upper_ctrl_limit: upper,
6810                lower_ctrl_limit: lower,
6811            });
6812        }
6813
6814        // C `get_graphic_double`'s last arm. Unlike control this one has a LINK
6815        // branch ahead of the recGbl call, so the three answers are: keep the
6816        // cache (listed on HOPR/LOPR), the link's limits, or the default arm.
6817        if slots.graphic_double && !Self::graphic_explicit_field(rtype, field) {
6818            let (upper, lower) = if link_backed.is_some() {
6819                // `dbGetGraphicLimits` on the backing link. A CONSTANT link has
6820                // no metadata getters and an unresolved one has nothing cached,
6821                // so in both cases the `dbAccess.c:216` 0/0 seed stands.
6822                link_meta
6823                    .and_then(|m| m.graphic_limits)
6824                    .map(|(lower, upper)| (upper, lower))
6825                    .unwrap_or((0.0, 0.0))
6826            } else {
6827                match super::record_trait::graphic_default_arm(rtype) {
6828                    super::record_trait::RsetDefaultArm::RecGblRange => {
6829                        self.rec_gbl_range_for(field).unwrap_or((0.0, 0.0))
6830                    }
6831                    super::record_trait::RsetDefaultArm::Seed => (0.0, 0.0),
6832                }
6833            };
6834            let d = snap.display.get_or_insert_with(Default::default);
6835            d.upper_disp_limit = upper;
6836            d.lower_disp_limit = lower;
6837        }
6838
6839        // C `get_alarm_double`, BOTH arms. This branch owns the four limits
6840        // outright: `slots.alarm_double` is exactly the condition under which
6841        // `getProperties` assigns the four `valueAlarm.*Limit` leaves, so
6842        // whenever the leaves are served this assigns them.
6843        //
6844        // The explicit arm used to be left to the record-level metadata cache
6845        // (`populate_display_info`), whose `match rtype` covered only some of
6846        // the types that supply the slot. A type it missed reached the wire
6847        // with `snap.display == None` and the four leaves kept the NT's
6848        // structural 0 — measured: DFANOUT.VAL, SEL.VAL and SUB.VAL served 0
6849        // where C serves NaN. That made "which limits does VAL carry" depend on
6850        // a match arm existing somewhere else, which is the dual meaning this
6851        // single owner removes.
6852        if slots.alarm_double {
6853            let (hihi, high, low, lolo) = if Self::alarm_explicit_field(rtype, field) {
6854                explicit_alarm
6855            } else if link_backed.is_some() {
6856                // `dbGetAlarmLimits` on the backing link; `dbAccess.c:294`'s
6857                // four NaN stand when it supplies nothing.
6858                link_meta
6859                    .and_then(|m| m.alarm_limits)
6860                    .map(|(lolo, low, high, hihi)| (hihi, high, low, lolo))
6861                    .unwrap_or_else(crate::server::recgbl::rec_gbl_get_alarm_double)
6862            } else {
6863                crate::server::recgbl::rec_gbl_get_alarm_double()
6864            };
6865            // The four alarm limits live on DisplayInfo because that mirrors
6866            // C's `dbr_gr_double` packing, which the CA encoder depends on.
6867            // Minting it here is safe: every other DisplayInfo field defaults
6868            // to the same value the `None` path already served.
6869            let d = snap.display.get_or_insert_with(Default::default);
6870            d.upper_alarm_limit = hihi;
6871            d.upper_warning_limit = high;
6872            d.lower_warning_limit = low;
6873            d.lower_alarm_limit = lolo;
6874        }
6875    }
6876
6877    /// The four limits C's `get_alarm_double` serves for the fields its rset
6878    /// lists — [`alarm_explicit_fields`](super::record_trait::alarm_explicit_fields).
6879    ///
6880    /// Read through [`Self::resolve_field`], the same unified accessor C's
6881    /// `prec->hihi` is. The port stores the eight alarm fields in one of two
6882    /// disjoint homes — `common.analog_alarm` for the types with the analog
6883    /// ladder (`ai`/`ao`/`calc`/…), the record's own struct for the types
6884    /// without it (`dfanout`/`sel`) — and `resolve_field` spans both. Reading
6885    /// the ladder slot directly instead would answer NaN for every `dfanout`
6886    /// and `sel` no matter how its HIHI/HHSV were set, because those two types
6887    /// have no slot at all.
6888    fn explicit_alarm_limits(&self, rtype: &str) -> (f64, f64, f64, f64) {
6889        let limit = |name: &str| {
6890            self.resolve_field(name)
6891                .and_then(|v| v.to_f64())
6892                .unwrap_or(0.0)
6893        };
6894        // The raw stored ordinal, NOT clamped to 0..=3: C tests `prec->hhsv`
6895        // for NONZERO, so an out-of-range severity still enables its limit.
6896        let severity = |name: &str| {
6897            self.resolve_field(name)
6898                .and_then(|v| v.to_f64())
6899                .unwrap_or(0.0) as i16
6900        };
6901        match super::record_trait::alarm_val_arm(rtype) {
6902            super::record_trait::AlarmValArm::Unconditional => {
6903                (limit("HIHI"), limit("HIGH"), limit("LOW"), limit("LOLO"))
6904            }
6905            super::record_trait::AlarmValArm::Gated => (
6906                gated(severity("HHSV"), limit("HIHI")),
6907                gated(severity("HSV"), limit("HIGH")),
6908                gated(severity("LSV"), limit("LOW")),
6909                gated(severity("LLSV"), limit("LOLO")),
6910            ),
6911        }
6912    }
6913
6914    /// The fields C's **`get_control_double`** answers with the record's own
6915    /// cached limits, rather than letting them fall to the `default:` arm.
6916    ///
6917    /// "VAL plus the seven alarm bands" is one type's list, not the shared
6918    /// one: it holds for `ai` (`aiRecord.c:267-288`), `ao`, `calc`, `calcout`,
6919    /// `longin`, `longout`, `int64in`, `int64out` and `sub`
6920    /// (`subRecord.c:272-292`) — the `_` arm — and for no other type. Every
6921    /// list below is transcribed from that type's own rset:
6922    ///
6923    /// * `aSub` (`aSubRecord.c:372-376`) is a bare `recGblGetControlDouble`:
6924    ///   it lists NOTHING, VAL included.
6925    /// * `seq` (`seqRecord.c:342-353`) lists only DLYn and `bo`
6926    ///   (`boRecord.c:310-318`) only HIGH — and both answer a LITERAL rather
6927    ///   than the cache, so they come from
6928    ///   [`Record::field_metadata_override`] (which runs after this routing
6929    ///   and wins over the `default:` arm). Nothing of these two types keeps
6930    ///   the cache, VAL included.
6931    /// * `dfanout` (`dfanoutRecord.c:197-213`) lists VAL and the three
6932    ///   latches but NOT the four bands.
6933    /// * `sel` (`selRecord.c:203-235`) lists the eight plus `A`..`L` /
6934    ///   `LA`..`LL`; `acalcout`/`scalcout` (`aCalcoutRecord.c:793-822`,
6935    ///   `sCalcoutRecord.c:653-682`) list VAL and the four bands but NOT the
6936    ///   latches, plus `A`..`L` / `PA`..`PL`.
6937    /// * `epid` (`epidRecord.c:263-287`) lists VAL, the four bands and CVAL on
6938    ///   HOPR/LOPR; `motor` (`motorRecord.cc:3263-3308`) lists VAL and RBV on
6939    ///   HLM/LLM.
6940    /// * the array types (`waveformRecord.c:268-289`, `aaiRecord.c:287-304`,
6941    ///   `aaoRecord.c:292-309`, `compressRecord.c:487-502`,
6942    ///   `histogramRecord.c:458-475`, `subArrayRecord.c:258-287`) list VAL
6943    ///   alone on the cache — their other listed fields answer computed spans,
6944    ///   so those too come from [`Record::field_metadata_override`].
6945    ///
6946    /// Fields whose listed case answers something OTHER than the record's
6947    /// cached limits are deliberately absent — `motor`'s DVAL/DRBV (DHLM/DLLM)
6948    /// and `epid`'s OVAL/P/I/D (DRVH/DRVL) have no override yet and so still
6949    /// take the `default:` arm.
6950    ///
6951    /// **Not** the other two slots' lists — see [`Self::alarm_explicit_field`]
6952    /// (smaller) and [`Self::graphic_explicit_field`] (larger, and cut short
6953    /// for different types). C's three rset arms are separate switches over
6954    /// separate field lists, so one shared predicate could only ever be right
6955    /// for one of them.
6956    fn control_explicit_field(rtype: &str, field: &str) -> bool {
6957        // The types that list nothing the cache can answer, VAL included.
6958        //
6959        // `tableRecord.c:795-810` and `mcaRecord.c:929-943` are the same
6960        // shape as `aSub`: a small named set (table's six user coordinates
6961        // `AX`..`Z`, mca's dead `BPTR` arm) and `recGblGetControlDouble` for
6962        // everything else — VAL and the alarm bands included. Both named sets
6963        // answer a literal rather than the record's HOPR/LOPR, so they come
6964        // from [`Record::field_metadata_override`] and nothing here keeps the
6965        // cache.
6966        if matches!(rtype, "aSub" | "seq" | "bo" | "table" | "mca") {
6967            return false;
6968        }
6969        if crate::server::database::is_value_field(field) {
6970            return true;
6971        }
6972        let f = field.to_ascii_uppercase();
6973        let bands: &[&str] = match rtype {
6974            "dfanout" => &["LALM", "ALST", "MLST"],
6975            "acalcout" | "scalcout" | "epid" => &["HIHI", "HIGH", "LOW", "LOLO"],
6976            "waveform" | "aai" | "aao" | "compress" | "histogram" | "subArray" | "motor" => &[],
6977            _ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
6978        };
6979        if bands.contains(&f.as_str()) {
6980            return true;
6981        }
6982        match rtype {
6983            // sel's args are 12 (`SEL_MAX`), not the calc family's 21.
6984            "sel" => Self::calc_arg_field(&f, 12),
6985            "acalcout" | "scalcout" => {
6986                Self::calc_arg_field(&f, 12)
6987                    || matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
6988            }
6989            "epid" => f == "CVAL",
6990            "motor" => f == "RBV",
6991            _ => false,
6992        }
6993    }
6994
6995    /// The fields C's **`get_alarm_double`** lists explicitly — **VAL alone**,
6996    /// not the eight [`Self::control_explicit_field`] lists.
6997    ///
6998    /// Transcribed from every rset in base that supplies the slot. Most are a
6999    /// bare `if (dbGetFieldIndex(paddr) == indexof(VAL))` with every other
7000    /// field falling to `recGblGetAlarmDouble` (`recGbl.c:155-162`, four NaN):
7001    /// `aiRecord.c:294`, `aoRecord.c:368`, `dfanoutRecord.c:218`,
7002    /// `int64inRecord.c:239`, `int64outRecord.c:283`, `longinRecord.c:244`,
7003    /// `longoutRecord.c:300`, `selRecord.c:241`. Three do NOT have that shape
7004    /// and reach the same NaN for a band field the long way —
7005    /// `calcRecord.c:257-280`, `calcoutRecord.c:532-555` and
7006    /// `subRecord.c:294-317` hoist the index into a `fieldIndex` local, test
7007    /// VAL, and otherwise try `get_linkNumber` first, so only a field that is
7008    /// neither VAL nor an `INPx` slot falls through to `recGblGetAlarmDouble`
7009    /// (`subRecord.c:313-314`); an `INPx` field takes that LINK's alarm limits
7010    /// through `dbGetAlarmLimits`, not the four NaN.
7011    ///
7012    /// So `.HIHI` serves VAL's *control* limits but NOT VAL's *alarm* limits —
7013    /// the band fields' four alarm limits are the recGbl NaN. Routing both
7014    /// slots off one VAL-class predicate is what put the record's own
7015    /// valueAlarm limits on all eight.
7016    ///
7017    /// Which fields each type lists — and the fact that some list none, and
7018    /// that `motor` lists two — is one per-type table,
7019    /// [`alarm_explicit_fields`](super::record_trait::alarm_explicit_fields);
7020    /// what that listed arm ANSWERS is its twin,
7021    /// [`alarm_val_arm`](super::record_trait::alarm_val_arm). Keeping the two
7022    /// questions in one place is what lets this predicate stay a pure
7023    /// membership test.
7024    fn alarm_explicit_field(rtype: &str, field: &str) -> bool {
7025        super::record_trait::alarm_explicit_fields(rtype)
7026            .iter()
7027            .any(|f| field.eq_ignore_ascii_case(f))
7028    }
7029
7030    /// `A`..`A+n-1` (a single letter) or `LA`..`LA+n-1` — C's calc-family
7031    /// argument fields, addressed by index range rather than by name.
7032    ///
7033    /// `calcRecord.c:161-167` / `calcoutRecord.c:417-423` test
7034    /// `idx >= indexof(A) && idx < indexof(A) + CALCPERFORM_NARGS`, and the dbd
7035    /// declares those `CALCPERFORM_NARGS` fields contiguously as the single
7036    /// letters `A`..`U` (`postfix.h:29` = 21, `calcRecord.dbd.pod:801-985`), so
7037    /// the index range and the letter range are the same set.
7038    fn calc_arg_field(field: &str, nargs: u8) -> bool {
7039        let last = b'A' + nargs - 1;
7040        match field.as_bytes() {
7041            [c] => c.is_ascii_uppercase() && *c <= last,
7042            [b'L', c] => c.is_ascii_uppercase() && *c <= last,
7043            _ => false,
7044        }
7045    }
7046
7047    /// The fields C's **`get_graphic_double`** answers with the record's own
7048    /// `HOPR`/`LOPR` — which is exactly what the VAL metadata cache already
7049    /// holds, so routing must leave them on it.
7050    ///
7051    /// The third membership question, and a third distinct set: the alarm arm
7052    /// lists VAL alone and the control arm lists the eight, but graphic lists
7053    /// the eight PLUS a per-type tail, and two types cut it short.
7054    ///
7055    /// * base analog (`aiRecord.c:244-265`, `aoRecord.c:316-339`,
7056    ///   `calcRecord.c:187-212`, `calcoutRecord.c:452-484`,
7057    ///   `subRecord.c:242-270`, `selRecord.c:181-201`,
7058    ///   `dfanoutRecord.c:181-195`, `longinRecord.c:190-204`,
7059    ///   `longoutRecord.c`, `int64inRecord.c:196-210`, `int64outRecord.c`):
7060    ///   the eight.
7061    /// * `acalcout`/`scalcout` (`aCalcoutRecord.c:1046`, `sCalcoutRecord.c:906`)
7062    ///   list only VAL/HIHI/HIGH/LOW/LOLO — NOT LALM/ALST/MLST — plus the
7063    ///   `A`..`L` and `PA`..`PL` ranges.
7064    /// * `sel` (`selRecord.c:193-196`) also lists `A`..`L` / `LA`..`LL`, via a
7065    ///   GCC case range. It has no link arm at all, so its args are HOPR/LOPR
7066    ///   where calc's identically-named ones are link-backed.
7067    /// * the SVAL family (`aiRecord.c:253`, `longinRecord.c`,
7068    ///   `int64inRecord.c:205`), `ao`'s `OVAL`/`PVAL`/`IVOV`
7069    ///   (`aoRecord.c:322-338`), and `compress`'s `IHIL`/`ILIL`
7070    ///   (`compressRecord.c:474-476`).
7071    ///
7072    /// Fields whose graphic case answers something OTHER than HOPR/LOPR are
7073    /// NOT here — they cannot keep the cache and are supplied by
7074    /// [`Record::field_metadata_override`] instead (`histogram` WDTH,
7075    /// `subArray`/`waveform`/`aai`/`aao` index fields, `seq` DLYn,
7076    /// `calcout` ODLY).
7077    fn graphic_explicit_field(rtype: &str, field: &str) -> bool {
7078        // The two types that do not list VAL. Neither switch is keyed on
7079        // VAL at all: `seqRecord.c:282-297` keys on `index - indexof(DLY0)`,
7080        // so every field BELOW DLY0 — VAL included — reaches
7081        // `recGblGetGraphicDouble`; `aSubRecord.c:350-368` keys on the link
7082        // number, and VAL is neither an inlink nor an outlink, so it falls out
7083        // having written nothing (the `graphic_default_arm` Seed).
7084        //
7085        // Measured: `SEQ.VAL` served display 0/0 — the empty VAL cache — where
7086        // C serves the DBF_LONG range.
7087        //
7088        // `tableRecord.c:778-792` and `mcaRecord.c:910-927` key on a named set
7089        // too — table's `AX`..`Z` window, mca's `DTIM`/`IDTIM` percent scale
7090        // and dead `BPTR` arm — and hand every other field, VAL included, to
7091        // `recGblGetGraphicDouble`. Both named sets answer literals through
7092        // [`Record::field_metadata_override`], so neither type keeps the cache.
7093        if matches!(rtype, "seq" | "aSub" | "table" | "mca") {
7094            return false;
7095        }
7096        if crate::server::database::is_value_field(field) {
7097            return true;
7098        }
7099        let f = field.to_ascii_uppercase();
7100        let bands: &[&str] = match rtype {
7101            "acalcout" | "scalcout" | "epid" => &["HIHI", "HIGH", "LOW", "LOLO"],
7102            // `swaitRecord.c:597-606` is a bare `pfield == &pwait->val` test,
7103            // so its `ALST`/`MLST` take `recGblGetGraphicDouble` — and swait
7104            // has no HIHI/HIGH/LOW/LOLO to ask about.
7105            "swait" => &[],
7106            _ => &["HIHI", "HIGH", "LOW", "LOLO", "LALM", "ALST", "MLST"],
7107        };
7108        if bands.contains(&f.as_str()) {
7109            return true;
7110        }
7111        match rtype {
7112            "ai" | "longin" | "int64in" => f == "SVAL",
7113            "ao" => matches!(f.as_str(), "OVAL" | "PVAL" | "IVOV"),
7114            "compress" => matches!(f.as_str(), "IHIL" | "ILIL"),
7115            // sel's args are 12 (`SEL_MAX`), not the calc family's 21.
7116            "sel" => Self::calc_arg_field(&f, 12),
7117            // A..L and PA..PL, both to HOPR/LOPR.
7118            "acalcout" | "scalcout" => {
7119                Self::calc_arg_field(&f, 12)
7120                    || matches!(f.as_bytes(), [b'P', c] if c.is_ascii_uppercase() && *c <= b'L')
7121            }
7122            // `epidRecord.c:238-248` names CVAL alongside VAL and the four
7123            // bands — the same list its `get_control_double` (`:263-273`) has
7124            // and this predicate did not.
7125            "epid" => f == "CVAL",
7126            _ => false,
7127        }
7128    }
7129
7130    /// The fields C's **`get_precision`** answers without reaching
7131    /// `recGblGetPrec` — the fourth membership question, and a fourth distinct
7132    /// set.
7133    ///
7134    /// Every `get_precision` in base and in the ported modules is the same
7135    /// two-part body: name some fields and answer them outright, hand the rest
7136    /// to `recGblGetPrec` (`recGbl.c:119-144`). For a field that can carry a
7137    /// precision at all — `dbAccess.c:388-389` gates `DBR_PRECISION` on
7138    /// `DBF_FLOAT`/`DBF_DOUBLE` — that shared tail does exactly one thing:
7139    /// clamp an out-of-range `PREC` to 15. So this predicate is what decides
7140    /// whether `caput REC.PREC 20` reaches a client as 20 or as 15, and the
7141    /// two answers differ per FIELD within one record: `ai.VAL` returns before
7142    /// the tail and serves 20, `ai.HOPR` falls into it and serves 15
7143    /// (`aiRecord.c:234-242`).
7144    ///
7145    /// The literal arms (`bo.HIGH`, `seq.DLYn`, `calcout.ODLY`,
7146    /// `histogram.SDEL`, `motor.VERS`, …) need no entry: they are
7147    /// [`Record::field_metadata_override`]s, which run after this and win, and
7148    /// every literal C uses is already inside `0..=15`.
7149    ///
7150    /// `link_supplied` is `dbGetPrecision`'s status on the backing link, and
7151    /// only `seq` reads it — see the `link_backed` arm.
7152    fn precision_explicit_field(
7153        rtype: &str,
7154        field: &str,
7155        link_backed: bool,
7156        link_supplied: bool,
7157    ) -> bool {
7158        match rtype {
7159            // No `recGblGetPrec` in the body at all: every field keeps PREC,
7160            // `ODLY` its literal 3 (`swaitRecord.c:583-595`).
7161            "swait" => return true,
7162            // `if (fieldIndex == VERS) 2; else if (fieldIndex >= VAL) prec;
7163            // else recGblGetPrec(...) /* Field is in dbCommon */`
7164            // (`transformRecord.c:752-767`, `scalerRecord.c:728-741`,
7165            // `tableRecord.c:814-828`). Only fields BELOW `VAL` — dbCommon —
7166            // reach the tail, and dbCommon declares no `DBF_FLOAT`/`DBF_DOUBLE`
7167            // field, so nothing that can be served ever gets there.
7168            "transform" | "scaler" | "table" => return true,
7169            // The same split inverted: `if (pfield < &pR->val) return 0;` then
7170            // `recGblGetPrec` (`sseqRecord.c:810-822`). Here it is the RECORD's
7171            // own fields — every `DLYn`, i.e. everything that can be served —
7172            // that reaches the tail, and the exempt half is the dbCommon one
7173            // that cannot.
7174            "sseq" => return false,
7175            // Falls through on every field, `DLY` included: it takes `DPREC`
7176            // instead of `PREC` and is clamped anyway
7177            // (`throttleRecord.c:451-464`).
7178            "throttle" => return false,
7179            _ => {}
7180        }
7181        if link_backed {
7182            // `if (linkNumber >= 0) { if (dbGetPrecision(...) == 0) *p = ...; }
7183            // else recGblGetPrec(...)` — the link arm returns whether or not
7184            // the link answered (`calcRecord.c:194-201`,
7185            // `calcoutRecord.c:461-468`, `subRecord.c:231-238`,
7186            // `aSubRecord.c:330-346`).
7187            //
7188            // `seq` is the exception, and it is why this takes a second
7189            // argument: its `case 2:` returns ONLY when `dbGetPrecision`
7190            // succeeded, and a `DOn` over a constant `DOLn` falls out of the
7191            // switch into the shared tail (`seqRecord.c:310-317`).
7192            return if rtype == "seq" { link_supplied } else { true };
7193        }
7194        if crate::server::database::is_value_field(field) {
7195            // `*precision = prec->prec; if (VAL) return 0;` — the common
7196            // shape (`aiRecord.c:238-239`, `aaiRecord.c:262-264`,
7197            // `aaoRecord.c:267-269`, `aoRecord.c:304-312`,
7198            // `calcRecord.c:190-192`, `calcoutRecord.c:457-459`,
7199            // `compressRecord.c:464-466`, `dfanoutRecord.c:169-171`,
7200            // `selRecord.c:152-155`, `subArrayRecord.c:221-223`,
7201            // `subRecord.c:227-229`, `waveformRecord.c:239-241`,
7202            // `sCalcoutRecord.c:616-618`, `aCalcoutRecord.c:756-758`,
7203            // `epidRecord.c:230-233`).
7204            //
7205            // The five that do NOT name VAL: `aSub` keys on the link number
7206            // only and VAL is neither an inlink nor an outlink
7207            // (`aSubRecord.c:330-346`); `mca` names `BPTR` and the four
7208            // calibration fields (`mcaRecord.c:898-905`); `seq` keys on
7209            // `index - indexof(DLY0)`, leaving VAL below the switch
7210            // (`seqRecord.c:305-317`); `histogram`'s switch has no VAL case
7211            // (`histogramRecord.c:423-436`); `motor` reaches the tail from
7212            // `default:` (`motorRecord.cc:3319-3335`).
7213            return !matches!(rtype, "aSub" | "mca" | "seq" | "histogram" | "motor");
7214        }
7215        let f = field.to_ascii_uppercase();
7216        match rtype {
7217            // `case VAL: case OVAL: case PVAL: break;` (`aoRecord.c:305-312`).
7218            "ao" => matches!(f.as_str(), "OVAL" | "PVAL"),
7219            // `if (fieldIndex == VAL || fieldIndex == CVAL) return 0;`
7220            // (`epidRecord.c:231-232`).
7221            "epid" => f == "CVAL",
7222            // The five cases that answer `prec->prec`
7223            // (`histogramRecord.c:424-430`). `SDEL` is the sixth case and a
7224            // literal, so the override covers it. Note that histogram's tail
7225            // gets an UNSEEDED `precision` — the record never assigns
7226            // `prec->prec` before the switch — so C answers `dbAccess.c:387`'s
7227            // zeroed buffer there, not a clamped PREC; `SDLY` is histogram's
7228            // only such field and carries its own `Some(0)` override.
7229            "histogram" => matches!(f.as_str(), "ULIM" | "LLIM" | "SGNL" | "SVAL" | "WDTH"),
7230            // `BPTR` returns, and the four calibration fields answer a literal
7231            // 6 (`mcaRecord.c:898-905`).
7232            "mca" => matches!(f.as_str(), "BPTR" | "CALO" | "CALS" | "CALQ" | "TTH"),
7233            // `case RRBV: case RMP: case REP: *precision = 0; break;` and
7234            // `case VERS: *precision = 2; break;` — both `break` past the
7235            // switch to the bare `return`, never to `recGblGetPrec`
7236            // (`motorRecord.cc:3322-3330`).
7237            "motor" => matches!(f.as_str(), "RRBV" | "RMP" | "REP" | "VERS"),
7238            // `sel` is deliberately absent: its `A`..`L` / `LA`..`LL` loop
7239            // compares `paddr->pfield` against `&pvalue` and `&plvalue` — the
7240            // addresses of the two LOCAL pointers, not the fields they walk
7241            // (`selRecord.c:159-160`) — so the test never matches and every
7242            // `sel` argument reaches `recGblGetPrec`. Transcribed as C
7243            // behaves, not as it reads.
7244            _ => false,
7245        }
7246    }
7247
7248    /// The field's type as the **dbd declares it**, which is the only type
7249    /// `recGblGetPrec` / `getMaxRangeValues` ever see.
7250    ///
7251    /// C reads `pdbFldDes->field_type` (`recGbl.c:127`, `:151`, `:169`) — the
7252    /// STATIC descriptor — so a `cvt_dbaddr` retype (the port's
7253    /// `runtime_typed`, DBF_NOACCESS in the dbd) never reaches the switch and
7254    /// the switch has no case for it. `None` reproduces that: no case, no
7255    /// write.
7256    fn static_field_type(&self, field: &str) -> Option<crate::types::DbFieldType> {
7257        let desc = self.field_desc(field)?;
7258        (!desc.runtime_typed).then_some(desc.dbf_type)
7259    }
7260
7261    /// `recGblGetGraphicDouble` / `recGblGetControlDouble` for `field` — the
7262    /// same `getMaxRangeValues` table both C entry points share
7263    /// (`recGbl.c:146-171`). `None` where C's switch has no case (STRING,
7264    /// MENU, DEVICE, NOACCESS, links), which writes nothing.
7265    ///
7266    /// `declared_dbf` is C's `pdbFldDes->field_type` verbatim. Deciding
7267    /// menu-ness from `desc.menu` instead asked whether the field carries its
7268    /// own inline choice list, which `SCAN` and `DTYP` do not — their choices
7269    /// come from the scan table and the device registry — so both reported a
7270    /// `DBF_USHORT` range of 65535/0 to `gft` where C reports 0/0.
7271    fn rec_gbl_range_for(&self, field: &str) -> Option<(f64, f64)> {
7272        let desc = self.field_desc(field)?;
7273        crate::server::recgbl::rec_gbl_get_graphic_double(desc.declared_dbf)
7274    }
7275
7276    /// Notify subscribers from a snapshot (call outside lock).
7277    /// Each entry carries its own posting mask: only subscribers whose
7278    /// mask intersects that field's mask are notified, and the delivered
7279    /// [`MonitorEvent`] reports that intersection — C
7280    /// `db_post_events(prec, &field, mask)` per-field granularity, then
7281    /// `pLog->mask = caEventMask & pevent->select` per subscriber.
7282    ///
7283    /// `backing` is the link metadata the process cycle resolved for this
7284    /// batch, at its own no-lock-held point. See [`Self::make_monitor_snapshot`]
7285    /// for why it has no default.
7286    pub fn notify_from_snapshot(&self, snapshot: &ProcessSnapshot, backing: LinkBacking<'_>) {
7287        use crate::server::database::filters::FilteredMonitorEvent;
7288
7289        // Nothing is subscribed to any field, so no post below can land; the
7290        // per-field lookup is a hash of the field name per post per cycle to
7291        // find that out. Same gate as `collect_subscriber_posts`.
7292        if self.subscribers.is_empty() {
7293            return;
7294        }
7295
7296        // Same ambient-origin inheritance as `notify_field_with_origin`:
7297        // a process cycle driven by an in-process writer's put tags its
7298        // posts with the writer's origin, so the writer's own filtered
7299        // subscriptions do not hear its cascade. 0 outside any scope.
7300        let origin = ambient_write_origin();
7301
7302        for (field, value, posting_mask) in snapshot.iter() {
7303            let posting_mask = *posting_mask;
7304            if let Some(subs) = self.subscribers.get(field.as_ref()) {
7305                // Build a full snapshot once per field (with display
7306                // metadata) and hand every subscriber a reference to that one
7307                // snapshot — C posts the fixed-size `db_field_log` and reads
7308                // the wide value by reference at delivery (`camessage.c:516`),
7309                // so a per-subscriber deep copy of an array value is a port
7310                // deviation, not parity.
7311                let Some(snap) = self.make_monitor_snapshot(field, value.clone(), backing) else {
7312                    continue;
7313                };
7314                let mon_snap = Arc::new(snap);
7315                for sub in subs {
7316                    // Paused subscriber (`db_event_disable`): suppress at
7317                    // the source — no delivery, no coalesce.
7318                    if !sub.active {
7319                        continue;
7320                    }
7321                    // Gate and narrow in one step through
7322                    // `Subscriber::delivered_mask`, which owns C's
7323                    // twice-used `caEventMask & pevent->select`. An empty
7324                    // posting mask means nothing changed and ands to zero,
7325                    // so it skips there rather than needing a check here.
7326                    if let Some(mask) = sub.delivered_mask(posting_mask) {
7327                        let event = MonitorEvent {
7328                            snapshot: mon_snap.clone(),
7329                            origin,
7330                            mask,
7331                        };
7332                        // Server-side filter chain (3.15.7). Empty chain
7333                        // is identity, so no behaviour change for the
7334                        // common no-filter case.
7335                        let filtered = if sub.filters.is_empty() {
7336                            Some(event)
7337                        } else {
7338                            sub.filters
7339                                .apply(FilteredMonitorEvent::new(event))
7340                                .map(|fe| fe.event)
7341                        };
7342                        let Some(event) = filtered else {
7343                            continue;
7344                        };
7345                        // C `db_queue_event_log`: append, or replace this
7346                        // monitor's last queued entry in place when the queue
7347                        // is in flow control or nearly full. The queue owns
7348                        // that decision and counts the displaced value.
7349                        sub.post(event);
7350                    }
7351                }
7352            }
7353        }
7354    }
7355
7356    /// Notify subscribers of a specific field, filtering by event mask.
7357    ///
7358    /// The last wrapper that still answers for its callers: `none()` here is a
7359    /// claim that no caller of this function names a link-backed field, and it
7360    /// is made once for 25 production call sites rather than at each of them.
7361    /// [`Self::notify_field_backed`] is the form for a caller that cannot make
7362    /// that claim.
7363    pub fn notify_field(&mut self, field: &str, mask: crate::server::recgbl::EventMask) {
7364        self.notify_field_with_origin(field, mask, 0, LinkBacking::none());
7365    }
7366
7367    /// [`Self::notify_field`] for a poster that may name a link-backed field
7368    /// and has resolved its backing.
7369    pub fn notify_field_backed(
7370        &mut self,
7371        field: &str,
7372        mask: crate::server::recgbl::EventMask,
7373        backing: LinkBacking<'_>,
7374    ) {
7375        self.notify_field_with_origin(field, mask, 0, backing);
7376    }
7377
7378    /// C `db_post_events(precord, NULL, DBE_ALARM)`: post a record-wide
7379    /// alarm event. Delivers to every subscriber on any field whose mask
7380    /// includes DBE_ALARM, each carrying its own monitored field's current
7381    /// value (the per-field `notify_field` already filters by mask
7382    /// intersection). Used by the alarm-acknowledge (ACKT/ACKS) put path so
7383    /// an alarm-mask monitor on any field observes the acknowledgement.
7384    pub fn notify_record_alarm(&mut self, backing: LinkBacking<'_>) {
7385        // Every subscribed field, so a client monitoring a link-backed one
7386        // (`CALC.A`) is in the set — this poster takes a backing for that
7387        // reason and not because the alarm itself is link-backed.
7388        let fields: Vec<String> = self.subscribers.keys().cloned().collect();
7389        for field in fields {
7390            self.notify_field_backed(&field, crate::server::recgbl::EventMask::ALARM, backing);
7391        }
7392    }
7393
7394    /// Notify subscribers with an origin tag for self-write filtering.
7395    ///
7396    /// This is C `db_post_events(precord, pfield, mask)` for one field, and —
7397    /// per the `last_posted` contract — the poster that advances the
7398    /// already-published value when `mask` carries a value class. Taking
7399    /// `&mut self` is what makes that unbypassable: there is no way to publish
7400    /// a field's value through the framework without the change detector
7401    /// learning that it was published.
7402    ///
7403    /// `backing` is the link metadata the put path resolved for this post, at
7404    /// its own no-lock-held point. See [`Self::make_monitor_snapshot`] for why
7405    /// it has no default.
7406    pub fn notify_field_with_origin(
7407        &mut self,
7408        field: &str,
7409        mask: crate::server::recgbl::EventMask,
7410        origin: u64,
7411        backing: LinkBacking<'_>,
7412    ) {
7413        use crate::server::database::filters::FilteredMonitorEvent;
7414        // A poster that carries no origin of its own inherits the ambient
7415        // one (0 outside any scope): this is how every post inside an
7416        // SNL writer's synchronous put+process cascade gets the writer's
7417        // tag without threading a parameter through the whole processing
7418        // machinery. An explicit origin always wins.
7419        let origin = if origin != 0 {
7420            origin
7421        } else {
7422            ambient_write_origin()
7423        };
7424        // A value-class post publishes the field to its DBE_VALUE/DBE_LOG
7425        // subscribers, exactly as C's `dbPut` does for the put field
7426        // (dbAccess.c:1414) — record it so the next process cycle's
7427        // change-detection loop does not publish the same value a second
7428        // time. An alarm-only / property-only post publishes no value, so it
7429        // leaves the map alone.
7430        let publishes_value = mask.intersects(
7431            crate::server::recgbl::EventMask::VALUE | crate::server::recgbl::EventMask::LOG,
7432        );
7433        let subs = self.subscribers.get(field).filter(|subs| !subs.is_empty());
7434        if subs.is_none() && !publishes_value {
7435            return;
7436        }
7437        let Some(value) = self.resolve_field(field) else {
7438            return;
7439        };
7440        // With no subscriber the post is value-class (the return above), and
7441        // the value goes straight to the owner.
7442        let Some(subs) = subs else {
7443            self.record_value_post(field, value);
7444            return;
7445        };
7446        let posted = publishes_value.then(|| value.clone());
7447        // A refused post publishes nothing, so it must not advance
7448        // `last_posted` either, or the next cycle's change detector would
7449        // treat the value as already delivered and the subscriber would never
7450        // see it: `posted` dies with this return.
7451        let Some(snap) = self.make_monitor_snapshot(field, value, backing) else {
7452            return;
7453        };
7454        {
7455            let mon_snap = Arc::new(snap);
7456            for sub in subs {
7457                // Paused subscriber (`db_event_disable`): suppress at
7458                // the source — no delivery, no coalesce.
7459                if !sub.active {
7460                    continue;
7461                }
7462                // Same single owner as the snapshot path: gate and
7463                // narrow are one operation (C `dbEvent.c:896-900`).
7464                if let Some(mask) = sub.delivered_mask(mask) {
7465                    let event = MonitorEvent {
7466                        snapshot: mon_snap.clone(),
7467                        origin,
7468                        mask,
7469                    };
7470                    // Server-side filter chain (3.15.7). Empty
7471                    // chain (the default for every subscriber
7472                    // until a `.{filter:opts}` PV-name suffix
7473                    // parser wires one in) is the identity, so
7474                    // existing subscribers see no behaviour
7475                    // change. A filter returning `None` silences
7476                    // this event for this subscriber only.
7477                    let filtered = if sub.filters.is_empty() {
7478                        Some(event)
7479                    } else {
7480                        sub.filters
7481                            .apply(FilteredMonitorEvent::new(event))
7482                            .map(|fe| fe.event)
7483                    };
7484                    let Some(event) = filtered else {
7485                        continue;
7486                    };
7487                    // Same single post owner as the snapshot path.
7488                    sub.post(event);
7489                }
7490            }
7491        }
7492        // The value is now published: hand it to the `last_posted` owner so
7493        // the change detector does not publish it again. Delivery to any
7494        // individual subscriber may have been filtered out, and the field may
7495        // have no subscriber at all, exactly as C's `db_post_events` may find
7496        // an empty `mlis` — C still leaves `monitor()`'s `*_lst` state
7497        // advanced by the cycle that ran, so the post, not the delivery, is
7498        // what counts.
7499        if let Some(value) = posted {
7500            self.record_value_post(field, value);
7501        }
7502    }
7503
7504    /// Add a subscriber for a specific field. Returns `None` when the
7505    /// per-field subscriber cap (`EPICS_CAS_MAX_SUBSCRIBERS_PER_PV`)
7506    /// is reached. the parallel cap on `ProcessVariable`
7507    /// defends against a misbehaving client opening many
7508    /// MONITOR ops against one shared PV; the same defence is needed
7509    /// for record fields, which the CA server's
7510    /// `ChannelTarget::RecordField` path lands on.
7511    pub fn add_subscriber(
7512        &mut self,
7513        field: &str,
7514        sid: u32,
7515        data_type: DbFieldType,
7516        mask: u16,
7517    ) -> Option<EventReader> {
7518        self.add_subscriber_on(&EventUser::new(), field, sid, data_type, mask)
7519    }
7520
7521    /// Add a field subscriber whose events queue on `user`'s event queue —
7522    /// C `db_add_event` with the circuit's `event_user` as context. Every
7523    /// subscription on one CA circuit shares that queue and therefore its
7524    /// `nDuplicates`, so a duplicate queued for one of them releases the
7525    /// EVENTS_OFF drain for all of them (`dbEvent.c:947`). In-process consumers
7526    /// use [`Self::add_subscriber`], which gives each its own `event_user`.
7527    pub fn add_subscriber_on(
7528        &mut self,
7529        user: &EventUser,
7530        field: &str,
7531        sid: u32,
7532        data_type: DbFieldType,
7533        mask: u16,
7534    ) -> Option<EventReader> {
7535        let cap = crate::server::pv::max_subscribers_per_pv();
7536        // A destroyed record takes no new monitor, so `destroyed => no
7537        // subscribers` survives a CREATE_CHAN + EVENT_ADD that races the
7538        // removal. Both are `&mut self`, so there is no window between them.
7539        if self.destroyed {
7540            return None;
7541        }
7542        let field_str = field.to_string();
7543        let bucket = self.subscribers.entry(field_str.clone()).or_default();
7544        // Reap rows whose consumer is gone before
7545        // counting against the cap. A record field whose value
7546        // never changes (e.g. a quasi-static catalog field) never
7547        // triggers `notify_field_with_origin`'s retain-filter, so
7548        // a long-lived subscribe-disconnect storm could pin the
7549        // bucket at `cap` worth of dead rows and lock out
7550        // genuine new subscribers.
7551        bucket.retain(|s| !s.is_closed());
7552        if bucket.len() >= cap {
7553            tracing::warn!(
7554                record = %self.name,
7555                field = %field_str,
7556                live = bucket.len(),
7557                cap,
7558                "record field subscriber cap reached, refusing add_subscriber"
7559            );
7560            return None;
7561        }
7562        let (sink, reader) = crate::server::event_queue::attach(user, sid);
7563        bucket.push(Subscriber {
7564            sid,
7565            data_type,
7566            mask,
7567            sink,
7568            filters: crate::server::database::filters::FilterChain::new(),
7569            active: true,
7570        });
7571        // A field with no `last_posted` entry has never been published: seed it
7572        // with the value the client is handed in the EVENT_ADD response, so the
7573        // first process cycle does not treat it as changed. An existing entry
7574        // is left alone — every post advances it whether or not anyone was
7575        // subscribed, so it is what was last published, and a change since
7576        // then is still owed to the field's other subscribers.
7577        if !self.last_posted.contains_key(&field_str) {
7578            if let Some(val) = self.resolve_field(&field_str) {
7579                self.last_posted.insert(field_str, val);
7580            }
7581        }
7582        Some(reader)
7583    }
7584
7585    /// Attach a filter to the most recently added subscriber for
7586    /// `field`. Returns `false` when no subscriber exists yet on that
7587    /// field (call `add_subscriber` first). The CA / PVA channel-name
7588    /// parsers will use this once `.{filter:opts}` syntax is wired.
7589    /// Tests can also use it directly to compose filter chains.
7590    pub fn attach_filter_to_last_subscriber(
7591        &mut self,
7592        field: &str,
7593        filter: std::sync::Arc<dyn crate::server::database::filters::SubscriptionFilter>,
7594    ) -> bool {
7595        if let Some(bucket) = self.subscribers.get_mut(field) {
7596            if let Some(sub) = bucket.last_mut() {
7597                sub.filters.push(filter);
7598                return true;
7599            }
7600        }
7601        false
7602    }
7603
7604    /// Remove a subscriber by subscription ID from all fields.
7605    pub fn remove_subscriber(&mut self, sid: u32) {
7606        for subs in self.subscribers.values_mut() {
7607            subs.retain(|s| s.sid != sid);
7608        }
7609    }
7610
7611    /// Destroy this record: drop every field monitor and refuse every future
7612    /// one, and drop every link target handle it holds. The record-backed
7613    /// half of the rule [`crate::server::pv::ProcessVariable::destroy`]
7614    /// states for simple PVs, so one sweep in a server closes both kinds of
7615    /// channel; and the one place a record lets go of the records its links
7616    /// resolved to, so a destroyed record keeps no other alive. Returns
7617    /// `true` for the call that performed the transition.
7618    pub(crate) fn destroy(&mut self) -> bool {
7619        let first = !self.destroyed;
7620        self.destroyed = true;
7621        self.subscribers.clear();
7622        for entry in self.parsed_inputs.iter_mut().flatten() {
7623            entry.release_target();
7624        }
7625        first
7626    }
7627
7628    /// Drop every link target handle that names `cell` — the other half of
7629    /// the rule [`Self::destroy`] keeps: a handle never outlives its
7630    /// target's map entry. The remover calls it on every record left in the
7631    /// map once the entry is gone, so a record that never processes again
7632    /// does not keep the removed one alive.
7633    pub(crate) fn release_link_targets_to(&mut self, cell: &Arc<RecordCell>) {
7634        for entry in self.parsed_inputs.iter_mut().flatten() {
7635            if entry.targets(cell) {
7636                entry.release_target();
7637            }
7638        }
7639    }
7640
7641    /// Whether `Self::destroy` has run.
7642    pub fn is_destroyed(&self) -> bool {
7643        self.destroyed
7644    }
7645
7646    /// Pause / resume one subscriber's event flow at the source
7647    /// (`db_event_disable` / `db_event_enable`). `active == false`
7648    /// suppresses every subsequent post to this subscriber, so the record stops
7649    /// doing per-event work for it. Entries already queued stay queued and are
7650    /// still delivered, exactly as in C: `db_event_disable` only unlinks the
7651    /// subscription from the record's monitor list (`dbEvent.c:524-535`) and
7652    /// never reaches into the event queue. No-op if no subscriber has this
7653    /// `sid`. The caller holds the record write lock, so this is exclusive with
7654    /// the read-locked post paths that consult `Subscriber::active`.
7655    pub fn set_subscriber_active(&mut self, sid: u32, active: bool) {
7656        for subs in self.subscribers.values_mut() {
7657            for sub in subs.iter_mut() {
7658                if sub.sid == sid {
7659                    sub.active = active;
7660                }
7661            }
7662        }
7663    }
7664
7665    /// Clean up subscriber rows whose consumer is gone.
7666    pub fn cleanup_subscribers(&mut self) {
7667        for subs in self.subscribers.values_mut() {
7668            subs.retain(|s| !s.is_closed());
7669        }
7670    }
7671}
7672
7673/// C `recGblCheckDeadband` (recGbl.c:345-370), spelled as C spells it:
7674///
7675/// ```c
7676/// double delta = 0;
7677/// if (finite(newval) && finite(*poldval)) {
7678///     delta = *poldval - newval;
7679///     if (delta < 0.0) delta = -delta;
7680/// }
7681/// else if (!isnan(newval) != !isnan(*poldval) ||
7682///          !isinf(newval) != !isinf(*poldval)) delta = epicsINF;
7683/// else if (isinf(newval) && newval != *poldval) delta = epicsINF;
7684/// if (delta > deadband) { *monitor_mask |= add_mask; *poldval = newval; }
7685/// ```
7686///
7687/// The `delta = 0` initialiser is load-bearing: the pairs no branch matches
7688/// — both NaN, or two same-signed infinities — reach `0 > deadband`, so they
7689/// fire only for a negative deadband and otherwise leave `*poldval` alone.
7690/// That is why the whole rule has to stay a single `delta > deadband` rather
7691/// than a chain of early returns, and why NaN cannot be read as a marker
7692/// here: C compares a NaN `*poldval` against a NaN `newval` by these rules
7693/// and finds them unchanged. `dbnd.c parse_ok` relies on exactly that when it
7694/// seeds its own `last` to `epicsNAN`.
7695///
7696/// `oldval` is `None` for the record types that carry no MLST/ALST cell to
7697/// hold a last-posted value; nothing was posted, so the first post is
7698/// unconditional. C has no such state — its MLST is a plain double the record
7699/// type initialises (0 for `calc` and `sel`, `prec->val` for `ai`,
7700/// aiRecord.c:129-130).
7701pub(crate) fn check_deadband(newval: f64, oldval: Option<f64>, deadband: f64) -> bool {
7702    let Some(oldval) = oldval else {
7703        return true;
7704    };
7705    let delta = if newval.is_finite() && oldval.is_finite() {
7706        (oldval - newval).abs()
7707    } else if newval.is_nan() != oldval.is_nan() || newval.is_infinite() != oldval.is_infinite() {
7708        // One is NaN or +/-inf and the other is not.
7709        f64::INFINITY
7710    } else if newval.is_infinite() && newval != oldval {
7711        // One is +inf, the other -inf.
7712        f64::INFINITY
7713    } else {
7714        0.0
7715    };
7716    delta > deadband
7717}
7718
7719#[cfg(test)]
7720mod device_menu_marking_tests {
7721    use super::*;
7722    use crate::server::records::ai::AiRecord;
7723    use crate::server::records::calc::CalcRecord;
7724    use crate::server::records::mbbo::MbboRecord;
7725
7726    /// C `dbAccess.c:176-179`: a `DBF_DEVICE` field whose record type declares
7727    /// no device support has `pfldDes->ftPvt == NULL` and takes `goto nostrs`,
7728    /// which clears `DBR_ENUM_STRS` — the client is sent NO choice list.
7729    ///
7730    /// `calc` declares no `device()` line, so QSRV2 omits `value.choices` on
7731    /// `CALC.DTYP`. The port used to default the missing menu to `[]` and mark
7732    /// an empty list instead.
7733    #[test]
7734    fn dtyp_of_a_record_type_with_no_device_support_supplies_no_choices() {
7735        let inst = RecordInstance::new("X".into(), CalcRecord::default());
7736        assert!(
7737            super::super::dbd_generated::device_menu("calc").is_none(),
7738            "precondition: calc declares no device() line (C ftPvt == NULL)"
7739        );
7740        assert!(
7741            inst.device_choices().is_none(),
7742            "a record type with no device menu must report None, not an empty list"
7743        );
7744        assert!(
7745            inst.enum_string_form_for("DTYP").is_none(),
7746            "DTYP must supply no enum-string form, so no `value.choices` is marked"
7747        );
7748    }
7749
7750    /// The other side of C's `dbAccess.c:205` comment — *"indicate option data
7751    /// not available. distinct from no_str==0"*. `ai` DOES declare device
7752    /// support, so its menu exists and its choices are served.
7753    #[test]
7754    fn dtyp_of_a_record_type_with_device_support_supplies_its_choices() {
7755        let inst = RecordInstance::new("X".into(), AiRecord::default());
7756        let choices = inst
7757            .device_choices()
7758            .expect("ai declares device() lines, so its menu exists");
7759        assert!(
7760            choices.iter().any(|c| c.as_str_lossy() == "Soft Channel"),
7761            "ai's device menu must carry its declared choices, got {choices:?}"
7762        );
7763        assert!(inst.enum_string_form_for("DTYP").is_some());
7764    }
7765
7766    /// An unset `DTYP` is index 0 on both sides of the distinction — a record
7767    /// type with no device menu has no slot for any DTYP, so the index stays 0
7768    /// rather than panicking or shifting.
7769    #[test]
7770    fn dtyp_index_is_zero_when_the_record_type_has_no_device_menu() {
7771        let inst = RecordInstance::new("X".into(), CalcRecord::default());
7772        assert_eq!(inst.dtyp_index(), 0);
7773    }
7774
7775    /// A downstream crate's registered device menu (asyn's) is merged AFTER the
7776    /// base-declared choices, matching a C fat softIoc that loaded `asyn.dbd`:
7777    /// `mbbo.DTYP` = the three base soft entries then `asynInt32`,
7778    /// `asynUInt32Digital`, in that order. `dtyp_index` reads the merged list,
7779    /// so an `mbbo` bound to `asynInt32` reports index 3 — the wire value C
7780    /// serves — instead of the appended-as-own-slot index the port gave before
7781    /// the menu was known.
7782    #[test]
7783    fn a_registered_device_menu_merges_after_the_base_declared_choices() {
7784        // The list asyn's generated `dbd_generated::DEVICE_MENU_MBBO` carries.
7785        static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
7786        super::super::register_device_menu("mbbo", ASYN_MBBO);
7787
7788        let mut inst = RecordInstance::new("X".into(), MbboRecord::default());
7789        let merged: Vec<String> = inst
7790            .device_choices()
7791            .expect("mbbo declares device() lines")
7792            .iter()
7793            .map(|c| c.as_str_lossy().into_owned())
7794            .collect();
7795        assert_eq!(
7796            merged,
7797            vec![
7798                "Soft Channel",
7799                "Raw Soft Channel",
7800                "Async Soft Channel",
7801                "asynInt32",
7802                "asynUInt32Digital",
7803            ],
7804            "base-declared choices first, asyn-contributed appended in asyn.dbd order"
7805        );
7806
7807        inst.common.dtyp = "asynInt32".into();
7808        assert_eq!(
7809            inst.dtyp_index(),
7810            3,
7811            "an asyn DTYP indexes into the merged menu, not an appended own slot"
7812        );
7813    }
7814
7815    /// The None-vs-empty contract survives the merge: a record type neither
7816    /// base nor any downstream crate contributes a `device()` for (calc) stays
7817    /// `None`, never `Some([])`, even after asyn menus are registered in this
7818    /// process.
7819    #[test]
7820    fn calc_stays_none_after_asyn_menus_are_registered() {
7821        static ASYN_MBBO: &[&str] = &["asynInt32", "asynUInt32Digital"];
7822        super::super::register_device_menu("mbbo", ASYN_MBBO);
7823
7824        let inst = RecordInstance::new("X".into(), CalcRecord::default());
7825        assert!(
7826            inst.device_choices().is_none(),
7827            "calc declares no device() and gets no contribution — still None"
7828        );
7829    }
7830}
7831
7832#[cfg(test)]
7833mod property_support_owner_tests {
7834    use crate::server::record::record_trait::default_property_support;
7835    use crate::server::snapshot::PropertySupport as P;
7836
7837    /// `sseqRecord.c:124-144` — the rset table NULLs every property slot
7838    /// except `get_precision`:
7839    ///
7840    /// ```c
7841    /// NULL,           /* get_units */
7842    /// get_precision,  /* get_precision */
7843    /// NULL,           /* get_enum_str */
7844    /// NULL,           /* get_enum_strs */
7845    /// NULL,           /* put_enum_str */
7846    /// NULL,           /* get_graphic_double */
7847    /// NULL,           /* get_control_double */
7848    /// NULL            /* get_alarm_double */
7849    /// ```
7850    ///
7851    /// `sseq` was previously grouped with the full-numeric synApps types, so
7852    /// the port marked six leaves per field that QSRV2 omits entirely.
7853    #[test]
7854    fn sseq_supplies_only_precision() {
7855        assert_eq!(
7856            default_property_support("sseq"),
7857            P {
7858                precision: true,
7859                ..P::NONE
7860            }
7861        );
7862    }
7863
7864    /// A record type the table does not name keeps the permissive
7865    /// `NUMERIC` default rather than silently losing metadata. This is the
7866    /// arm `asyn` used to land on — and why marking had to become a trait
7867    /// method: asyn-rs cannot add a row here.
7868    #[test]
7869    fn an_untranscribed_record_type_keeps_the_permissive_default() {
7870        assert_eq!(default_property_support("no-such-record-type"), P::NUMERIC);
7871    }
7872}
7873
7874#[cfg(test)]
7875mod metadata_cache_tests {
7876    use super::*;
7877    use crate::server::records::ai::AiRecord;
7878
7879    /// Helper: build an AiRecord wrapped in a RecordInstance with EGU/PREC/HOPR/LOPR set.
7880    fn ai_instance() -> RecordInstance {
7881        let mut rec = AiRecord::default();
7882        let _ = rec.put_field("EGU", EpicsValue::String("degC".into()));
7883        let _ = rec.put_field("PREC", EpicsValue::Short(2));
7884        let _ = rec.put_field("HOPR", EpicsValue::Double(100.0));
7885        let _ = rec.put_field("LOPR", EpicsValue::Double(0.0));
7886        let _ = rec.put_field("VAL", EpicsValue::Double(25.0));
7887        RecordInstance::new("TEMP".to_string(), rec)
7888    }
7889
7890    /// a record-field monitor whose event queue has run short of room
7891    /// replaces its last queued entry in place (C `db_queue_event_log`,
7892    /// `dbEvent.c:812-827`), and the displaced value — which the consumer never
7893    /// observed — must be counted in the shared `dropped_monitor_events()`
7894    /// counter (C `nreplace`), the same accounting a `ProcessVariable` post
7895    /// uses. Before the fix the record-field path overwrote its coalesce slot
7896    /// without counting, hiding slow-consumer loss on the path most CA/PVA
7897    /// database monitors use. The counter is process-global, so the assertion is
7898    /// a strict monotonic increase (robust under parallel tests); the
7899    /// revert-verify runs this test in isolation.
7900    #[test]
7901    fn bfr10_record_field_overflow_counts_dropped_event() {
7902        use crate::server::event_queue::{event_que_size, events_per_que};
7903        use crate::server::pv::dropped_monitor_events;
7904        use crate::server::recgbl::EventMask;
7905        let mut inst = ai_instance();
7906        // Keep the reader alive and do NOT drain, so the ring fills to the
7907        // replace threshold and later posts displace the tail entry.
7908        let _reader = inst
7909            .add_subscriber(
7910                "VAL",
7911                1,
7912                crate::types::DbFieldType::Double,
7913                EventMask::VALUE.bits(),
7914            )
7915            .expect("subscriber added");
7916        let before = dropped_monitor_events();
7917        let posts = event_que_size() - events_per_que() + 10;
7918        for _ in 0..posts {
7919            inst.notify_field_with_origin("VAL", EventMask::VALUE, 0, LinkBacking::none());
7920        }
7921        let after = dropped_monitor_events();
7922        assert!(
7923            after > before,
7924            "a post that replaces an unobserved queued entry must record a \
7925             dropped monitor event (before={before}, after={after})"
7926        );
7927    }
7928
7929    #[test]
7930    fn metadata_cache_source_set_check() {
7931        // Every field `populate_display_info` / `populate_control_info` /
7932        // `populate_enum_info` reads.
7933        assert!(is_metadata_cache_source("EGU"));
7934        assert!(is_metadata_cache_source("PREC"));
7935        assert!(is_metadata_cache_source("HOPR"));
7936        assert!(is_metadata_cache_source("LOPR"));
7937        assert!(is_metadata_cache_source("DRVH"));
7938        assert!(is_metadata_cache_source("ZNAM"));
7939        assert!(is_metadata_cache_source("ZRST"));
7940        assert!(is_metadata_cache_source("FFST"));
7941
7942        // Every cell `explicit_alarm_limits` reads, now that the cache holds
7943        // its answer: the four bands and the four severities that gate them.
7944        assert!(is_metadata_cache_source("HIHI"));
7945        assert!(is_metadata_cache_source("HIGH"));
7946        assert!(is_metadata_cache_source("LOW"));
7947        assert!(is_metadata_cache_source("LOLO"));
7948        assert!(is_metadata_cache_source("HHSV"));
7949        assert!(is_metadata_cache_source("HSV"));
7950        assert!(is_metadata_cache_source("LSV"));
7951        assert!(is_metadata_cache_source("LLSV"));
7952
7953        assert!(!is_metadata_cache_source("VAL"));
7954        assert!(!is_metadata_cache_source("DESC"));
7955        assert!(!is_metadata_cache_source("SCAN"));
7956        assert!(!is_metadata_cache_source("PHAS"));
7957    }
7958
7959    #[test]
7960    fn cache_starts_empty_then_populates_on_first_snapshot() {
7961        let inst = ai_instance();
7962
7963        // Cache starts empty
7964        assert!(inst.metadata_cache.lock().unwrap().is_none());
7965
7966        // First snapshot triggers populate + cache store
7967        let snap = inst.snapshot_for_field("VAL").unwrap();
7968        let display = snap.display.expect("ai snapshot must have display");
7969        assert_eq!(display.units, "degC");
7970        assert_eq!(display.precision, 2);
7971        assert_eq!(display.upper_disp_limit, 100.0);
7972        assert_eq!(display.lower_disp_limit, 0.0);
7973
7974        // Cache is now populated
7975        assert!(inst.metadata_cache.lock().unwrap().is_some());
7976    }
7977
7978    #[test]
7979    fn q_form_info_tag_sets_display_form_index() {
7980        // pvxs maps the `Q:form` info tag to `display.form.index` for the
7981        // VAL field (iocsource.cpp:42-62). "Hex" is slot 4 of the
7982        // seven-entry menu (Default/String/Binary/Decimal/Hex/...).
7983        let mut inst = ai_instance();
7984        inst.set_info("Q:form", "Hex");
7985        let snap = inst.snapshot_for_field("VAL").unwrap();
7986        let display = snap.display.expect("ai snapshot must have display");
7987        assert_eq!(display.form, 4, "Q:form=Hex -> display.form index 4");
7988    }
7989
7990    /// R16-31: `Q:form` is a record-level info tag, but QSRV assigns
7991    /// `display.form.index` only when the channel addresses the VAL field
7992    /// (`if(dbIsValueField(dbChannelFldDes(chan)))`, `iocsource.cpp:53`). A
7993    /// snapshot of any other field of the same record reports the default
7994    /// form, on both the GET and the monitor producer.
7995    #[test]
7996    fn q_form_applies_to_the_val_field_only() {
7997        let mut inst = ai_instance();
7998        inst.set_info("Q:form", "Hex");
7999
8000        let val = inst.snapshot_for_field("VAL").unwrap();
8001        assert_eq!(val.display.expect("ai display").form, 4);
8002
8003        for non_val in ["RVAL", "SEVR", "HOPR"] {
8004            let Some(snap) = inst.snapshot_for_field(non_val) else {
8005                panic!("ai.{non_val} must resolve");
8006            };
8007            assert_eq!(
8008                snap.display.expect("ai display").form,
8009                0,
8010                "Q:form must not reach ai.{non_val} — pvxs applies it to VAL only"
8011            );
8012        }
8013
8014        // The monitor producer shares the same per-field owner.
8015        let update = inst
8016            .make_monitor_snapshot("RVAL", EpicsValue::Long(7), LinkBacking::none())
8017            .expect("nothing was declined: the backing is unresolved, not declined");
8018        assert_eq!(
8019            update.display.expect("ai display").form,
8020            0,
8021            "a monitor update on a non-VAL field carries the default form too"
8022        );
8023        let update = inst
8024            .make_monitor_snapshot("VAL", EpicsValue::Double(1.0), LinkBacking::none())
8025            .expect("nothing was declined: the backing is unresolved, not declined");
8026        assert_eq!(update.display.expect("ai display").form, 4);
8027    }
8028
8029    #[test]
8030    fn q_form_absent_or_unknown_leaves_form_default() {
8031        // No `Q:form` tag -> form stays 0 (Default).
8032        let inst = ai_instance();
8033        let snap = inst.snapshot_for_field("VAL").unwrap();
8034        assert_eq!(snap.display.expect("ai display").form, 0);
8035
8036        // Unrecognised tag -> pvxs leaves the index untouched (0).
8037        let mut inst2 = ai_instance();
8038        inst2.set_info("Q:form", "Nonsense");
8039        let snap2 = inst2.snapshot_for_field("VAL").unwrap();
8040        assert_eq!(snap2.display.expect("ai display").form, 0);
8041    }
8042
8043    /// `info(Q:time:tag)` resolves to pvxs's `nsecMask`
8044    /// (`ioc/typeutils.cpp:79-88`). The prefix test there is a byte-exact
8045    /// `strncmp("nsec:lsb:", 9)` and the digit count is fed straight to
8046    /// `(uint64_t(1u)<<dig)-1u` — no case folding, no whitespace tolerance
8047    /// around the prefix, and no bounds clamp. Each boundary gets a case.
8048    #[test]
8049    fn qtime_nsec_mask_matches_pvxs_updatensecmask() {
8050        let cases: &[(&str, u64)] = &[
8051            // parses: `epicsParseInt32` skips whitespace around the digits
8052            // and accepts a sign.
8053            ("nsec:lsb:20", (1 << 20) - 1),
8054            ("nsec:lsb:1", 1),
8055            ("nsec:lsb: 4 ", 0xF),
8056            ("nsec:lsb:+4", 0xF),
8057            // no clamp: 31 is the mask pvxs actually serves (the old Rust
8058            // `(1..=30)` guard dropped it), and 0 is pvxs's "off" mask.
8059            ("nsec:lsb:31", 0x7FFF_FFFF),
8060            ("nsec:lsb:0", 0),
8061            // `strncmp` is byte-exact: case-folded or whitespace-split
8062            // prefixes do not match, so pvxs leaves `nsecMask` at 0.
8063            ("NSEC:LSB:4", 0),
8064            ("Nsec:Lsb:4", 0),
8065            ("nsec: lsb: 4", 0),
8066            (" nsec:lsb:4", 0),
8067            // `epicsParseInt32` failures: no conversion, extraneous trailing
8068            // bytes, overflow past epicsInt32.
8069            ("nsec:lsb:", 0),
8070            ("nsec:lsb:abc", 0),
8071            ("nsec:lsb:4x", 0),
8072            ("nsec:lsb:4 5", 0),
8073            ("nsec:lsb:99999999999999999999", 0),
8074            ("nsec:lsb:2147483648", 0),
8075        ];
8076        for (tag, want) in cases {
8077            let mut inst = ai_instance();
8078            inst.set_info("Q:time:tag", *tag);
8079            assert_eq!(
8080                inst.qtime_nsec_mask(),
8081                *want,
8082                "info(Q:time:tag, {tag:?}) must resolve to nsecMask {want:#x}"
8083            );
8084        }
8085        // Tag absent entirely → pvxs never enters the `if(auto val = ...)`
8086        // body and `nsecMask` stays 0.
8087        assert_eq!(ai_instance().qtime_nsec_mask(), 0);
8088    }
8089
8090    /// End-to-end on the snapshot: `nsec:lsb:31` publishes
8091    /// `nanoseconds & ~mask` (0, since nanoseconds < 1e9 < 2^31) and
8092    /// `userTag = nanoseconds & mask` (pvxs `iocsource.cpp:239-248`). The
8093    /// old `(1..=30)` clamp served the raw nanoseconds and the record's
8094    /// utag instead.
8095    #[test]
8096    fn qtime_nsec_lsb_31_is_served_not_ignored() {
8097        use std::time::{Duration, SystemTime};
8098        let mut inst = ai_instance();
8099        // 123_456_700, not …789: Windows `SystemTime` is a FILETIME with 100 ns
8100        // resolution, so a sub-100 ns literal is truncated on readback and the
8101        // assertion below would see …700. Any value < 2^31 exercises the
8102        // nsec:lsb:31 mask identically, so pin one that survives the round trip.
8103        inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
8104        inst.common.utag = 5;
8105        inst.set_info("Q:time:tag", "nsec:lsb:31");
8106
8107        let snap = inst.snapshot_for_field("VAL").unwrap();
8108        assert_eq!(snap.user_tag, 123_456_700);
8109        assert_eq!(snap.timestamp.subsec_nanos(), 0);
8110        assert_eq!(snap.timestamp.unix_secs(), 42);
8111    }
8112
8113    /// The monitor path applies the same `Q:time:tag` nsec split as GET.
8114    /// Pre-fix, `make_monitor_snapshot` skipped `apply_nsec_mask`, so a
8115    /// monitor update on a `nsec:lsb:N` record posted the raw nanoseconds
8116    /// and the record utag while a GET of the same channel served the
8117    /// split — upstream pvxs PR #189 is the same defect in its
8118    /// `subscriptionCallback`.
8119    #[test]
8120    fn qtime_nsec_mask_applies_on_the_monitor_path() {
8121        use std::time::{Duration, SystemTime};
8122        let mut inst = ai_instance();
8123        // 100 ns-multiple so the subsec_nanos assertion holds on Windows too;
8124        // see qtime_nsec_lsb_31_is_served_not_ignored for the FILETIME reason.
8125        inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
8126        inst.common.utag = 5;
8127        inst.set_info("Q:time:tag", "nsec:lsb:31");
8128
8129        let mon = inst
8130            .make_monitor_snapshot("VAL", EpicsValue::Double(1.0), LinkBacking::none())
8131            .expect("nothing was declined: the backing is unresolved, not declined");
8132        assert_eq!(mon.user_tag, 123_456_700);
8133        assert_eq!(mon.timestamp.subsec_nanos(), 0);
8134        assert_eq!(mon.timestamp.unix_secs(), 42);
8135    }
8136
8137    /// The mirror boundary: a tag pvxs's `strncmp` rejects must leave the
8138    /// timestamp and the record's own utag alone. The old case-insensitive
8139    /// split matched `NSEC:LSB:4` and masked the wire timestamp pvxs serves
8140    /// unmasked.
8141    #[test]
8142    fn qtime_uppercase_tag_leaves_timestamp_untouched() {
8143        use std::time::{Duration, SystemTime};
8144        let mut inst = ai_instance();
8145        // 100 ns-multiple so the subsec_nanos assertion holds on Windows too;
8146        // see qtime_nsec_lsb_31_is_served_not_ignored for the FILETIME reason.
8147        inst.common.time = SystemTime::UNIX_EPOCH + Duration::new(42, 123_456_700);
8148        inst.common.utag = 5;
8149        inst.set_info("Q:time:tag", "NSEC:LSB:4");
8150
8151        let snap = inst.snapshot_for_field("VAL").unwrap();
8152        assert_eq!(
8153            snap.user_tag, 5,
8154            "record utag must survive a non-matching tag"
8155        );
8156        assert_eq!(snap.timestamp.subsec_nanos(), 123_456_700);
8157    }
8158
8159    /// the served `timeStamp.userTag` defaults to the record's `utag`
8160    /// (pvxs `iocsource.cpp:245`), on both the GET (`snapshot_for_field`)
8161    /// and MONITOR (`make_monitor_snapshot`) paths. Pre-fix both hard-set
8162    /// it to 0, dropping the record's tag. A bit-31 utag also pins the
8163    /// `u64 -> i32` narrowing: the low 32 bits' pattern is preserved
8164    /// (no clamp), matching pvxs assigning `epicsUTag` into the `Int32`
8165    /// wire field.
8166    #[test]
8167    fn snapshot_serves_record_utag_as_timestamp_usertag() {
8168        let mut inst = ai_instance();
8169        // no `info(Q:time:tag, ...)` on this record, so the nsec-LSB
8170        // override never fires and the utag default is what is served.
8171        inst.common.utag = 0x9000_0000;
8172        let want = 0x9000_0000u32 as i32;
8173
8174        let get = inst.snapshot_for_field("VAL").unwrap();
8175        assert_eq!(
8176            get.user_tag, want,
8177            "GET path must serve the record's utag as timeStamp.userTag"
8178        );
8179
8180        let mon = inst
8181            .make_monitor_snapshot("VAL", EpicsValue::Double(1.0), LinkBacking::none())
8182            .expect("nothing was declined: the backing is unresolved, not declined");
8183        assert_eq!(
8184            mon.user_tag, want,
8185            "MONITOR path must carry the record's utag too"
8186        );
8187    }
8188
8189    #[test]
8190    fn cache_hit_returns_same_metadata() {
8191        let inst = ai_instance();
8192
8193        // Prime the cache
8194        let snap1 = inst.snapshot_for_field("VAL").unwrap();
8195        let display1 = snap1.display.unwrap();
8196
8197        // Subsequent snapshots return the same cached metadata
8198        let snap2 = inst.snapshot_for_field("VAL").unwrap();
8199        let display2 = snap2.display.unwrap();
8200
8201        assert_eq!(display1.units, display2.units);
8202        assert_eq!(display1.precision, display2.precision);
8203        assert_eq!(display1.upper_disp_limit, display2.upper_disp_limit);
8204        assert_eq!(display1.lower_disp_limit, display2.lower_disp_limit);
8205    }
8206
8207    #[test]
8208    fn invalidate_clears_cache() {
8209        let inst = ai_instance();
8210        let _ = inst.snapshot_for_field("VAL");
8211        assert!(inst.metadata_cache.lock().unwrap().is_some());
8212
8213        inst.invalidate_metadata_cache();
8214        assert!(inst.metadata_cache.lock().unwrap().is_none());
8215    }
8216
8217    #[test]
8218    fn notify_field_written_invalidates_for_metadata_field() {
8219        let inst = ai_instance();
8220        let _ = inst.snapshot_for_field("VAL");
8221        assert!(inst.metadata_cache.lock().unwrap().is_some());
8222
8223        // Writing a metadata field should invalidate
8224        inst.notify_field_written("EGU");
8225        assert!(inst.metadata_cache.lock().unwrap().is_none());
8226    }
8227
8228    #[test]
8229    fn notify_field_written_skips_non_metadata_field() {
8230        let inst = ai_instance();
8231        let _ = inst.snapshot_for_field("VAL");
8232        assert!(inst.metadata_cache.lock().unwrap().is_some());
8233
8234        // Writing a value field should NOT invalidate the cache
8235        inst.notify_field_written("VAL");
8236        assert!(inst.metadata_cache.lock().unwrap().is_some());
8237
8238        // DESC is not property-class either — its cache invalidation
8239        // is owned by the DESC arm of `put_common_field`, not by this
8240        // notify path (UI-106).
8241        inst.notify_field_written("DESC");
8242        assert!(inst.metadata_cache.lock().unwrap().is_some());
8243    }
8244
8245    #[test]
8246    fn notify_field_written_is_case_insensitive() {
8247        let inst = ai_instance();
8248        let _ = inst.snapshot_for_field("VAL");
8249        assert!(inst.metadata_cache.lock().unwrap().is_some());
8250
8251        // Lowercase metadata field name should still trigger invalidation
8252        inst.notify_field_written("egu");
8253        assert!(inst.metadata_cache.lock().unwrap().is_none());
8254    }
8255
8256    /// epics-base faac1df1 — `notify_field_written_if_changed` must
8257    /// SKIP the cache invalidation when the metadata field's value
8258    /// didn't actually change. Otherwise a stream of idempotent puts
8259    /// from a CSS panel binds DBE_PROPERTY subscribers to bogus
8260    /// "property changed" events on every cycle.
8261    #[test]
8262    fn notify_field_written_if_changed_skips_when_unchanged() {
8263        let mut inst = ai_instance();
8264        let _ = inst.snapshot_for_field("VAL");
8265        assert!(inst.metadata_cache.lock().unwrap().is_some());
8266
8267        // Capture prev, do a no-op put, then notify — cache must remain.
8268        let prev = inst.record.get_field("EGU");
8269        let _ = inst.record.put_field("EGU", prev.clone().unwrap());
8270        inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
8271        assert!(
8272            inst.metadata_cache.lock().unwrap().is_some(),
8273            "no-op put must not invalidate the metadata cache"
8274        );
8275    }
8276
8277    /// And when the value DID change, the cache must invalidate.
8278    #[test]
8279    fn notify_field_written_if_changed_invalidates_on_real_change() {
8280        let mut inst = ai_instance();
8281        let _ = inst.snapshot_for_field("VAL");
8282        assert!(inst.metadata_cache.lock().unwrap().is_some());
8283
8284        let prev = inst.record.get_field("EGU");
8285        let _ = inst
8286            .record
8287            .put_field("EGU", EpicsValue::String("kPa".into()));
8288        inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
8289        assert!(
8290            inst.metadata_cache.lock().unwrap().is_none(),
8291            "real metadata change must invalidate cache"
8292        );
8293    }
8294
8295    /// UI-106 / epics-base#785 — DESC feeds `display.description`
8296    /// (pvxs fills it on every metadata populate, iocsource.cpp:306-310),
8297    /// and a changed DESC refreshes the cache at its write owner so the
8298    /// next snapshot serves the new text.
8299    #[test]
8300    fn desc_reaches_display_description_and_a_write_refreshes_it() {
8301        let mut inst = ai_instance();
8302        inst.put_common_field("DESC", EpicsValue::String("before".into()))
8303            .unwrap();
8304        let snap = inst.snapshot_for_field("VAL").unwrap();
8305        assert_eq!(
8306            snap.display.as_ref().unwrap().description.as_str_lossy(),
8307            "before"
8308        );
8309        inst.put_common_field("DESC", EpicsValue::String("after".into()))
8310            .unwrap();
8311        assert!(
8312            inst.metadata_cache.lock().unwrap().is_none(),
8313            "a changed DESC must invalidate the metadata cache"
8314        );
8315        let snap = inst.snapshot_for_field("VAL").unwrap();
8316        assert_eq!(
8317            snap.display.as_ref().unwrap().description.as_str_lossy(),
8318            "after"
8319        );
8320    }
8321
8322    /// …and an idempotent DESC put must NOT invalidate — same
8323    /// discipline as faac1df1 for the property-class fields.
8324    #[test]
8325    fn an_idempotent_desc_put_keeps_the_cache() {
8326        let mut inst = ai_instance();
8327        inst.put_common_field("DESC", EpicsValue::String("same".into()))
8328            .unwrap();
8329        let _ = inst.snapshot_for_field("VAL");
8330        assert!(inst.metadata_cache.lock().unwrap().is_some());
8331        inst.put_common_field("DESC", EpicsValue::String("same".into()))
8332            .unwrap();
8333        assert!(
8334            inst.metadata_cache.lock().unwrap().is_some(),
8335            "an unchanged DESC must not invalidate the metadata cache"
8336        );
8337    }
8338
8339    /// Non-metadata fields don't carry property semantics — the
8340    /// `if_changed` variant must never invalidate for them, matching
8341    /// the existing `notify_field_written` short-circuit.
8342    #[test]
8343    fn notify_field_written_if_changed_skips_non_metadata_field() {
8344        let mut inst = ai_instance();
8345        let _ = inst.snapshot_for_field("VAL");
8346        assert!(inst.metadata_cache.lock().unwrap().is_some());
8347        // VAL is neither a cache source nor `prop(YES)` — must be skipped
8348        // even with a changed value.
8349        inst.notify_field_written_if_changed("VAL", None, LinkBacking::none());
8350        assert!(inst.metadata_cache.lock().unwrap().is_some());
8351    }
8352
8353    #[test]
8354    fn cache_picks_up_new_value_after_invalidation() {
8355        let mut inst = ai_instance();
8356
8357        // First snapshot: degC
8358        let snap1 = inst.snapshot_for_field("VAL").unwrap();
8359        assert_eq!(snap1.display.unwrap().units, "degC");
8360
8361        // Mutate EGU and invalidate
8362        let _ = inst
8363            .record
8364            .put_field("EGU", EpicsValue::String("mV".into()));
8365        inst.notify_field_written("EGU");
8366
8367        // Second snapshot: mV (rebuilt)
8368        let snap2 = inst.snapshot_for_field("VAL").unwrap();
8369        assert_eq!(snap2.display.unwrap().units, "mV");
8370    }
8371
8372    /// R19-41: every snapshot carries the mask of which properties the
8373    /// channel SUPPLIES — C's `rset` slots (`dbAccess.c:336-427` clears the
8374    /// option bit of each NULL slot) narrowed to the addressed field. One
8375    /// case per gate boundary; the three record types are the ones measured
8376    /// against pvxs, which marks none of these leaves.
8377    #[test]
8378    fn property_support_masks_what_the_record_type_does_not_supply() {
8379        use crate::server::records::longout::LongoutRecord;
8380        use crate::server::records::stringout::StringoutRecord;
8381        use crate::server::records::waveform::WaveformRecord;
8382
8383        // ai VAL (DBF_DOUBLE): every numeric slot, no enum strings.
8384        let ai = ai_instance();
8385        let p = ai.snapshot_for_field("VAL").unwrap().properties;
8386        assert_eq!(p, PropertySupport::NUMERIC);
8387        assert_eq!(
8388            ai.snapshot_for_field("VAL").unwrap().precision(),
8389            Some(2),
8390            "an ai supplies get_precision and VAL is DBF_DOUBLE"
8391        );
8392
8393        // ai RVAL (DBF_LONG): the SAME rset, but C keeps DBR_PRECISION only
8394        // for DBF_FLOAT/DBF_DOUBLE (`dbAccess.c:386-395`).
8395        let rval = ai.snapshot_for_field("RVAL").unwrap();
8396        assert!(
8397            !rval.properties.precision && rval.precision().is_none(),
8398            "a non-float field supplies no precision even when the rset does"
8399        );
8400        assert!(
8401            rval.properties.units,
8402            "the other slots are unaffected by the field's type"
8403        );
8404
8405        // longout: `#define get_precision NULL`.
8406        let lo = RecordInstance::new("LO".to_string(), LongoutRecord::default());
8407        let lo = lo.snapshot_for_field("VAL").unwrap();
8408        assert!(!lo.properties.precision && lo.precision().is_none());
8409        assert!(lo.properties.units && lo.properties.graphic_double);
8410
8411        // stringout: no property slot at all.
8412        let so = RecordInstance::new("SO".to_string(), StringoutRecord::default());
8413        let so = so.snapshot_for_field("VAL").unwrap();
8414        assert_eq!(so.properties, PropertySupport::NONE);
8415        assert!(so.units().is_none(), "a stringout supplies no EGU");
8416
8417        // waveform: `#define get_alarm_double NULL`.
8418        let wf = RecordInstance::new("WF".to_string(), WaveformRecord::default());
8419        let wf = wf.snapshot_for_field("VAL").unwrap();
8420        assert!(
8421            !wf.properties.alarm_double && wf.alarm_limits().is_none(),
8422            "a waveform supplies no alarm limits — a GUI must not draw bands at zero"
8423        );
8424        assert!(wf.properties.units && wf.properties.graphic_double);
8425    }
8426
8427    #[test]
8428    fn make_monitor_snapshot_uses_cache() {
8429        let inst = ai_instance();
8430        assert!(inst.metadata_cache.lock().unwrap().is_none());
8431
8432        // make_monitor_snapshot should also populate the cache
8433        let snap = inst
8434            .make_monitor_snapshot("VAL", EpicsValue::Double(42.0), LinkBacking::none())
8435            .expect("nothing was declined: the backing is unresolved, not declined");
8436        assert!(snap.display.is_some());
8437        assert!(inst.metadata_cache.lock().unwrap().is_some());
8438
8439        // Subsequent call hits cache
8440        let snap2 = inst
8441            .make_monitor_snapshot("VAL", EpicsValue::Double(43.0), LinkBacking::none())
8442            .expect("nothing was declined: the backing is unresolved, not declined");
8443        let d1 = snap.display.unwrap();
8444        let d2 = snap2.display.unwrap();
8445        assert_eq!(d1.units, d2.units);
8446        assert_eq!(d1.precision, d2.precision);
8447    }
8448
8449    /// Stub record with a per-field metadata override on SPD only —
8450    /// models a C RSET whose get_units/get_graphic_double key on
8451    /// dbGetFieldIndex (e.g. motorRecord.cc:3156-3361).
8452    static PER_FIELD_META_FIELDS: &[crate::server::record::FieldDesc] = &[
8453        crate::server::record::FieldDesc::new("VAL", crate::types::DbFieldType::Double, false),
8454        crate::server::record::FieldDesc::new("SPD", crate::types::DbFieldType::Double, false),
8455        crate::server::record::FieldDesc::new("EGU", crate::types::DbFieldType::String, false),
8456        crate::server::record::FieldDesc::new("PREC", crate::types::DbFieldType::Short, false),
8457        crate::server::record::FieldDesc::new("HOPR", crate::types::DbFieldType::Double, false),
8458        crate::server::record::FieldDesc::new("LOPR", crate::types::DbFieldType::Double, false),
8459    ];
8460
8461    struct PerFieldMetaRecord;
8462
8463    impl Record for PerFieldMetaRecord {
8464        /// Its own type, not `ai`: the fixture serves `SPD`, which no `ai`
8465        /// declares, and a field is readable only where the record type
8466        /// declares it (`resolve_field`). Record-level metadata still
8467        /// populates, because that comes from EGU/PREC/HOPR/LOPR below.
8468        fn record_type(&self) -> &'static str {
8469            "per_field_meta"
8470        }
8471        fn get_field(&self, name: &str) -> Option<EpicsValue> {
8472            match name {
8473                "VAL" | "SPD" => Some(EpicsValue::Double(1.0)),
8474                "EGU" => Some(EpicsValue::String("mm".into())),
8475                "PREC" => Some(EpicsValue::Short(3)),
8476                "HOPR" => Some(EpicsValue::Double(100.0)),
8477                "LOPR" => Some(EpicsValue::Double(-100.0)),
8478                _ => None,
8479            }
8480        }
8481        fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
8482            Err(CaError::FieldNotFound(name.to_string()))
8483        }
8484        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
8485            PER_FIELD_META_FIELDS
8486        }
8487        fn field_metadata_override(
8488            &self,
8489            field: &str,
8490        ) -> Option<crate::server::record::FieldMetadataOverride> {
8491            if field != "SPD" {
8492                return None;
8493            }
8494            Some(crate::server::record::FieldMetadataOverride {
8495                units: Some("mm/sec".into()),
8496                precision: Some(1),
8497                disp_limits: Some((5.0, 0.5)),
8498                ctrl_limits: Some((4.0, 1.0)),
8499                alarm_limits: Some((9.0, 8.0, -8.0, -9.0)),
8500            })
8501        }
8502    }
8503
8504    #[test]
8505    fn field_metadata_override_applies_on_get_and_monitor_paths() {
8506        let inst = RecordInstance::new("PFM".to_string(), PerFieldMetaRecord);
8507
8508        // VAL: no override — record-level metadata serves it.
8509        let snap = inst.snapshot_for_field("VAL").unwrap();
8510        let d = snap.display.unwrap();
8511        assert_eq!(d.units, "mm");
8512        assert_eq!(d.precision, 3);
8513        assert_eq!(d.upper_disp_limit, 100.0);
8514
8515        // SPD via the GET path: every member patched over the cache.
8516        let snap = inst.snapshot_for_field("SPD").unwrap();
8517        let d = snap.display.unwrap();
8518        assert_eq!(d.units, "mm/sec");
8519        assert_eq!(d.precision, 1);
8520        assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
8521        assert_eq!(
8522            (
8523                d.upper_alarm_limit,
8524                d.upper_warning_limit,
8525                d.lower_warning_limit,
8526                d.lower_alarm_limit
8527            ),
8528            (9.0, 8.0, -8.0, -9.0)
8529        );
8530        let c = snap.control.unwrap();
8531        assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
8532
8533        // SPD via the monitor path: identical override.
8534        let snap = inst
8535            .make_monitor_snapshot("SPD", EpicsValue::Double(2.0), LinkBacking::none())
8536            .expect("nothing was declined: the backing is unresolved, not declined");
8537        let d = snap.display.unwrap();
8538        assert_eq!(d.units, "mm/sec");
8539        assert_eq!((d.upper_disp_limit, d.lower_disp_limit), (5.0, 0.5));
8540        let c = snap.control.unwrap();
8541        assert_eq!((c.upper_ctrl_limit, c.lower_ctrl_limit), (4.0, 1.0));
8542    }
8543
8544    /// Stub modelling the motor monitor() shape (C motorRecord.cc:
8545    /// 3468-3507): VAL is a setpoint, the MDEL/ADEL deadband tracks
8546    /// the RBV readback, which advances on every process.
8547    static READBACK_DEADBAND_FIELDS: &[crate::server::record::FieldDesc] = &[
8548        crate::server::record::FieldDesc::new("VAL", crate::types::DbFieldType::Double, false),
8549        crate::server::record::FieldDesc::new("RBV", crate::types::DbFieldType::Double, false),
8550        crate::server::record::FieldDesc::new("MDEL", crate::types::DbFieldType::Double, false),
8551        crate::server::record::FieldDesc::new("ADEL", crate::types::DbFieldType::Double, false),
8552    ];
8553
8554    struct ReadbackDeadbandRecord {
8555        val: f64,
8556        rbv: f64,
8557        deadband: f64,
8558    }
8559
8560    impl Record for ReadbackDeadbandRecord {
8561        /// `RBV` is a motor field, not an `ai` one, and only a record type
8562        /// that declares a field can serve it.
8563        fn record_type(&self) -> &'static str {
8564            "readback_deadband"
8565        }
8566        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
8567            self.rbv += 30.0;
8568            Ok(crate::server::record::ProcessOutcome::complete())
8569        }
8570        fn get_field(&self, name: &str) -> Option<EpicsValue> {
8571            match name {
8572                "VAL" => Some(EpicsValue::Double(self.val)),
8573                "RBV" => Some(EpicsValue::Double(self.rbv)),
8574                "MDEL" | "ADEL" => Some(EpicsValue::Double(self.deadband)),
8575                _ => None,
8576            }
8577        }
8578        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
8579            match (name, value) {
8580                ("VAL", EpicsValue::Double(v)) => {
8581                    self.val = v;
8582                    Ok(())
8583                }
8584                ("MDEL", EpicsValue::Double(v)) => {
8585                    self.deadband = v;
8586                    Ok(())
8587                }
8588                _ => Err(CaError::FieldNotFound(name.to_string())),
8589            }
8590        }
8591        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
8592            READBACK_DEADBAND_FIELDS
8593        }
8594        fn monitor_deadband_value(&self) -> Option<f64> {
8595            Some(self.rbv)
8596        }
8597        fn monitor_deadband_field(&self) -> &'static str {
8598            "RBV"
8599        }
8600    }
8601
8602    /// C motor monitor() parity: MDEL/ADEL throttle the deadband
8603    /// field's (RBV) delivery; VAL posts only when the setpoint
8604    /// actually changed — not on every readback poll.
8605    #[test]
8606    fn deadband_field_routes_readback_and_val_posts_only_on_change() {
8607        use crate::server::recgbl::EventMask;
8608        let mut inst = RecordInstance::new(
8609            "RDB".to_string(),
8610            ReadbackDeadbandRecord {
8611                val: 5.0,
8612                rbv: 0.0,
8613                deadband: 10.0,
8614            },
8615        );
8616        let _val_rx = inst
8617            .add_subscriber(
8618                "VAL",
8619                1,
8620                crate::types::DbFieldType::Double,
8621                EventMask::VALUE.bits(),
8622            )
8623            .expect("VAL subscriber");
8624        let _rbv_rx = inst
8625            .add_subscriber(
8626                "RBV",
8627                2,
8628                crate::types::DbFieldType::Double,
8629                EventMask::VALUE.bits(),
8630            )
8631            .expect("RBV subscriber");
8632        let names =
8633            |snap: &ProcessSnapshot| snap.iter().map(|(n, _, _)| n.clone()).collect::<Vec<_>>();
8634
8635        // Cycle 1 (first publish): RBV fires via the deadband trigger
8636        // (MLST starts at the NaN never-posted sentinel). VAL must NOT
8637        // post: `add_subscriber` seeded `last_posted` with the current
8638        // value (the initial value already went out with EVENT_ADD), and
8639        // C monitor() posts VAL only when MARKED(M_VAL) — nothing marked
8640        // it.
8641        let (snap, _) = inst.process_local().unwrap();
8642        let n = names(&snap);
8643        assert!(n.contains(&std::borrow::Cow::Borrowed("RBV")), "{n:?}");
8644        assert!(
8645            !n.contains(&std::borrow::Cow::Borrowed("VAL")),
8646            "VAL unchanged since subscribe must not post: {n:?}"
8647        );
8648
8649        // Cycle 2: RBV moved past MDEL, VAL unchanged → RBV posted,
8650        // VAL not re-posted.
8651        let (snap, _) = inst.process_local().unwrap();
8652        let n = names(&snap);
8653        assert!(
8654            n.contains(&std::borrow::Cow::Borrowed("RBV")),
8655            "RBV crossed MDEL: {n:?}"
8656        );
8657        assert!(
8658            !n.contains(&std::borrow::Cow::Borrowed("VAL")),
8659            "unchanged VAL must not post: {n:?}"
8660        );
8661
8662        // Cycle 3: widen the deadband — RBV moves within it → throttled.
8663        let _ = inst.record.put_field("MDEL", EpicsValue::Double(1000.0));
8664        let (snap, _) = inst.process_local().unwrap();
8665        let n = names(&snap);
8666        assert!(
8667            !n.contains(&std::borrow::Cow::Borrowed("RBV")),
8668            "MDEL must throttle RBV: {n:?}"
8669        );
8670
8671        // Cycle 4: setpoint moves while RBV stays inside the deadband →
8672        // VAL posts via change detection, RBV stays throttled.
8673        let _ = inst.record.put_field("VAL", EpicsValue::Double(42.0));
8674        let (snap, _) = inst.process_local().unwrap();
8675        let n = names(&snap);
8676        assert!(
8677            n.contains(&std::borrow::Cow::Borrowed("VAL")),
8678            "changed VAL must post: {n:?}"
8679        );
8680        assert!(
8681            !n.contains(&std::borrow::Cow::Borrowed("RBV")),
8682            "MDEL must throttle RBV: {n:?}"
8683        );
8684    }
8685
8686    /// A subroutine-less aSub (empty SNAM — the record the PVA monitor
8687    /// oracle drives as `ORACLE:MONSCAN:ASUB`) mirrors C `do_sub`
8688    /// (aSubRecord.c:459-465): an empty SNAM returns 0 BEFORE the bad-sub
8689    /// check, and C `process` (`:224`) runs `prec->val = status = 0` every
8690    /// cycle. So a periodic scan forces VAL back to 0, and C `monitor()`
8691    /// (`:414`, `val != oval`) posts nothing — the driven `dbPut`s are the
8692    /// only VAL events.
8693    ///
8694    /// Before the fix the port's "no bound subroutine" branch returned
8695    /// `S_db_BadSub` and never wrote VAL, so a scanned aSub kept VAL at the
8696    /// last client put and the deadband gate re-posted it on every scan (the
8697    /// oracle's 7 updates where C posts 4). This pins both halves: `process`
8698    /// resets VAL to 0, and a scan of the reset value posts nothing.
8699    #[test]
8700    fn subroutineless_asub_process_resets_val_and_stops_scan_overposting() {
8701        use crate::server::recgbl::EventMask;
8702        use crate::server::records::asub_record::ASubRecord;
8703
8704        let mut inst = RecordInstance::new("ASUB".to_string(), ASubRecord::default());
8705        // The default record: no subroutine bound, SNAM empty.
8706        assert!(inst.subroutine.is_none());
8707        let _val_rx = inst
8708            .add_subscriber(
8709                "VAL",
8710                1,
8711                crate::types::DbFieldType::Long,
8712                EventMask::VALUE.bits(),
8713            )
8714            .expect("VAL subscriber");
8715        let posts_val = |snap: &ProcessSnapshot| snap.iter().any(|(n, _, _)| n == "VAL");
8716
8717        // A settling scan of the unchanged record: C `do_sub` returns 0 and
8718        // `process` leaves VAL at 0 (already 0), settling the monitor gate.
8719        let _ = inst.process_local().unwrap();
8720        assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
8721        // status 0 -> C `if (!status)` drives every OUT link (aSub's
8722        // `multi_output_links` gate reads the cycle status); a bad-sub status
8723        // would suppress all 21.
8724        assert_eq!(
8725            inst.record.multi_output_links().len(),
8726            21,
8727            "empty-SNAM do_sub status must be 0, not S_db_BadSub"
8728        );
8729
8730        // A client caput lands on VAL (DBF_LONG, not process-passive: it posts
8731        // but does not itself process, leaving VAL non-zero — exactly how the
8732        // oracle drives the scanned reproducer between scans).
8733        inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
8734
8735        // The periodic scan processes. C forces VAL back to 0 and posts
8736        // nothing (val == oval == 0). Before the fix VAL stayed 7 and the scan
8737        // re-posted it.
8738        let (snap, _) = inst.process_local().unwrap();
8739        assert_eq!(
8740            inst.record.get_field("VAL"),
8741            Some(EpicsValue::Long(0)),
8742            "a scan must reset VAL to the do_sub status (0)"
8743        );
8744        assert!(
8745            !posts_val(&snap),
8746            "a scan that resets VAL to 0 must not re-post it"
8747        );
8748
8749        // A second driven put + scan: the monitor marker stays at 0, so no
8750        // scan ever re-posts the reset value.
8751        inst.record.put_field("VAL", EpicsValue::Long(7)).unwrap();
8752        let (snap, _) = inst.process_local().unwrap();
8753        assert_eq!(inst.record.get_field("VAL"), Some(EpicsValue::Long(0)));
8754        assert!(
8755            !posts_val(&snap),
8756            "repeated scans must not re-post the reset VAL"
8757        );
8758    }
8759
8760    /// Record that names DIFF in `force_posted_fields` (the motor's C
8761    /// `process_motor_info` unconditional `MARK(M_DIFF)`) while keeping
8762    /// every value constant — a settled axis parked at a fixed non-zero
8763    /// following error. VAL is a control: not force-listed, so it must
8764    /// fall back to change-detection.
8765    static FORCE_POST_FIELDS: &[crate::server::record::FieldDesc] = &[
8766        crate::server::record::FieldDesc::new("DIFF", crate::types::DbFieldType::Double, false),
8767        crate::server::record::FieldDesc::new("VAL", crate::types::DbFieldType::Double, false),
8768    ];
8769
8770    struct ForcePostRecord {
8771        diff: f64,
8772        val: f64,
8773    }
8774
8775    impl Record for ForcePostRecord {
8776        fn record_type(&self) -> &'static str {
8777            "force_post"
8778        }
8779        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
8780            // Values never change — the readback already matches; only the
8781            // unconditional MARK should keep DIFF flowing.
8782            Ok(crate::server::record::ProcessOutcome::complete())
8783        }
8784        fn get_field(&self, name: &str) -> Option<EpicsValue> {
8785            match name {
8786                "DIFF" => Some(EpicsValue::Double(self.diff)),
8787                "VAL" => Some(EpicsValue::Double(self.val)),
8788                _ => None,
8789            }
8790        }
8791        fn put_field(&mut self, name: &str, _value: EpicsValue) -> CaResult<()> {
8792            Err(CaError::FieldNotFound(name.to_string()))
8793        }
8794        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
8795            FORCE_POST_FIELDS
8796        }
8797        fn force_posted_fields(&self) -> &'static [&'static str] {
8798            &["DIFF"]
8799        }
8800    }
8801
8802    /// C motorRecord parity: `process_motor_info` MARKs M_DIFF/M_RDIF every
8803    /// CALLBACK_DATA pass and `monitor()` posts them with `DBE_VAL_LOG`
8804    /// regardless of change, so a force-posted field re-posts on an
8805    /// otherwise-idle cycle while an unchanged non-force field does not.
8806    #[test]
8807    fn force_posted_field_reposts_unchanged_value_each_cycle() {
8808        use crate::server::recgbl::EventMask;
8809        let mut inst = RecordInstance::new(
8810            "FP".to_string(),
8811            ForcePostRecord {
8812                diff: 2.5,
8813                val: 1.0,
8814            },
8815        );
8816        let _diff_rx = inst
8817            .add_subscriber(
8818                "DIFF",
8819                1,
8820                crate::types::DbFieldType::Double,
8821                EventMask::VALUE.bits(),
8822            )
8823            .expect("DIFF subscriber");
8824        let _val_rx = inst
8825            .add_subscriber(
8826                "VAL",
8827                2,
8828                crate::types::DbFieldType::Double,
8829                EventMask::VALUE.bits(),
8830            )
8831            .expect("VAL subscriber");
8832        let names =
8833            |snap: &ProcessSnapshot| snap.iter().map(|(n, _, _)| n.clone()).collect::<Vec<_>>();
8834
8835        // Cycle 1 (first publish): both DIFF and VAL post — last_posted is
8836        // empty so change-detection treats every subscribed field as new.
8837        let (snap1, _) = inst.process_local().unwrap();
8838        assert!(
8839            names(&snap1).contains(&std::borrow::Cow::Borrowed("DIFF")),
8840            "DIFF posts on first publish: {:?}",
8841            names(&snap1)
8842        );
8843
8844        // Cycle 2: nothing changed. VAL (not force-listed) must NOT re-post;
8845        // DIFF (force-listed) MUST re-post — the C unconditional MARK +
8846        // DBE_VAL_LOG. This is the divergence MOT-1 closes.
8847        let (snap2, _) = inst.process_local().unwrap();
8848        assert!(
8849            names(&snap2).contains(&std::borrow::Cow::Borrowed("DIFF")),
8850            "force-posted DIFF must re-post when unchanged: {:?}",
8851            names(&snap2)
8852        );
8853        assert!(
8854            !names(&snap2).contains(&std::borrow::Cow::Borrowed("VAL")),
8855            "an unchanged non-force field must not re-post: {:?}",
8856            names(&snap2)
8857        );
8858        // The forced re-post carries DBE_VALUE|DBE_LOG (no alarm bits this
8859        // cycle), matching C `monitor_mask | DBE_VAL_LOG` with monitor_mask=0.
8860        let diff_mask = snap2
8861            .iter()
8862            .find(|(n, _, _)| n == "DIFF")
8863            .map(|(_, _, m)| *m)
8864            .expect("DIFF post present");
8865        assert_eq!(
8866            diff_mask.bits(),
8867            (EventMask::VALUE | EventMask::LOG).bits(),
8868            "forced re-post mask is DBE_VAL_LOG"
8869        );
8870    }
8871
8872    /// Record that names S1 in `log_swept_fields` (the scaler's idle
8873    /// `monitor()` DBE_LOG sweep) while keeping every value constant. S2
8874    /// is a control: subscribed but NOT swept, so an unchanged S2 must
8875    /// not re-post. Neither field is the primary `VAL`, so the default
8876    /// deadband field resolves to nothing and does not confound the test.
8877    static LOG_SWEEP_FIELDS: &[crate::server::record::FieldDesc] = &[
8878        crate::server::record::FieldDesc::new("S1", crate::types::DbFieldType::Long, false),
8879        crate::server::record::FieldDesc::new("S2", crate::types::DbFieldType::Long, false),
8880    ];
8881
8882    struct LogSweepRecord {
8883        s1: i32,
8884        s2: i32,
8885    }
8886
8887    impl Record for LogSweepRecord {
8888        fn record_type(&self) -> &'static str {
8889            "scaler"
8890        }
8891        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
8892            // Counts never change — only the unconditional idle LOG sweep
8893            // should keep S1 flowing to a DBE_LOG (archiver) subscriber.
8894            Ok(crate::server::record::ProcessOutcome::complete())
8895        }
8896        fn get_field(&self, name: &str) -> Option<EpicsValue> {
8897            match name {
8898                "S1" => Some(EpicsValue::Long(self.s1)),
8899                "S2" => Some(EpicsValue::Long(self.s2)),
8900                _ => None,
8901            }
8902        }
8903        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
8904            match (name, value) {
8905                ("S1", EpicsValue::Long(v)) => {
8906                    self.s1 = v;
8907                    Ok(())
8908                }
8909                ("S2", EpicsValue::Long(v)) => {
8910                    self.s2 = v;
8911                    Ok(())
8912                }
8913                _ => Err(CaError::FieldNotFound(name.to_string())),
8914            }
8915        }
8916        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
8917            LOG_SWEEP_FIELDS
8918        }
8919        fn log_swept_fields(&self) -> &'static [&'static str] {
8920            &["S1"]
8921        }
8922    }
8923
8924    /// C `scalerRecord.c::monitor():757-773` sweeps each active channel with a
8925    /// literal `DBE_LOG` on every cycle it runs, unconditionally — the sweep is
8926    /// INDEPENDENT of the change post, not an alternative to it (R12-62). So an
8927    /// UNCHANGED swept field posts `DBE_LOG` only, and a CHANGED swept field
8928    /// posts TWICE on that one cycle: once by change-detection, and once by the
8929    /// sweep with `DBE_LOG`. (In C's scaler those two are `updateCounts()`'s
8930    /// `DBE_VALUE` at `:582` and `monitor()`'s `DBE_LOG` at `:771`.) A
8931    /// non-swept field never re-posts when unchanged. `add_subscriber` seeds
8932    /// `last_posted` with the current value (the initial value goes out via
8933    /// EVENT_ADD), so a freshly subscribed unchanged field already takes the
8934    /// sweep path on cycle 1.
8935    #[test]
8936    fn log_swept_field_reposts_unchanged_with_log_mask_only() {
8937        use crate::server::recgbl::EventMask;
8938        let mut inst = RecordInstance::new("SW".to_string(), LogSweepRecord { s1: 7, s2: 9 });
8939        let _s1_rx = inst
8940            .add_subscriber(
8941                "S1",
8942                1,
8943                crate::types::DbFieldType::Long,
8944                EventMask::LOG.bits(),
8945            )
8946            .expect("S1 subscriber");
8947        let _s2_rx = inst
8948            .add_subscriber(
8949                "S2",
8950                2,
8951                crate::types::DbFieldType::Long,
8952                EventMask::VALUE.bits(),
8953            )
8954            .expect("S2 subscriber");
8955        let names =
8956            |snap: &ProcessSnapshot| snap.iter().map(|(n, _, _)| n.clone()).collect::<Vec<_>>();
8957        let count_of =
8958            |snap: &ProcessSnapshot, f: &str| snap.iter().filter(|(n, _, _)| n == f).count();
8959        let mask_of = |snap: &ProcessSnapshot, f: &str| {
8960            snap.iter().find(|(n, _, _)| n == f).map(|(_, _, m)| *m)
8961        };
8962
8963        // Cycle 1: nothing changed since subscribe. S1 (swept) re-posts
8964        // with DBE_LOG ONLY; S2 (not swept) must NOT re-post.
8965        let (snap1, _) = inst.process_local().unwrap();
8966        assert!(
8967            names(&snap1).contains(&std::borrow::Cow::Borrowed("S1")),
8968            "log-swept S1 must re-post when unchanged: {:?}",
8969            names(&snap1)
8970        );
8971        assert!(
8972            !names(&snap1).contains(&std::borrow::Cow::Borrowed("S2")),
8973            "unchanged non-swept S2 must not re-post: {:?}",
8974            names(&snap1)
8975        );
8976        // DBE_LOG, plus the DBE_ALARM of this cycle's transition: a record
8977        // starts UDF/INVALID and its first process clears that, so cycle 1 IS an
8978        // alarm transition (CBUG-B19 — C's sweep drops the alarm bit; this
8979        // assertion used to require a bare DBE_LOG). No DBE_VALUE either way:
8980        // the counts have not moved.
8981        assert_eq!(
8982            mask_of(&snap1, "S1").unwrap().bits(),
8983            (EventMask::LOG | EventMask::ALARM).bits(),
8984            "idle sweep posts DBE_LOG + the alarm transition, never DBE_VALUE"
8985        );
8986
8987        // Cycle 2: S1's count changed. Change-detection delivers it, and the
8988        // sweep delivers it AGAIN with DBE_LOG — the two C `db_post_events`
8989        // calls of the count-completion cycle.
8990        inst.record.put_field("S1", EpicsValue::Long(8)).unwrap();
8991        let (snap2, _) = inst.process_local().unwrap();
8992        assert_eq!(
8993            count_of(&snap2, "S1"),
8994            2,
8995            "a changed swept field posts twice — change post + independent \
8996             DBE_LOG sweep: {:?}",
8997            snap2.iter().collect::<Vec<_>>()
8998        );
8999        let s1_masks: Vec<u16> = snap2
9000            .iter()
9001            .filter(|(n, _, _)| n == "S1")
9002            .map(|(_, _, m)| m.bits())
9003            .collect();
9004        assert_eq!(
9005            s1_masks,
9006            vec![
9007                (EventMask::VALUE | EventMask::LOG).bits(),
9008                EventMask::LOG.bits()
9009            ],
9010            "change post first (VALUE|LOG here — this stub is not a \
9011             value_only_change_fields record), then the sweep's literal DBE_LOG"
9012        );
9013
9014        // Cycle 3: unchanged again — back to the DBE_LOG-only sweep.
9015        let (snap3, _) = inst.process_local().unwrap();
9016        assert_eq!(
9017            mask_of(&snap3, "S1").unwrap().bits(),
9018            EventMask::LOG.bits(),
9019            "unchanged-again S1 returns to the DBE_LOG-only sweep"
9020        );
9021    }
9022
9023    /// A log-swept record that can raise an alarm on demand — the scaler's
9024    /// `do_alarm()` (scalerRecord.c:745-755) in miniature.
9025    static ALARMING_LOG_SWEEP_FIELDS: &[crate::server::record::FieldDesc] =
9026        &[crate::server::record::FieldDesc::new(
9027            "S1",
9028            crate::types::DbFieldType::Long,
9029            false,
9030        )];
9031
9032    struct AlarmingLogSweepRecord {
9033        s1: i32,
9034        alarm: bool,
9035    }
9036
9037    impl Record for AlarmingLogSweepRecord {
9038        fn record_type(&self) -> &'static str {
9039            "scaler"
9040        }
9041        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
9042            Ok(crate::server::record::ProcessOutcome::complete())
9043        }
9044        /// This fixture drives its alarm purely through `check_alarms`
9045        /// (`self.alarm`), so it must NOT also raise the central UDF alarm —
9046        /// otherwise the born `udf = 1` pins severity at INVALID every cycle
9047        /// and there is never a real NO_ALARM → INVALID transition to test.
9048        /// (Before `rec_gbl_check_udf` stopped fabricating a UDF message, the
9049        /// ALARM bit this test asserts came from that fabricated amsg
9050        /// flipping to "" — an artifact, not the severity transition the
9051        /// test name and comments describe.) With no UDF alarm, cycle 1
9052        /// genuinely clears the born UDF/INVALID to NO_ALARM, and the
9053        /// `self.alarm` cycle is a true severity transition.
9054        fn raises_udf_alarm(&self) -> bool {
9055            false
9056        }
9057        fn check_alarms(&mut self, common: &mut crate::server::record::CommonFields) {
9058            if self.alarm {
9059                crate::server::recgbl::rec_gbl_set_sevr(
9060                    common,
9061                    crate::server::recgbl::alarm_status::UDF_ALARM,
9062                    crate::server::record::AlarmSeverity::Invalid,
9063                );
9064            }
9065        }
9066        fn get_field(&self, name: &str) -> Option<EpicsValue> {
9067            match name {
9068                "S1" => Some(EpicsValue::Long(self.s1)),
9069                _ => None,
9070            }
9071        }
9072        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
9073            match (name, value) {
9074                ("S1", EpicsValue::Long(v)) => {
9075                    self.s1 = v;
9076                    Ok(())
9077                }
9078                _ => Err(CaError::FieldNotFound(name.to_string())),
9079            }
9080        }
9081        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
9082            ALARMING_LOG_SWEEP_FIELDS
9083        }
9084        fn log_swept_fields(&self) -> &'static [&'static str] {
9085            &["S1"]
9086        }
9087        fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
9088            Some(self)
9089        }
9090    }
9091
9092    /// CBUG-B19 — the sweep post carries the alarm-transition bits.
9093    ///
9094    /// DEVIATION from C, deliberate. C's scaler `monitor()` computes
9095    /// `monitor_mask = recGblResetAlarms(pscal)` (scalerRecord.c:764), ORs
9096    /// `DBE_VALUE|DBE_LOG` into it (`:766`), and then posts every `Sn` with a
9097    /// LITERAL `DBE_LOG` (`:771`) — `monitor_mask` is assigned, OR-ed, and never
9098    /// read. The alarm bit that `recGblResetAlarms` returns is exactly what every
9099    /// other record ORs into its value posts, so C drops it: a client subscribed
9100    /// to `Sn` with DBE_ALARM receives NOTHING on a severity transition.
9101    ///
9102    /// The DBE_VALUE half of C's dead `|=` is deliberately not resurrected — the
9103    /// sweep is unconditional, so a VALUE bit here would fire a value event on
9104    /// every idle scan whether or not the counts moved. The first assertion pins
9105    /// that.
9106    #[test]
9107    fn b19_log_swept_field_carries_the_alarm_transition_bits() {
9108        use crate::server::recgbl::EventMask;
9109        let mut inst = RecordInstance::new(
9110            "SW".to_string(),
9111            AlarmingLogSweepRecord {
9112                s1: 7,
9113                alarm: false,
9114            },
9115        );
9116        let _s1_rx = inst
9117            .add_subscriber(
9118                "S1",
9119                1,
9120                crate::types::DbFieldType::Long,
9121                (EventMask::LOG | EventMask::ALARM).bits(),
9122            )
9123            .expect("S1 subscriber");
9124        let mask_of = |snap: &ProcessSnapshot, f: &str| {
9125            snap.iter().find(|(n, _, _)| n == f).map(|(_, _, m)| *m)
9126        };
9127
9128        // Cycle 1 clears the record's initial UDF/INVALID alarm, which is itself
9129        // a transition; cycle 2 is the quiet baseline. The sweep is then DBE_LOG
9130        // alone — in particular NOT DBE_VALUE, since the counts have not moved.
9131        let _ = inst.process_local().unwrap();
9132        let (snap1, _) = inst.process_local().unwrap();
9133        assert_eq!(
9134            mask_of(&snap1, "S1").unwrap().bits(),
9135            EventMask::LOG.bits(),
9136            "no alarm transition → the sweep is DBE_LOG only"
9137        );
9138
9139        // The alarm fires: severity moves NO_ALARM → INVALID, so this cycle's
9140        // posts carry DBE_ALARM. C posts DBE_LOG here and the alarm subscriber
9141        // learns nothing.
9142        if let Some(r) = inst
9143            .record
9144            .as_any_mut()
9145            .and_then(|a| a.downcast_mut::<AlarmingLogSweepRecord>())
9146        {
9147            r.alarm = true;
9148        }
9149        let (snap2, _) = inst.process_local().unwrap();
9150        assert_eq!(
9151            mask_of(&snap2, "S1").unwrap().bits(),
9152            (EventMask::LOG | EventMask::ALARM).bits(),
9153            "the severity transition must reach the swept field (C drops it)"
9154        );
9155
9156        // Severity stays INVALID: no transition, so no alarm bit — the sweep is
9157        // DBE_LOG again.
9158        let (snap3, _) = inst.process_local().unwrap();
9159        assert_eq!(
9160            mask_of(&snap3, "S1").unwrap().bits(),
9161            EventMask::LOG.bits(),
9162            "a steady severity is not a transition"
9163        );
9164    }
9165
9166    /// Stub record that simulates a record whose process() mutates an
9167    /// internal metadata field. Used to verify that the
9168    /// `Record::took_metadata_change()` hook actually triggers cache
9169    /// invalidation in `process_local()`.
9170    struct MutatingMetaRecord {
9171        val: f64,
9172        egu: String,
9173        took_change: bool,
9174    }
9175
9176    impl Record for MutatingMetaRecord {
9177        fn record_type(&self) -> &'static str {
9178            "ai" // pretend to be ai so populate_display_info populates EGU
9179        }
9180        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
9181            // Simulate dynamic metadata change inside processing
9182            self.egu = "kV".into();
9183            self.took_change = true;
9184            Ok(crate::server::record::ProcessOutcome::complete())
9185        }
9186        fn get_field(&self, name: &str) -> Option<EpicsValue> {
9187            match name {
9188                "VAL" => Some(EpicsValue::Double(self.val)),
9189                "EGU" => Some(EpicsValue::String(self.egu.clone().into())),
9190                "PREC" => Some(EpicsValue::Short(0)),
9191                "HOPR" => Some(EpicsValue::Double(0.0)),
9192                "LOPR" => Some(EpicsValue::Double(0.0)),
9193                _ => None,
9194            }
9195        }
9196        fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
9197            match (name, value) {
9198                ("VAL", EpicsValue::Double(v)) => {
9199                    self.val = v;
9200                    Ok(())
9201                }
9202                ("EGU", EpicsValue::String(s)) => {
9203                    self.egu = s.as_str_lossy().into_owned();
9204                    Ok(())
9205                }
9206                _ => Err(CaError::FieldNotFound(name.to_string())),
9207            }
9208        }
9209        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
9210            &[]
9211        }
9212        fn took_metadata_change(&mut self) -> bool {
9213            let was = self.took_change;
9214            self.took_change = false; // reset after reporting
9215            was
9216        }
9217    }
9218
9219    #[test]
9220    fn process_local_invalidates_cache_on_took_metadata_change() {
9221        let mut inst = RecordInstance::new(
9222            "MUT".to_string(),
9223            MutatingMetaRecord {
9224                val: 1.0,
9225                egu: "V".to_string(),
9226                took_change: false,
9227            },
9228        );
9229
9230        // Build the cache once with the original EGU
9231        let snap1 = inst.snapshot_for_field("VAL").unwrap();
9232        assert_eq!(snap1.display.unwrap().units, "V");
9233        assert!(inst.metadata_cache.lock().unwrap().is_some());
9234
9235        // Run process_local — the stub record sets took_change inside process()
9236        let _ = inst.process_local();
9237
9238        // Cache should now be invalidated (took_metadata_change returned true)
9239        assert!(
9240            inst.metadata_cache.lock().unwrap().is_none(),
9241            "process_local should invalidate cache when took_metadata_change is true"
9242        );
9243
9244        // Next snapshot picks up the new EGU
9245        let snap2 = inst.snapshot_for_field("VAL").unwrap();
9246        assert_eq!(snap2.display.unwrap().units, "kV");
9247    }
9248
9249    /// Stub record that does NOT mutate metadata fields. Verifies the
9250    /// default `took_metadata_change` returns false and the cache stays.
9251    struct StableMetaRecord {
9252        val: f64,
9253    }
9254    impl Record for StableMetaRecord {
9255        fn record_type(&self) -> &'static str {
9256            "ai"
9257        }
9258        fn process(&mut self) -> CaResult<crate::server::record::ProcessOutcome> {
9259            self.val += 1.0;
9260            Ok(crate::server::record::ProcessOutcome::complete())
9261        }
9262        fn get_field(&self, name: &str) -> Option<EpicsValue> {
9263            match name {
9264                "VAL" => Some(EpicsValue::Double(self.val)),
9265                "EGU" => Some(EpicsValue::String("V".into())),
9266                "PREC" => Some(EpicsValue::Short(0)),
9267                "HOPR" => Some(EpicsValue::Double(0.0)),
9268                "LOPR" => Some(EpicsValue::Double(0.0)),
9269                _ => None,
9270            }
9271        }
9272        fn put_field(&mut self, _: &str, _: EpicsValue) -> CaResult<()> {
9273            Ok(())
9274        }
9275        fn declared_fields(&self) -> &'static [crate::server::record::FieldDesc] {
9276            &[]
9277        }
9278        // took_metadata_change uses default impl (returns false)
9279    }
9280
9281    #[test]
9282    fn process_local_keeps_cache_when_no_metadata_change() {
9283        let mut inst = RecordInstance::new("STABLE".to_string(), StableMetaRecord { val: 0.0 });
9284
9285        let _ = inst.snapshot_for_field("VAL");
9286        assert!(inst.metadata_cache.lock().unwrap().is_some());
9287
9288        // Run process_local several times — cache should remain intact
9289        let _ = inst.process_local();
9290        assert!(inst.metadata_cache.lock().unwrap().is_some());
9291        let _ = inst.process_local();
9292        assert!(inst.metadata_cache.lock().unwrap().is_some());
9293        let _ = inst.process_local();
9294        assert!(inst.metadata_cache.lock().unwrap().is_some());
9295    }
9296
9297    // ── Regression: DBE_PROPERTY event delivery boundaries ──────────────
9298
9299    /// Subscribe `VAL` for PROPERTY, put `field`, run the post gate, and report
9300    /// whether an event was delivered. `put` must differ from the field's
9301    /// current value or the change-detection suppresses the post either way.
9302    fn property_event_on_put<R: Record>(rec: R, field: &str, put: EpicsValue) -> bool {
9303        use crate::server::recgbl::EventMask;
9304        let mut inst = RecordInstance::new("PROPGATE".to_string(), rec);
9305        let mut rx = inst
9306            .add_subscriber(
9307                "VAL",
9308                1,
9309                crate::types::DbFieldType::Double,
9310                EventMask::PROPERTY.bits(),
9311            )
9312            .expect("subscriber added");
9313        let prev = inst.record.get_field(field);
9314        assert_ne!(prev.as_ref(), Some(&put), "{field}: put must be a change");
9315        inst.record.put_field(field, put).expect("put accepted");
9316        inst.notify_field_written_if_changed(field, prev.as_ref(), LinkBacking::none());
9317        rx.try_recv().is_ok()
9318    }
9319
9320    /// Boundary: `prop(YES)`. `histogramRecord.dbd.pod` declares ULIM
9321    /// `special(SPC_RESET)` + `prop(YES)`, so C's `dbPut` sets
9322    /// `propertyUpdate` (dbAccess.c:1330) and posts DBE_PROPERTY. ULIM is
9323    /// nobody's cache source — it reaches the wire through the live
9324    /// `apply_field_metadata_override` — so a set keyed on cache sources
9325    /// cannot answer this, which is why the gate reads the declaration.
9326    #[test]
9327    fn prop_yes_field_posts_property_event() {
9328        use crate::server::records::histogram::HistogramRecord;
9329        assert!(
9330            property_event_on_put(
9331                HistogramRecord::default(),
9332                "ULIM",
9333                EpicsValue::Double(100.0)
9334            ),
9335            "histogram.ULIM is prop(YES) — a changed put must post DBE_PROPERTY"
9336        );
9337    }
9338
9339    /// Boundary: `pp(TRUE)` without `prop`. `biRecord.dbd.pod` declares ZSV
9340    /// `pp(TRUE)`, `menu(menuAlarmSevr)` and no `prop`, so C's
9341    /// `paddr->pfldDes->prop` is 0 and no property event is posted.
9342    #[test]
9343    fn pp_true_without_prop_posts_no_property_event() {
9344        use crate::server::records::bi::BiRecord;
9345        assert!(
9346            !property_event_on_put(BiRecord::default(), "ZSV", EpicsValue::Short(2)),
9347            "bi.ZSV is pp(TRUE) but not prop(YES) — it must post no DBE_PROPERTY"
9348        );
9349    }
9350
9351    /// Boundary 1: metadata field written with a CHANGED value, subscriber
9352    /// mask includes PROPERTY → subscriber receives an event.
9353    /// Mirrors C dbAccess.c:1395-1396 `if (propertyUpdate && !status)`
9354    /// `db_post_events(precord,NULL,DBE_PROPERTY)`.
9355    #[test]
9356    fn r47_property_event_delivered_on_changed_metadata() {
9357        use crate::server::recgbl::EventMask;
9358        let mut inst = ai_instance();
9359        let mut rx = inst
9360            .add_subscriber(
9361                "VAL",
9362                1,
9363                crate::types::DbFieldType::Double,
9364                EventMask::PROPERTY.bits(),
9365            )
9366            .expect("subscriber added");
9367
9368        let prev = inst.record.get_field("EGU"); // "degC"
9369        let _ = inst
9370            .record
9371            .put_field("EGU", EpicsValue::String("kPa".into()));
9372        inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
9373
9374        assert!(
9375            rx.try_recv().is_ok(),
9376            "PROPERTY subscriber must receive event when metadata field changes"
9377        );
9378    }
9379
9380    /// Boundary 2: same metadata field written with the SAME value → NO event.
9381    /// Matches C suppression at dbAccess.c:1379-1383 and the `prev != now` gate.
9382    #[test]
9383    fn r47_no_event_on_unchanged_metadata() {
9384        use crate::server::recgbl::EventMask;
9385        let mut inst = ai_instance();
9386        let mut rx = inst
9387            .add_subscriber(
9388                "VAL",
9389                1,
9390                crate::types::DbFieldType::Double,
9391                EventMask::PROPERTY.bits(),
9392            )
9393            .expect("subscriber added");
9394
9395        let prev = inst.record.get_field("EGU"); // "degC"
9396        // Write the same value — no change
9397        let _ = inst.record.put_field("EGU", prev.clone().unwrap());
9398        inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
9399
9400        assert!(
9401            rx.try_recv().is_err(),
9402            "PROPERTY subscriber must NOT receive event when metadata value is unchanged"
9403        );
9404    }
9405
9406    /// Boundary 3: VALUE-only subscriber (no PROPERTY bit) receives NO event
9407    /// from a metadata write, even when the field value changed.
9408    #[test]
9409    fn r47_value_only_subscriber_no_event_on_metadata_write() {
9410        use crate::server::recgbl::EventMask;
9411        let mut inst = ai_instance();
9412        let mut rx = inst
9413            .add_subscriber(
9414                "VAL",
9415                1,
9416                crate::types::DbFieldType::Double,
9417                EventMask::VALUE.bits(),
9418            )
9419            .expect("subscriber added");
9420
9421        let prev = inst.record.get_field("EGU"); // "degC"
9422        let _ = inst
9423            .record
9424            .put_field("EGU", EpicsValue::String("kPa".into()));
9425        inst.notify_field_written_if_changed("EGU", prev.as_ref(), LinkBacking::none());
9426
9427        assert!(
9428            rx.try_recv().is_err(),
9429            "VALUE-only subscriber must NOT receive event from a metadata write"
9430        );
9431    }
9432
9433    /// Boundary 4 (took_metadata_change path): PROPERTY subscriber receives
9434    /// event after process_local() when the record reports a metadata change.
9435    #[test]
9436    fn r47_process_local_property_event_on_took_metadata_change() {
9437        use crate::server::recgbl::EventMask;
9438        let mut inst = RecordInstance::new(
9439            "MUT2".to_string(),
9440            MutatingMetaRecord {
9441                val: 1.0,
9442                egu: "V".to_string(),
9443                took_change: false,
9444            },
9445        );
9446        let mut rx = inst
9447            .add_subscriber(
9448                "VAL",
9449                1,
9450                crate::types::DbFieldType::Double,
9451                EventMask::PROPERTY.bits(),
9452            )
9453            .expect("subscriber added");
9454
9455        // process() sets took_change = true and updates egu to "kV"
9456        let _ = inst.process_local();
9457
9458        assert!(
9459            rx.try_recv().is_ok(),
9460            "PROPERTY subscriber must receive event after process_local reports took_metadata_change"
9461        );
9462    }
9463}
9464
9465#[cfg(test)]
9466mod aftc_filter_tests {
9467    //! Tests for the shared AFTC alarm-range filter
9468    //! (`records::alarm_filter::aftc_filter`) as driven by
9469    //! `evaluate_analog_alarm`. Pure-function tests: no record instance
9470    //! needed — the filter is a stateless transform of (raw_alarm, aftc,
9471    //! afvl_in, t_last, t_now). Algorithm provenance: 2009 EPICS
9472    //! Codeathon (epics-base `824d37811`), C `aiRecord.c:355-401`.
9473
9474    use crate::server::records::alarm_filter::aftc_filter;
9475    use std::time::{Duration, SystemTime};
9476
9477    fn at(secs: f64) -> SystemTime {
9478        SystemTime::UNIX_EPOCH + Duration::from_secs_f64(secs)
9479    }
9480
9481    #[test]
9482    fn disabled_when_aftc_le_zero() {
9483        // aftc=0 means filter disabled — pass-through.
9484        let (out, afvl) = aftc_filter(2, 0.0, 0.0, at(0.0), at(1.0));
9485        assert_eq!(out, 2);
9486        assert_eq!(afvl, 0.0);
9487    }
9488
9489    #[test]
9490    fn initial_sample_seeds_state_unchanged_alarm() {
9491        // afvl=0 means first sample after enable — alarm passes through
9492        // and accumulator seeds with the raw severity.
9493        let (out, afvl) = aftc_filter(2, 3.0, 0.0, at(0.0), at(0.5));
9494        assert_eq!(out, 2);
9495        assert_eq!(afvl, 2.0);
9496    }
9497
9498    #[test]
9499    fn raises_alarm_only_after_full_time_constant() {
9500        // Single-step heuristic: with `aftc = 3s` and `dt = 0.1s`, alpha
9501        // ≈ 0.967, so a one-shot raw_alarm=2 against afvl=0.0 should not
9502        // produce alarm=2 yet — the filter must hold off until the
9503        // accumulator crosses the threshold.
9504        // Seed with afvl=0.01 (tiny prior, simulating "almost no alarm
9505        // yet"); the filter must keep alarm at 0 after one short tick.
9506        let (out, afvl) = aftc_filter(2, 3.0, 0.01, at(0.0), at(0.1));
9507        assert_eq!(out, 0, "filter should suppress alarm rise on a 0.1s tick");
9508        assert!(afvl > 0.0 && afvl < 2.0);
9509    }
9510
9511    #[test]
9512    fn dt_zero_is_no_op() {
9513        // Two evaluations at the same instant produce no filter advance.
9514        let (out, afvl) = aftc_filter(2, 3.0, 1.5, at(0.0), at(0.0));
9515        assert_eq!(out, 1); // floor(|1.5|) = 1
9516        assert_eq!(afvl, 1.5);
9517    }
9518
9519    #[test]
9520    fn long_steady_state_converges_to_alarm() {
9521        // After many steps with raw_alarm=2 and dt much smaller than aftc,
9522        // the accumulator must converge towards 2.
9523        let aftc = 1.0;
9524        let mut afvl = 0.0;
9525        let mut last = at(0.0);
9526        let mut alarm = 0;
9527        for i in 1..=100 {
9528            let now = at(i as f64 * 0.05);
9529            let (out, new_afvl) = aftc_filter(2, aftc, afvl, last, now);
9530            alarm = out;
9531            afvl = new_afvl;
9532            last = now;
9533        }
9534        assert_eq!(
9535            alarm, 2,
9536            "after 5 s of steady raw=2 with aftc=1 s, output must reach 2"
9537        );
9538        assert!(afvl.abs() >= 1.99 && afvl.abs() <= 2.0);
9539    }
9540}
9541
9542#[cfg(test)]
9543mod check_deadband_tests {
9544    use super::check_deadband;
9545
9546    const NAN: f64 = f64::NAN;
9547    const INF: f64 = f64::INFINITY;
9548
9549    /// C's own test for this function, transcribed:
9550    /// `modules/database/test/ioc/db/recGblCheckDeadbandTest.c` runs all 19
9551    /// (oldval, newval) pairs it can build from {below-band, above-band,
9552    /// unchanged, -0.0, NaN, +inf, -inf} against deadbands -1, 0 and 1.5, and
9553    /// carries the expected mask for each of the 57 cells. Transcribing the
9554    /// table rather than writing cases per story is what keeps the pairs no C
9555    /// branch matches — `(NaN, NaN)` and same-signed infinity — from being
9556    /// dropped, since they are exactly the ones a reader is tempted to fold
9557    /// into "not comparable, so post".
9558    #[test]
9559    fn matches_the_c_recgblcheckdeadband_truth_table() {
9560        // t_SetValues: [oldval, newval]
9561        let pairs: [(f64, f64); 19] = [
9562            (1.0, 2.0),
9563            (0.0, 2.0),
9564            (0.0, 0.0),
9565            (-0.0, 0.0),
9566            (1.0, NAN),
9567            (1.0, INF),
9568            (1.0, -INF),
9569            (NAN, 1.0),
9570            (NAN, NAN),
9571            (NAN, INF),
9572            (NAN, -INF),
9573            (INF, 1.0),
9574            (INF, NAN),
9575            (INF, INF),
9576            (INF, -INF),
9577            (-INF, 1.0),
9578            (-INF, NAN),
9579            (-INF, INF),
9580            (-INF, -INF),
9581        ];
9582        // t_ExpectedUpdates, one row per deadband in t_Deadband.
9583        let expected: [(f64, [bool; 19]); 3] = [
9584            (
9585                -1.0,
9586                [
9587                    true, true, true, true, true, true, true, true, true, true, true, true, true,
9588                    true, true, true, true, true, true,
9589                ],
9590            ),
9591            (
9592                0.0,
9593                [
9594                    true, true, false, false, true, true, true, true, false, true, true, true,
9595                    true, false, true, true, true, true, false,
9596                ],
9597            ),
9598            (
9599                1.5,
9600                [
9601                    false, true, false, false, true, true, true, true, false, true, true, true,
9602                    true, false, true, true, true, true, false,
9603                ],
9604            ),
9605        ];
9606
9607        for (deadband, row) in expected {
9608            for (i, ((oldval, newval), want)) in pairs.iter().zip(row).enumerate() {
9609                assert_eq!(
9610                    check_deadband(*newval, Some(*oldval), deadband),
9611                    want,
9612                    "C pattern {i}: deadband={deadband} oldval={oldval} newval={newval}"
9613                );
9614            }
9615        }
9616    }
9617
9618    /// The port-only state: a record type with no MLST/ALST cell has posted
9619    /// nothing, so the first comparison has no baseline and must fire whatever
9620    /// the value and the deadband are — including the value C's table says
9621    /// would not fire against an equal baseline.
9622    #[test]
9623    fn never_posted_fires_regardless_of_value_or_deadband() {
9624        for value in [0.0, 1.0, NAN, INF, -INF] {
9625            for deadband in [-1.0, 0.0, 1.5] {
9626                assert!(
9627                    check_deadband(value, None, deadband),
9628                    "never-posted must fire: value={value} deadband={deadband}"
9629                );
9630            }
9631        }
9632    }
9633}
9634
9635#[cfg(test)]
9636mod common_field_dbload_tests {
9637    use super::*;
9638    use crate::server::records::ai::AiRecord;
9639
9640    /// The db loader feeds every common field to `put_common_field` as an
9641    /// `EpicsValue::String`. Each numeric/menu common field directive must
9642    /// take effect at load — both the integer form (`field(PHAS, "1")`) and
9643    /// the menu-label form (`field(PRIO, "HIGH")`, `field(DISS, "MAJOR")`) —
9644    /// rather than being silently dropped because the arm matched only its
9645    /// typed variant. One assertion per affected common-field arm.
9646    #[test]
9647    fn db_loaded_string_common_fields_take_effect() {
9648        let mut inst = RecordInstance::new("REC".to_string(), AiRecord::default());
9649        let put = |inst: &mut RecordInstance, f: &str, v: &str| {
9650            inst.put_common_field_db_load(f, EpicsValue::String(v.into()))
9651                .unwrap_or_else(|e| panic!("put_common_field_db_load({f}, {v:?}) failed: {e}"));
9652        };
9653
9654        // Integer-valued directives.
9655        put(&mut inst, "PHAS", "1");
9656        assert_eq!(inst.common.phas, 1, "field(PHAS, \"1\")");
9657        put(&mut inst, "TSE", "-2");
9658        assert_eq!(inst.common.tse, -2, "field(TSE, \"-2\")");
9659        put(&mut inst, "DISV", "1");
9660        assert_eq!(inst.common.disv, 1, "field(DISV, \"1\")");
9661        put(&mut inst, "DISA", "1");
9662        assert_eq!(inst.common.disa, 1, "field(DISA, \"1\")");
9663        put(&mut inst, "LCNT", "3");
9664        assert_eq!(inst.common.lcnt, 3, "field(LCNT, \"3\")");
9665        put(&mut inst, "DISP", "1");
9666        assert!(inst.common.disp != 0, "field(DISP, \"1\")");
9667        put(&mut inst, "UDF", "0");
9668        assert!(inst.common.udf == 0, "field(UDF, \"0\")");
9669
9670        // Menu-label directives (resolved via the one menu converter).
9671        put(&mut inst, "PRIO", "HIGH");
9672        assert_eq!(inst.common.prio, 2, "field(PRIO, \"HIGH\")");
9673        put(&mut inst, "DISS", "MAJOR");
9674        assert_eq!(
9675            inst.common.diss,
9676            AlarmSeverity::Major as i16,
9677            "field(DISS, \"MAJOR\")"
9678        );
9679        put(&mut inst, "UDFS", "NO_ALARM");
9680        assert_eq!(
9681            inst.common.udfs,
9682            AlarmSeverity::NoAlarm as i16,
9683            "field(UDFS, \"NO_ALARM\")"
9684        );
9685        put(&mut inst, "ACKT", "NO");
9686        assert!(!inst.common.ackt, "field(ACKT, \"NO\")");
9687
9688        // Numeric form of a menu field still works (field(PRIO, "0")).
9689        put(&mut inst, "PRIO", "0");
9690        assert_eq!(inst.common.prio, 0, "field(PRIO, \"0\")");
9691
9692        // A String-typed common field is untouched by the coercion.
9693        put(&mut inst, "DESC", "a description");
9694        assert_eq!(inst.common.desc.as_str_lossy().as_ref(), "a description");
9695    }
9696}
9697
9698#[cfg(test)]
9699mod declared_override_tests {
9700    use super::*;
9701    use crate::server::records::dfanout::DfanoutRecord;
9702
9703    /// A field `dfanout`'s `.dbd` DECLARES (HOPR/LOPR/PREC/EGU) but the
9704    /// `DfanoutRecord` struct models no storage for: a put must be ACCEPTED and
9705    /// stored (C `dbPut` writes it into record memory), and a later
9706    /// `resolve_field` must serve the written value — not the `.dbd` initial.
9707    #[test]
9708    fn declared_but_unmodeled_field_put_is_stored_and_served() {
9709        let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
9710
9711        // Untouched: reads its declared default (initial / type-zero), NOT an
9712        // error, and the override store is empty.
9713        assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(0.0)));
9714        assert!(inst.declared_overrides.is_empty());
9715
9716        // DBF_DOUBLE, DBF_SHORT and DBF_STRING declared metadata fields all
9717        // land, coerced to the declared type.
9718        inst.put_common_field("HOPR", EpicsValue::String("10".into()))
9719            .expect("caput dfanout.HOPR 10 must be accepted");
9720        inst.put_common_field("PREC", EpicsValue::String("3".into()))
9721            .expect("caput dfanout.PREC 3 must be accepted");
9722        inst.put_common_field("EGU", EpicsValue::String("volts".into()))
9723            .expect("caput dfanout.EGU volts must be accepted");
9724
9725        assert_eq!(inst.resolve_field("HOPR"), Some(EpicsValue::Double(10.0)));
9726        assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
9727        assert_eq!(
9728            inst.resolve_field("EGU"),
9729            Some(EpicsValue::String("volts".into()))
9730        );
9731        // Case-insensitive key: the lower-case read reaches the same slot.
9732        assert_eq!(inst.resolve_field("hopr"), Some(EpicsValue::Double(10.0)));
9733    }
9734
9735    /// The declared type's C range rules apply through the write-side coercion
9736    /// owner: `caput dfanout.PREC 99999` into a `DBF_SHORT` is REFUSED (C
9737    /// `epicsParseInt16` overflow → `S_db_badField`), and the field keeps its
9738    /// prior value — never wraps to a garbage `Short`.
9739    #[test]
9740    fn declared_override_honors_declared_type_range() {
9741        let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
9742        inst.put_common_field("PREC", EpicsValue::String("3".into()))
9743            .expect("in-range PREC accepted");
9744        assert!(
9745            inst.put_common_field("PREC", EpicsValue::String("99999".into()))
9746                .is_err(),
9747            "PREC 99999 overflows DBF_SHORT and must be refused"
9748        );
9749        assert!(
9750            inst.put_common_field("PREC", EpicsValue::String("abc".into()))
9751                .is_err(),
9752            "non-numeric PREC must be refused"
9753        );
9754        // The refused puts left the accepted value intact.
9755        assert_eq!(inst.resolve_field("PREC"), Some(EpicsValue::Short(3)));
9756    }
9757
9758    /// An UNDECLARED field name is still `FieldNotFound` — the override store
9759    /// captures only fields with a real `dbFldDes`, so a misspelled field is
9760    /// refused exactly as C's `dbNameToAddr` refuses it.
9761    #[test]
9762    fn undeclared_field_is_still_not_found() {
9763        let mut inst = RecordInstance::new("DF".to_string(), DfanoutRecord::default());
9764        assert!(matches!(
9765            inst.put_common_field("XYZZY", EpicsValue::String("1".into())),
9766            Err(CaError::FieldNotFound(_))
9767        ));
9768        assert!(inst.declared_overrides.is_empty());
9769    }
9770
9771    /// A PARTIALLY modeled field — one the record SERVES via `get_field` but
9772    /// has no `put_field` arm for (`calcout.PVAL` → `self.pval`) — must NOT
9773    /// land in the override map: doing so would place the value where
9774    /// `resolve_field` (which reads `get_field` first) never sees it, a silent
9775    /// write loss. The override is only for fields the record serves nothing
9776    /// for; a partially modeled field's put is the record's own concern.
9777    #[test]
9778    fn partially_modeled_field_is_not_captured_by_override() {
9779        use crate::server::records::calcout::CalcoutRecord;
9780        let mut inst = RecordInstance::new("CO".to_string(), CalcoutRecord::default());
9781        // PVAL is served by the record (its own storage), so it is not stored
9782        // in the override map; the map stays empty and no ghost cell shadows
9783        // the record's read.
9784        let _ = inst.put_common_field("PVAL", EpicsValue::String("1".into()));
9785        assert!(
9786            inst.declared_overrides.is_empty(),
9787            "a field the record serves via get_field must not enter the override map"
9788        );
9789    }
9790}
9791
9792#[cfg(test)]
9793mod pact_exit_tests {
9794    use super::*;
9795    use crate::server::records::ai::AiRecord;
9796
9797    fn instance() -> RecordInstance {
9798        RecordInstance::new("PACT:REC".to_string(), AiRecord::new(0.0))
9799    }
9800
9801    /// The two boundary values of the bit `leave_pact` mints. It is minted
9802    /// under the `&mut self` the caller already holds, which is what lets
9803    /// `PvDatabase::apply_pact_exit` take no record lock and so be safe to
9804    /// call from a `Drop` that still has a `rec.write()` alive in scope.
9805    #[test]
9806    fn leave_pact_reports_an_empty_restart_queue_as_nothing_to_do() {
9807        let mut inst = instance();
9808        inst.enter_pact();
9809        assert!(!inst.leave_pact().restart_pending());
9810    }
9811
9812    #[test]
9813    fn leave_pact_reports_a_queued_notify_so_the_tail_drains_it() {
9814        let mut inst = instance();
9815        inst.enter_pact();
9816        let (tx, _rx) = crate::runtime::sync::oneshot::channel();
9817        inst.queue_notify_put(DeferredNotify::Process { completion: tx });
9818        assert!(inst.leave_pact().restart_pending());
9819    }
9820}
9821
9822#[cfg(test)]
9823mod declaration_gate_tests {
9824    use super::*;
9825    use crate::server::records::{bi::BiRecord, calc::CalcRecord, histogram::HistogramRecord};
9826
9827    fn inst(name: &str, record: Box<dyn Record>) -> RecordInstance {
9828        RecordInstance::new_boxed(name.to_string(), record)
9829    }
9830
9831    /// The boundary is DECLARED / NOT DECLARED, one case each way per
9832    /// storage that this port keeps for every record but C keeps per record
9833    /// type. Every expectation measured on `softIoc` R7.0.10-146 with
9834    /// `record(calc,"C:GOOD")`, `record(bi,"B:ONE")`,
9835    /// `record(histogram,"H:ONE")`:
9836    ///
9837    /// ```text
9838    /// dbgf C:GOOD.OUT      PV 'C:GOOD.OUT' not found
9839    /// dbgf C:GOOD.INP      PV 'C:GOOD.INP' not found
9840    /// dbgf C:GOOD.SSCN     PV 'C:GOOD.SSCN' not found
9841    /// dbgf C:GOOD.OLDSIMM  PV 'C:GOOD.OLDSIMM' not found
9842    /// dbgf C:GOOD.NOSUCH   PV 'C:GOOD.NOSUCH' not found
9843    /// dbgf C:GOOD.RTYP     DBF_STRING: "calc"
9844    /// dbgf C:GOOD.NAME     DBF_STRING: "C:GOOD"
9845    /// dbgf B:ONE.INP       DBF_STRING: ""
9846    /// dbgf B:ONE.OUT       PV 'B:ONE.OUT' not found
9847    /// dbgf B:ONE.HIHI      PV 'B:ONE.HIHI' not found
9848    /// dbgf H:ONE.INP       PV 'H:ONE.INP' not found
9849    /// ```
9850    #[test]
9851    fn a_field_resolves_exactly_where_the_record_type_declares_it() {
9852        let calc = inst("C:GOOD", Box::new(CalcRecord::default()));
9853        for undeclared in ["OUT", "INP", "SSCN", "OLDSIMM", "NOSUCH"] {
9854            assert_eq!(calc.resolve_field(undeclared), None, "calc.{undeclared}");
9855        }
9856        // Undeclared, but C's `dbNameToAddr` falls through to the record
9857        // type's attributes for it.
9858        assert_eq!(
9859            calc.resolve_field("RTYP"),
9860            Some(EpicsValue::String("calc".into()))
9861        );
9862        // Declared by dbCommon, so it stays readable.
9863        assert_eq!(
9864            calc.resolve_field("NAME"),
9865            Some(EpicsValue::String("C:GOOD".into()))
9866        );
9867        assert!(calc.resolve_field("CALC").is_some());
9868
9869        // The same storage, on a record type that DOES declare INP and does
9870        // not declare OUT or the analog-alarm ladder.
9871        let bi = inst("B:ONE", Box::new(BiRecord::default()));
9872        assert_eq!(bi.resolve_field("INP"), Some(EpicsValue::String("".into())));
9873        assert_eq!(bi.resolve_field("OUT"), None);
9874        assert_eq!(bi.resolve_field("HIHI"), None);
9875
9876        // `histogramRecord.dbd` declares SVL, not INP — the case
9877        // `Record::declares_inp_link` was written for, now answered by the
9878        // declaration itself.
9879        let histogram = inst("H:ONE", Box::new(HistogramRecord::default()));
9880        assert_eq!(histogram.resolve_field("INP"), None);
9881        assert!(histogram.resolve_field("SVL").is_some());
9882    }
9883
9884    /// The channel-existence side must agree with the read side, or a client
9885    /// gets a SEARCH answered and a CREATE refused (or worse, the reverse).
9886    /// `resolve_string_view_field` is the `$` long-string route to the same
9887    /// funnel.
9888    #[test]
9889    fn the_long_string_view_is_gated_by_the_same_declaration() {
9890        let calc = inst("C:GOOD", Box::new(CalcRecord::default()));
9891        assert_eq!(calc.resolve_string_view_field("OUT"), None);
9892        assert!(calc.resolve_string_view_field("CALC").is_some());
9893    }
9894
9895    /// `declares_simulation` replaced `resolve_field("SIMM").is_some()` as the
9896    /// gate on `PvDatabase::check_simulation_mode`, so it must answer the same
9897    /// thing for every record type the port carries — a type that gained the
9898    /// simulation block while the flag said otherwise would silently stop
9899    /// simulating, and one that lost it would resolve SIML/SIOL by name on
9900    /// every process cycle again.
9901    #[test]
9902    fn the_simulation_gate_agrees_with_resolving_simm_on_every_record_type() {
9903        use crate::server::record::dbd_generated::RECORD_TYPE_ORDER;
9904        let mut declared = 0usize;
9905        for rtype in RECORD_TYPE_ORDER {
9906            let Ok(record) = crate::server::db_loader::create_record(rtype) else {
9907                continue;
9908            };
9909            let instance = inst(&format!("SIM:{rtype}"), record);
9910            assert_eq!(
9911                instance.declares_simulation(),
9912                instance.resolve_field("SIMM").is_some(),
9913                "{rtype}"
9914            );
9915            declared += usize::from(instance.declares_simulation());
9916        }
9917        // Both arms have to be populated or the equality above is vacuous.
9918        assert!(declared > 0, "no record type declared a simulation block");
9919        assert!(
9920            declared < RECORD_TYPE_ORDER.len(),
9921            "every record type declared one"
9922        );
9923    }
9924
9925    /// The two arms by name, so the gate's meaning is readable without
9926    /// running the sweep above: `ai` is `readValue`-bearing, `calc` has no
9927    /// `readValue` in C at all.
9928    /// The slot index is what lets the cycle take a pre-read link's text
9929    /// without searching for it by name, so it has to name the same link the
9930    /// search would have found — on every record type, not just the calc
9931    /// class it was measured on.
9932    #[test]
9933    fn the_metadata_link_slots_name_the_same_links_a_search_would_find() {
9934        use crate::server::record::dbd_generated::RECORD_TYPE_ORDER;
9935        let mut with_slots = 0usize;
9936        for rtype in RECORD_TYPE_ORDER {
9937            let Ok(record) = crate::server::db_loader::create_record(rtype) else {
9938                continue;
9939            };
9940            let instance = inst(&format!("S:{rtype}"), record);
9941            let links = instance.link_backed_metadata_links();
9942            let slots = instance.link_backed_metadata_input_slots();
9943            assert_eq!(links.len(), slots.len(), "{rtype}");
9944            for (lf, slot) in links.iter().zip(slots) {
9945                let multi = instance.record.multi_input_links();
9946                assert_eq!(
9947                    *slot,
9948                    multi.iter().position(|(mf, _)| mf == lf),
9949                    "{rtype}.{lf}"
9950                );
9951                if let Some(i) = *slot {
9952                    assert_eq!(multi[i].0, lf.as_str(), "{rtype}.{lf}");
9953                    with_slots += 1;
9954                }
9955            }
9956        }
9957        assert!(
9958            with_slots > 0,
9959            "no record type maps a metadata link onto its multi-input list"
9960        );
9961    }
9962
9963    /// `link_text` replaced a materialise-then-test shape at six link-read
9964    /// sites, so what it owes them is that boundary: a link that reads empty
9965    /// is `None`, and every other answer is the text itself.
9966    #[test]
9967    fn link_text_answers_none_exactly_where_a_link_reads_empty() {
9968        use crate::server::record::dbd_generated::RECORD_TYPE_ORDER;
9969        for rtype in RECORD_TYPE_ORDER {
9970            let Ok(record) = crate::server::db_loader::create_record(rtype) else {
9971                continue;
9972            };
9973            let instance = inst(&format!("L:{rtype}"), record);
9974            let declared: Vec<&'static str> = instance
9975                .record
9976                .multi_input_links()
9977                .iter()
9978                .chain(instance.record.string_input_links())
9979                .map(|(lf, _)| *lf)
9980                .collect();
9981            for lf in declared {
9982                let materialised = match instance.record.get_field(lf) {
9983                    Some(EpicsValue::String(text)) => text.as_str_lossy().into_owned(),
9984                    _ => String::new(),
9985                };
9986                assert_eq!(
9987                    instance.link_text(lf),
9988                    (!materialised.is_empty()).then_some(materialised),
9989                    "{rtype}.{lf}"
9990                );
9991            }
9992        }
9993    }
9994
9995    /// A record that lends a link text must lend the SAME text its
9996    /// `get_field` materialises — the two paths are one answer, and a slot
9997    /// mapping that drifts by one would otherwise hand the cycle a
9998    /// neighbouring link's target.
9999    #[test]
10000    fn a_lent_link_text_is_the_one_get_field_materialises() {
10001        use crate::server::record::dbd_generated::RECORD_TYPE_ORDER;
10002        for rtype in RECORD_TYPE_ORDER {
10003            let Ok(mut record) = crate::server::db_loader::create_record(rtype) else {
10004                continue;
10005            };
10006            let declared: Vec<&'static str> = record
10007                .multi_input_links()
10008                .iter()
10009                .chain(record.string_input_links())
10010                .map(|(lf, _)| *lf)
10011                .collect();
10012            // Distinct per slot, so a mapping off by one cannot agree.
10013            for (slot, lf) in declared.iter().enumerate() {
10014                let text = format!("SRC:{rtype}:{slot}.VAL CP");
10015                let _ = record.put_field(lf, EpicsValue::String(text.as_str().into()));
10016            }
10017            for lf in &declared {
10018                let Some(lent) = record.link_text_ref(lf) else {
10019                    continue;
10020                };
10021                let materialised = match record.get_field(lf) {
10022                    Some(EpicsValue::String(text)) => text.as_str_lossy().into_owned(),
10023                    _ => String::new(),
10024                };
10025                assert_eq!(
10026                    lent, materialised,
10027                    "{rtype}.{lf} lent a text its get_field does not hold"
10028                );
10029            }
10030        }
10031    }
10032
10033    #[test]
10034    fn link_text_reads_a_wired_link_and_refuses_what_is_not_one() {
10035        let mut instance = inst("C:GOOD", Box::new(CalcRecord::default()));
10036        assert_eq!(instance.link_text("INPA"), None, "an unwired INPA");
10037        instance
10038            .record
10039            .put_field("INPA", EpicsValue::String("SRC:ONE.VAL CP".into()))
10040            .expect("INPA takes a link string");
10041        assert_eq!(
10042            instance.link_text("INPA").as_deref(),
10043            Some("SRC:ONE.VAL CP")
10044        );
10045        // A numeric field is not a link, and neither is one the type does not
10046        // declare: both have to read as "no link", not as an empty one.
10047        assert_eq!(instance.link_text("A"), None);
10048        assert_eq!(instance.link_text("NOSUCH"), None);
10049    }
10050
10051    #[test]
10052    fn a_record_type_without_readvalue_declares_no_simulation_block() {
10053        assert!(!inst("C:GOOD", Box::new(CalcRecord::default())).declares_simulation());
10054        assert!(inst("B:ONE", Box::new(BiRecord::default())).declares_simulation());
10055    }
10056}
10057
10058#[cfg(test)]
10059mod link_field_rendering_tests {
10060    use super::render_link_field;
10061
10062    /// One case per boundary of C's `dbGetString` link switch
10063    /// (`dbStaticLib.c:1906-2050`), not one per scenario: the modifier chain has
10064    /// a defaulted arm, and the three field types mask it differently, so the
10065    /// cases that matter are the mask edges rather than a walk of realistic
10066    /// links.
10067    #[test]
10068    fn a_link_field_renders_with_cs_parsed_modifiers() {
10069        use crate::types::DbfLinkClass::{FwdLink, InLink, OutLink};
10070        for (class, text, want) in [
10071            // An input link's absent modifiers are C's defaults, not absences.
10072            (InLink, "L:B", "L:B NPP NMS"),
10073            (InLink, "L:B MS", "L:B NPP MS"),
10074            (InLink, "L:B PP MS", "L:B PP MS"),
10075            (InLink, "L:B MSI", "L:B NPP MSI"),
10076            (InLink, "L:B MSS", "L:B NPP MSS"),
10077            // The process class is one assignment down C's chain, so ` CA`
10078            // appears only when no PP/CP/CPP won it.
10079            (InLink, "L:B CA", "L:B CA NMS"),
10080            (InLink, "L:B CP", "L:B CP NMS"),
10081            (InLink, "L:B CPP", "L:B CPP NMS"),
10082            (InLink, "L:B CP CA", "L:B CA NMS"),
10083            (InLink, "L:B CP NPP", "L:B NPP NMS"),
10084            // The target is the slice before the first space, verbatim: a
10085            // `.FIELD` survives where a rebuild through `channel_name` would
10086            // drop an explicit `.VAL`.
10087            (InLink, "L:B.SEVR MS", "L:B.SEVR NPP MS"),
10088            (InLink, "L:B.VAL", "L:B.VAL NPP NMS"),
10089            // `DBF_OUTLINK` masks CP/CPP off before the render sees it.
10090            (OutLink, "L:B", "L:B NPP NMS"),
10091            (OutLink, "L:B CPP MS", "L:B NPP MS"),
10092            // `DBF_FWDLINK` keeps only CA and prints no severity switch at all.
10093            (FwdLink, "L:B", "L:B"),
10094            (FwdLink, "L:B CA", "L:B CA"),
10095            (FwdLink, "L:B PP MS", "L:B"),
10096            // Everything that is not a PV link is its own text.
10097            (InLink, "12.5", "12.5"),
10098            (InLink, "", ""),
10099            (InLink, "[1, 2, 3]", "[1, 2, 3]"),
10100            (InLink, "@dev p1 p2", "@dev p1 p2"),
10101            (InLink, "{\"const\":1}", "{\"const\":1}"),
10102        ] {
10103            assert_eq!(render_link_field(class, text), want, "{class:?} {text:?}");
10104        }
10105    }
10106}
10107
10108#[cfg(test)]
10109mod unanswerable_notify_tests {
10110    use super::*;
10111    use crate::server::records::calc::CalcRecord;
10112
10113    fn rec(name: &str) -> RecordInstance {
10114        RecordInstance::new(name.into(), CalcRecord::default())
10115    }
10116
10117    /// The ordinary case: the client is still waiting, so the slot is its own.
10118    /// C only reaches `dbNotifyCancel` from a teardown.
10119    #[test]
10120    fn a_waiting_client_keeps_the_slot() {
10121        let mut r = rec("A");
10122        let (tx, _rx) = crate::runtime::sync::oneshot::channel();
10123        r.install_or_queue_notify(tx).expect("slot was free");
10124        assert!(r.unanswerable_notify().is_none());
10125        assert!(r.has_notify(), "a live put-callback must not be cancelled");
10126    }
10127
10128    /// C `rsrvFreePutNotify` (`camessage.c:1630-1638`): the client went away
10129    /// with its put-callback still busy, so the notify leaves the record.
10130    #[test]
10131    fn a_departed_client_releases_the_slot() {
10132        let mut r = rec("A");
10133        let (tx, rx) = crate::runtime::sync::oneshot::channel();
10134        r.install_or_queue_notify(tx).expect("slot was free");
10135        drop(rx);
10136        let dead = r.unanswerable_notify().expect("nobody can answer it");
10137        assert!(r.release_notify(&dead));
10138        assert!(
10139            !r.has_notify(),
10140            "the record must be free for the next put-notify"
10141        );
10142    }
10143
10144    /// A notify that COMPLETED is not unanswerable — it answered. The sender is
10145    /// spent, so a receiver dropped afterwards says nothing about the slot.
10146    #[test]
10147    fn a_completed_notify_is_not_cancelled() {
10148        let mut r = rec("A");
10149        let (tx, rx) = crate::runtime::sync::oneshot::channel();
10150        let set = r.install_or_queue_notify(tx).expect("slot was free");
10151        set.leave();
10152        drop(rx);
10153        assert!(r.unanswerable_notify().is_none());
10154    }
10155
10156    /// The set names every record that holds it, entry and `dbNotifyAdd`
10157    /// target alike — C `pnotifyPvt->waitList` (`dbNotify.c:227`/`:498`), which
10158    /// `dbNotifyCancel` walks at `:428`.
10159    #[test]
10160    fn the_set_names_the_entry_and_every_chain_target() {
10161        let mut entry = rec("A");
10162        let mut target = rec("B");
10163        let (tx, _rx) = crate::runtime::sync::oneshot::channel();
10164        let set = entry.install_or_queue_notify(tx).expect("slot was free");
10165        target.join_put_notify(Some(&set));
10166        let members: Vec<String> = set
10167            .joined_records()
10168            .into_iter()
10169            .map(|n| n.to_string())
10170            .collect();
10171        assert_eq!(members, vec!["A".to_string(), "B".to_string()]);
10172    }
10173
10174    /// A chain target releases too. It holds the same wait-set through
10175    /// `dbNotifyAdd`, and the record whose cycle never ends is exactly the one
10176    /// that keeps it — a `busy` FLNK target left at VAL=1 declines its own
10177    /// `recGblFwdLink` by contract, so nothing else would ever free it. C
10178    /// empties the whole wait list (`dbNotify.c:428-430`) before it looks at
10179    /// the entry at all.
10180    #[test]
10181    fn a_chain_target_holding_the_same_set_releases_too() {
10182        let mut entry = rec("A");
10183        let mut target = rec("B");
10184        let (tx, rx) = crate::runtime::sync::oneshot::channel();
10185        let set = entry.install_or_queue_notify(tx).expect("slot was free");
10186        target.join_put_notify(Some(&set));
10187        drop(rx);
10188        let dead = target.unanswerable_notify().expect("nobody can answer it");
10189        assert!(Arc::ptr_eq(&dead, &set));
10190        assert!(target.release_notify(&dead));
10191        assert!(!target.has_notify(), "B must be free for the next put");
10192        assert!(entry.release_notify(&dead));
10193        assert!(!entry.has_notify());
10194    }
10195
10196    /// The record's own slot is the authority, not the name the sweep carries:
10197    /// a member that has since completed and taken a LIVE notify keeps it.
10198    /// C re-tests `precord->ppn` for the same reason (`restartCheck`'s
10199    /// `assert(precord->ppn)`, `dbNotify.c:154`).
10200    #[test]
10201    fn a_member_that_moved_on_to_a_live_notify_is_left_alone() {
10202        let mut entry = rec("A");
10203        let mut target = rec("B");
10204        let (tx, rx) = crate::runtime::sync::oneshot::channel();
10205        let dead = entry.install_or_queue_notify(tx).expect("slot was free");
10206        target.join_put_notify(Some(&dead));
10207        drop(rx);
10208
10209        // B finished its contribution and a fresh client took it.
10210        assert!(target.release_notify(&dead));
10211        let (tx2, _rx2) = crate::runtime::sync::oneshot::channel();
10212        target.install_or_queue_notify(tx2).expect("slot was free");
10213
10214        assert!(
10215            !target.release_notify(&dead),
10216            "the stale name must not evict the live notify"
10217        );
10218        assert!(target.has_notify());
10219    }
10220}
10221
10222#[cfg(test)]
10223mod forced_secondary_post_tests {
10224    use super::*;
10225    use crate::server::recgbl::EventMask;
10226    use crate::server::records::ao::AoRecord;
10227    use crate::types::DbFieldType;
10228
10229    /// An ao whose output moved this cycle (`omod`) with `oraw != rval`,
10230    /// the state C's `aoRecord.c:541` posts RVAL from.
10231    fn ao_with_moved_rval(rval: i32) -> RecordInstance {
10232        let mut rec = AoRecord::default();
10233        rec.rval = rval;
10234        rec.omod = true;
10235        RecordInstance::new("AO:FORCED".to_string(), rec)
10236    }
10237
10238    /// One guarded monitor cycle; the fields it posted, in order.
10239    fn guarded_cycle(inst: &mut RecordInstance) -> Vec<String> {
10240        let mut snapshot = ProcessSnapshot::new();
10241        inst.collect_subscriber_posts(&mut snapshot, EventMask::VALUE, EventMask::NONE, true);
10242        snapshot
10243            .iter()
10244            .map(|(field, _, _)| field.to_string())
10245            .collect()
10246    }
10247
10248    /// Boundary `oraw != rval` with a subscriber: RVAL posts once and the
10249    /// old copy advances; boundary `oraw == rval` on the next guarded
10250    /// cycle: nothing.
10251    #[test]
10252    fn a_changed_rval_posts_once_then_not_again() {
10253        let mut inst = ao_with_moved_rval(7);
10254        let _rx = inst
10255            .add_subscriber("RVAL", 1, DbFieldType::Long, EventMask::VALUE.bits())
10256            .expect("RVAL subscriber");
10257        let posted = guarded_cycle(&mut inst);
10258        assert_eq!(
10259            posted.iter().filter(|f| *f == "RVAL").count(),
10260            1,
10261            "{posted:?}"
10262        );
10263        assert_eq!(inst.record.get_field("ORAW"), Some(EpicsValue::Long(7)));
10264        let posted = guarded_cycle(&mut inst);
10265        assert!(!posted.iter().any(|f| f == "RVAL"), "{posted:?}");
10266    }
10267
10268    /// Boundary no subscriber: the old copy still advances, and nothing is
10269    /// posted — the bookkeeping must not depend on who is watching.
10270    #[test]
10271    fn a_changed_rval_advances_oraw_with_no_subscriber() {
10272        let mut inst = ao_with_moved_rval(7);
10273        assert!(guarded_cycle(&mut inst).is_empty());
10274        assert_eq!(inst.record.get_field("ORAW"), Some(EpicsValue::Long(7)));
10275    }
10276}