Designing a Feature
A guided tour of the callp call-processing software, ending in a recipe for building your own feature.
This document is for a developer who knows C++ but has never seen this codebase (and maybe has never seen telephony software). It walks through the concepts in dependency order: first the machine you're programming, then how a call flows through it, then the anatomy of a real feature, and finally the step-by-step recipe — with the traps marked.
All code below is quoted verbatim from the tree, with file:line references
current as of this writing. When the code and this document disagree, the code
wins — then please fix this document.
Companion documents:
ARCHITECTURE.txt— one-screen glossary of the terms used here.PLAN.md§ "Key architecture facts" — dense, verified engine facts.TESTS.txt— the prose catalog of every regression test.README.txt— the historical design journal (why things are the way they are).JOURNAL.md— the execution journal (what changed, what surprised us).
Part I — The machine you're programming
1. Orientation
This is call-processing software for a Mitel SX-20 PABX — a small 1980s office phone switch — with the original processor card replaced by a Raspberry Pi Pico. The software drives the real crossbar, line, trunk, and tone hardware. Full configuration: 72 lines, 8 trunks, 12 junctors, 2 DTMF receivers.
There are two builds from the same source:
| Build | Driver | Target | How |
|---|---|---|---|
| Hardware | CMakeLists.txt (pico-sdk) |
Pi Pico in the SX-20 | make (top target), make burn |
| Simulator | Makefile, -DSIMULATOR, clang++ |
Native Linux binary ./callp |
make sim |
The simulator replaces only the hardware register layer (sx20_read/
sx20_write) and the clock; everything else — every state machine, every
feature — is the exact same code. You will do essentially all development
against the simulator, where 24 hours of PBX time runs in about 90 seconds.
What is a "feature"? In this codebase a feature is a Processor — a small state machine attached to a call. The name and concept come from the DMS-100's feature processing environment; the header explains it best (processor.h:10-33):
/*
* Call Processor
*
* The "call processor" is inspired by the DMS-100 FPE's "feature" -- a module that handles
* call features (like 3-way calling, transfer, call forward, and so on).
*
* Our call processor, however, is more generic in that it can also handle regular "call
* processing" functions (like the lifecycle of a regular line).
* This effectively integrates "features" and "call processing functions", hence "processor."
*
* A processor has access to the CCB, maintains its own context, and is handed events by the
* CCB processing main loop.
*
* Some processors operate singly, and others operate in a cooperative primary/secondary
* arrangement with one or more instances of themselves (e.g., P_RingGroup has a primary
* P_RingGroupP that rings the group, and secondaries (P_RingGroupS) are installed to monitor
* progress and effect call abandonment).
*
* A processor may consume the event, or may instead choose to have processing performed by the
* next processor in the chain.
*
* When a processor is no longer required, it sets its "active" status to false. Its destructor
* will be called by the CCB processing, and the processor will be removed from the CCB's list.
*/
So "designing a feature" means: writing a new P_Something state machine,
teaching the translator which dialed digits invoke it, and proving it works in
the simulator. Everything in Part I builds toward understanding that sentence.
One style note before you read any code: this is deliberately "C with
classes". Static arrays, no exceptions, no smart pointers, almost no STL
(one list, string in a few places). Match that idiom — don't "modernize"
as you go.
2. The superloop
There is no operating system, no threads, no interrupts, and no event queue. The entire system is one polling loop (main.cc:39-54):
for (uint64_t loopcount = 0;; loopcount++) {
now = time_us_64 ();
scan_lines ();
...
scan_trunks ();
...
scan_dtmfs ();
...
scan_cc ();
...
callback_busy_tick ();
scan_eeprom ();
...
scan_ringer ();
...
watchdog ();
Three consequences shape everything you will write:
-
Time is a global snapshot.
now(microseconds) is captured once at the top of each pass. Every state machine compares againstnow; nobody reads a fresh clock mid-pass, so all state machines within one pass agree on what time it is. "Set a timer" means: recordsince = nowwhen entering a state, and checknow - since > TIMEOUTon a later pass. There is no timer subsystem, on purpose. -
Everything is a state machine polled every pass. If your code needs to "wait", it doesn't block — it remembers its state and returns, and it gets asked again a few milliseconds later.
-
In the simulator, time is a variable. The loop runs flat-out and each pass advances virtual time by a fixed tick (utils.cc:334-338):
void next_tick (void) { sim_time_base += sim_optk; }Default is 5 ms per tick (
-kto change it). That's why a test canwait(35000)for a 35-second call-forward timeout for free, and why every run is deterministic — same inputs, same tick, same result, every time.
Ordering within a pass matters: endpoints (scan_lines, scan_trunks,
scan_dtmfs) run before scan_cc(). An endpoint that detects something
delivers its event into call control immediately, synchronously, from inside
its own scan — and then scan_cc() does the housekeeping poll afterwards.
We'll trace that in §11.
3. Endpoints — where hardware becomes events
An Endpoint is anything a junctor can be connected to: a line, a trunk, a DTMF receiver/transmitter, the pager. The base class is tiny (endpoint.h:40-57):
struct Endpoint
{
EndpointType type = Endpoint_none; // what kind of end point is it?
uint16_t hwaddr = 0; // base address of hardware
bool present = false; // is the hardware physically present?
CCB *ccb = NULL;
int unit = 0; // unit number (e.g., LEN, TEN, DTMF RX #, etc)
virtual void teardown (void) { ... }
virtual void dump (void) { ... }
};
The interesting endpoint is Line (ep_lines.h:106-147). Line::scan() reads
one hardware register per pass and runs a hook-switch FSM
(LineState_Idle → WaitingForIdleDebounce → OffHook → OnHookCompute → ...,
ep_lines.cc:207-330) that debounces the raw contact into exactly five clean,
semantic events (ep_lines.h:83-89):
// events that the Line FSM generates
enum LineEvent {
LineEvent_Offhook,
LineEvent_Onhook,
LineEvent_Digit,
LineEvent_Flash,
LineEvent_BadDigit,
};
The timing constants that separate a glitch from a rotary pulse from a hookflash from a hangup are all in one place (ep_lines.cc:201-205):
#define DEBOUNCE_TIME ( 50 * MS) // how long you have to be offhook in order for it to register
#define HANGUP_TIME (1000 * MS) // after this amount of time onhook, it's a hangup
#define DIGIT_ONHOOK_TIME ( 35 * MS) // anything over this amount is a digit onhook; typically, pulse dialing on hook time is 62ms
#define INTERDIGIT_TIME ( 100 * MS) // anything over this amount is a digit complete; typically, pulse dialing off hook time is 38ms
#define FLASH_HOOK_TIME ( 250 * MS)
The layering rule: emit() is a private member of Line. Only the
hook-switch FSM may emit events. Call processing never sees bouncing contacts
or pulse counts — it sees "off-hook", "digit '7'", "flash". When you write a
feature you live entirely above this line.
The one-pointer trick
Look back at the base class: every endpoint has a single CCB *ccb pointer.
This one pointer carries an enormous amount of design weight:
-
ccb == NULLmeans the endpoint is idle. -
When an idle line goes off-hook, it has no CCB — so it originates: it allocates a call and becomes that call's
originator. -
When call processing makes some other line ring, it plants the caller's CCB into the ringing line (
make_ring, ep_lines.cc:373-374):state = LineState_Ringing; ccb = originator; -
So when the ringing line's subscriber answers,
Line::emit()findsccb != NULLand delivers the off-hook event into the caller's call instead of starting a new one. That is the answer. Two-party calls assemble themselves out of this asymmetry.
The comment above Line::emit() states it plainly (ep_lines.cc:450-464):
/*
* emit (event)
*
* This is what drives origination vs termination.
*
* If there's no CCB, then that means we're not part of a call yet.
* And that means that we're originating. We allocate a CCB.
*
* If there is a CCB, then that means that we're already part of a call.
* And that means that we're terminating.
* ...
*/
Keep this trick in mind — several features (pickup, park) work by carefully re-pointing this one pointer, a pattern the code calls "custody".
4. The CCB — one call, one control block
The Call Control Block is the per-call data structure. Everything about one call in progress — who's on it, what hardware it holds, which state machines are running on it — lives here (cc.h:90-149, abridged):
#define NDIGITSTACK 10 // should be plenty
struct CCB
{
bool active = false;
// resources allocated to the call
Endpoint *originator = NULL; // the originator of the call
Endpoint *terminator = NULL; // the terminator of the call
Endpoint *thirdparty = NULL; // a third party in the call
DTMF_RX *dtmf_rx = NULL; // DTMF receiver
DTMF_TX *dtmf_tx = NULL; // DTMF transmitter
Treatment *treatment = NULL; // treatment processor
Pager *pager = NULL; // pager
Junctor *junctor = NULL; // junctor involved in the call
list <Processor *> processors; // list of processors
...
// digit management
char digits_ [NDIGITSTACK][NDIGITS + 1]; // digits to analyze, stacked by time
...
void handle_event (CCBEvent, CCBEventMessage *);
bool topology_change = false;
int handle_event_depth = 0;
...
};
CCBs are a static pool — CCB ccbs [NCCBS] (cc.cc:12), where NCCBS is
MAX_JUNCTORS * 2 = 24. Nothing about a CCB is heap-allocated except the
processors on its list.
Lifecycle
Birth is lazy and happens in the endpoint, not in call control. When an idle line emits its first event (ep_lines.cc:474-491):
if (ccb == NULL) {
ccb = ccb_alloc (this);
if (!ccb) {
Debug << "Line[" << this - lines << "]::emit could not allocate a CCB, ignoring event (congestion)\n";
return;
}
ccb -> junctor = allocate_junctor (JUNCTOR_ANY);
if (!ccb -> junctor) {
Debug << "Line[" << this - lines << "]::emit could not allocate a junctor, ignoring event (congestion)\n";
ccb -> originator = NULL; // undo what ccb_alloc() just did, returning the CCB to the free pool
ccb -> active = false;
ccb = NULL;
return;
}
Debug << "Line[" << this - lines << "]::emit adds P_Line, allocates junctor " << (int) ccb -> junctor -> facility << "\n";
ccb -> insert_processor (PName_Line);
}
Note the shape: allocate CCB, then junctor; if the junctor fails, unwind the CCB and silently ignore the event. In telephony, congestion = silence, not a crash.
Death is a two-step reap in scan_cc() (cc.cc:79-96):
void
scan_cc (void)
{
for (auto &c : ccbs) {
if (c.active) {
// pass event on to CCB
c.handle_event (CCBEvent_Poll, NULL);
cull_dead_processors (c);
// cull inactive CCB
if (!c.processors.size() && !c.treatment) {
c.teardown ();
ccb_free (&c);
}
}
}
}
Read that inner condition carefully; it's the single most important lifecycle rule in the system:
A CCB dies when its last processor goes inactive and it has no treatment.
Nobody "closes" a call. Processors retire themselves (§5), and once the list
is empty the call evaporates. CCB::teardown() (cc.cc:112-158) then does the
orderly shutdown — synthesizes a final CCBEvent_Onhook to any still-active
processors, calls teardown() on each attached endpoint (which resets the
line and clears its ccb pointer), and frees the junctor/DTMF/pager
resources. Finally ccb_free() (cc.cc:35-51) verifies that every resource
pointer is already NULL, screaming *** FAIL *** into the log for anything
left behind — this is your first leak detector, and the simulator's
end-of-test checks (§14) are built on the same principle.
5. Processors and the chain — the heart of the system
Every CCB carries an ordered list<Processor *>. A Processor is a small state
machine with, essentially, two entry points (processor.h:103-146, abridged):
struct Processor
{
...
bool active = true; // if not active, can be culled by CC
CCB *ccb = NULL; // (most) everyone needs a back pointer to the CCB
TreatmentType curtreat_ = Treatment_none; // current treatemnt
uint64_t since = 0; // (most) everyone needs a "since when" for state changes
ProcessorName pname = PName_none; // so that we can find it later
char digits [NDIGITS + 1] = { };
virtual ProcessorReturn handler (CCBEvent, CCBEventMessage *) { return ProcessorReturn_Error; }
virtual void dump (void) { ... }
...
};
There is no init(), no enter()/exit(). Construction sets pname and
nothing else; a processor does its first real work when it receives its first
CCBEvent_Poll (you'll see how that's guaranteed in a moment).
handle_event(): how an event visits the chain
void
CCB::handle_event (CCBEvent e, CCBEventMessage *m)
{
handle_event_depth++;
do {
topology_change = false;
DebugIndent;
for (auto p : processors) {
if (!p -> active) continue;
ProcessorReturn r = p -> handler (e, m);
...
switch (r) {
case ProcessorReturn_Consumed:
DebugOutdent;
goto done;
case ProcessorReturn_Next:
// proceed to next
break;
case ProcessorReturn_Error:
break;
}
}
DebugOutdent;
if (topology_change) {
Debug << *this << " notes a topology change, going again with a poll\n";
e = CCBEvent_Poll;
...
if (handle_event_depth == 1) {
cull_dead_processors (*this);
}
}
} while (topology_change);
done:
if (treatment) {
treatment -> handler (CCBEvent_Poll, NULL);
}
...
handle_event_depth--;
}
(cc.cc:160-219)
Rules to internalize:
-
Front to back, newest first.
insert_processor()doesprocessors.push_front(p)(cc.cc:236), so the most recently inserted processor sees every event first. This is why digit collection works:P_LineinsertsP_Collect, andP_Collect— now at the front — gets first crack at everything the line emits. -
The return value contract (processor.h:35-39):
enum ProcessorReturn { ProcessorReturn_Consumed, // this processor handled the event, do not pass it on to anyone else, we're done with it ProcessorReturn_Error, // this processor detected an error (event is consumed) ProcessorReturn_Next, // this processor did not handle the event, pass it on to the next processor };The actual semantics, from the switch above:
Return What really happens Consumedgoto done— chain stops. Also skips the topology-change re-poll (the goto jumps past it).Nextcontinue to the next processor. This is the default; features cooperate. Error⚠️ identical to Nextin the code, despite the "event is consumed" comment. Don't rely onErrorstopping the chain.Also: there is no
ProcessorReturn_Delete. The comment at cc.h:104 ("automatically removed ... when the processor returns ProcessorReturnDelete") describes a mechanism that was removed long ago. Don't go looking for it. -
Retirement. A processor never removes or deletes itself. It sets
active = falseand returns.cull_dead_processors()(cc.cc:62-77) — running fromscan_cc()or from the outermosthandle_event()— deletes it and erases it from the list. Its destructor runs then, and destructors do real work in this codebase (see §12).CCB::remove_processor(pname)(cc.cc:244-256) is just "setactive = falseon every match" — same mechanism, driven from outside. -
The topology-change re-poll.
insert_processor()setstopology_change = true. When the chain finishes a pass and that flag is set, thedo/whileloop runs the chain again, immediately, with the event rewritten toCCBEvent_Poll. That's the guarantee mentioned above: a freshly inserted processor gets its initializing Poll in the samehandle_event()call — it does not wait for the next superloop pass, and it never sees the original triggering event (which was already dispatched). This is also how a processor that inserted a helper can re-acquire a resource the helper just dropped, within one cycle. -
Reentrancy. A handler may call
ccb->handle_event()recursively on its own CCB — that's howCCBEvent_CallProgressreports are delivered (§9). Thehandle_event_depthcounter exists because of it: only the outermost frame may physically erase/delete processors, or a paused iterator up the stack would be used after free. The comment at cc.cc:194-199 tells the whole story. Your handler must never erase fromccb->processors; setactive = falseand let the machinery do it.
6. Junctors and the crossbar — how audio gets connected
A junctor is one of 12 audio buses in the crossbar fabric — think of it as
the call's party line. Everything the parties should hear gets crossbar-
connected to the call's one junctor: the other party's line, a tone, a DTMF
receiver (which "hears" the junctor), the pager. The struct is trivial
(junctors.h) — allocated plus a facility number — because a junctor is
just a wire; all the intelligence is in who's connected to it.
Allocation cycles a rotor so the same junctor isn't hammered repeatedly (junctors.cc:14-30):
Junctor *
allocate_junctor (uint16_t prefmask)
{
static uint8_t rotor = 0; // cycle the junctors (0..255 is applied as a modulo offset)
for (int j = 0; j < MAX_JUNCTORS; j++) {
int jnum = (j + rotor++) % MAX_JUNCTORS;
if (prefmask & (1 << jnum)) {
if (junctors [jnum].allocated == false) {
junctors [jnum].allocated = true;
return &junctors [jnum];
}
}
}
return NULL;
}
The physical layer is three-plus MT8804 8×4 crossbar chips forming the matrix; the big comment at junctors.cc:49-91 is the hardware reference. The software API is a flat set of typed, NULL-safe connect/disconnect pairs:
void xbar_connect_tone (Junctor *, uint8_t);
void xbar_connect_line (Junctor *, Line *);
void xbar_connect_trunk (Junctor *, Trunk *);
void xbar_connect_dtmf_rx (Junctor *, DTMF_RX *);
...and their disconnect twins
Rule for feature authors: you almost never call xbar_* yourself. The
resource wrappers do it for you and keep the bookkeeping straight:
- want a tone? →
treatment(...)(§7) - want digits? →
p_add_dtmf_rx()/p_release_dtmf_rx()(§8) - connecting a line to the call? →
line->connect_junctor(ccb->junctor)(lines have a shadow register; ep_lines.cc owns that write)
The exceptions are advanced custody/bridging moves (pickup bridging the picker onto another call's junctor, EBO's hand-rolled warning-tone cadence) — and when you make one, you own the cleanup.
7. Treatments — tones with a cadence
A treatment is a tone applied to the call: dial tone, busy, ringback,
reorder. There are only four physical tone sources
(ToneFacility_Dial/Ring/Busy/Quiet, treatment.h:44-48); everything else is
cadence — a bit pattern clocked over the crossbar connection. The whole
tone plan is one table, indexed positionally by the enum (treatment.cc:102-115):
static const Tone tones [NTreatments] =
{
// pattern timebase facility length repeat type
{ 0, 0, 0, 0, false, Treatment_none },
{ compile_cadence ("1,1"), 500 * MS, ToneFacility_Busy, 2, true, Treatment_Busy },
{ compile_cadence ("1"), 9999 * MS, ToneFacility_Dial, 1, true, Treatment_Dialtone },
{ compile_cadence ("1,1,1"), 500 * MS, ToneFacility_Ring, 8, true, Treatment_DoubleRing },
{ compile_cadence ("1,1"), 250 * MS, ToneFacility_Busy, 2, true, Treatment_Error },
{ compile_cadence ("1,1"), 125 * MS, ToneFacility_Busy, 2, true, Treatment_Fault },
{ compile_cadence ("1,1,1,1,1,1"), 200 * MS, ToneFacility_Dial, 6, false, Treatment_InitialStutterDial },
{ compile_cadence ("1"), 9999 * MS, ToneFacility_Quiet, 1, true, Treatment_Quiet },
{ compile_cadence ("2,4"), 1000 * MS, ToneFacility_Ring, 6, true, Treatment_Ring },
{ compile_cadence ("1,1"), 500 * MS, ToneFacility_Dial, 2, true, Treatment_StutteringDial }
};
init_treatment() (treatment.cc:137-149) checks at boot that the table order
matches the enum and hangs forever printing a failure if not — so adding a
treatment is enum + stringifier + table row, in matching positions, and you
can't silently get it wrong.
Semantics a newcomer needs (the names alone don't say it):
Treatment_Busy— normal station busy (30 ipm).Treatment_Error— reorder / fast busy (60 ipm): "you did something wrong". This is what a feature plays when it refuses.Treatment_Fault— very fast busy (120 ipm): "the system is broken" — bad datafill, unroutable digits.Treatment_Ring— ringback heard by the caller. The called set's physical bell is a different subsystem entirely (ringer.cc).
A processor requests a tone through the inherited helper (processor.h:114-121):
void
treatment (TreatmentType t)
{
if (ccb) {
ccb -> remove_treatment (curtreat_);
ccb -> add_treatment (curtreat_ = t);
}
}
and — this matters — the base destructor cleans up after you
(processor.h:105-112): whatever curtreat_ you left applied is removed when
you're culled. You set a tone and forget it; the lifecycle machinery does the
rest. A CCB holds at most one treatment at a time (cc.cc:441-459), and the
treatment FSM is clocked by the treatment->handler(Poll) call at the end of
every handle_event() (§5).
⚠️ The treatment-churn trap.
treatment()is remove-then-add on every call. That's fine for the universal idiom "apply a tone, then transition to another state". But if your state sits under the same treatment across every Poll, guard it — or you'll destroy and recreate theTreatmentobject every 5 ms and the cadence never runs. The worked example is p_line.cc:140-146:if (curtreat_ != Treatment_Fault) { treatment (Treatment_Fault); }
8. Digits and translation — from dialed digits to your feature
Two separate mechanisms share the word "digits"; don't confuse them.
class Digits (digits.h) is a packed-BCD storage type (16 bytes for up
to 30 digits, with an extended alphabet for *, #, and dial-control codes).
It's the database type — LineDB::last_number, speedcall entries. Features
rarely touch it.
The CCB digit stack is what features actually use: NDIGITSTACK (10)
rows of up-to-30-char strings (cc.h:111-121), with digits() returning the
current row and digits_previous() the one before.
The two meet in the translator. Dialed digits accumulate on the current
stack row (via ccb->digits_store(), driven by P_Collect), and after each
digit the translator tries to match them against the dial plan — one table
(translate.cc:19-56, abridged):
static struct TranslationTable {
...
string_view access; // access code prefix; "X" matches digits, "?" matches anything
ProcessorName name; // matching processor
} ttable [] =
{
{ "0", PName_PPAP },
{ "1XX", PName_CallExtension },
{ "2XX", PName_Speedcall},
{ "3", PName_Pickup },
// 4 available
{ "5", PName_BusyOverride },
{ "6", PName_CallbackBusy },
{ "74X", PName_RingGroupP }, // there are NRINGGROUPS ring groups (74 == "RI"ng)
{ "8", PName_CallTrunkGroup },
{ "9", PName_CallTrunkGroup },
// { "*2XX", PName_ProgramSpeedcall },
{ "**999", PName_Debug },
{ "#", PName_Speedcall },
};
One row of this table is all it takes to bind a dial code to your feature. The commented-out rows are reserved codes waiting for their processors to be written.
The matching algorithm (Translator::translate(), translate.cc:83-131) has
three outcomes, and the way ambiguity is handled is elegant:
- Prefix-match the dialed digits against every row (
X= any digit). - Two rows still match → need more digits. Ambiguity is never resolved by
table order; the caller just keeps collecting. This is why
"3"(pickup) and"2XX"(speedcall) coexist peacefully with everything else. - No row matches →
Treatment_Fault. More digits can't fix a non-match. - Exactly one full match → push a digit-stack frame with the unconsumed
remainder, and recommend the row's
ProcessorName.
The three outcomes are returned as integers the caller chose — Translator
is constructed with the caller's own state values (p_line.cc:49):
translator = new Translator { ccb, P_LineState_Collect, P_LineState_Route, P_LineState_Treatment };
so P_Line literally does
state = static_cast <P_LineState> (translator -> translate ());
(p_line.cc:117) — translation results are state transitions.
The translator only recommends; P_Line does the insertion, and note the
graceful-degradation branch (p_line.cc:121-132):
case P_LineState_Route:
ccb -> remove_processor (PName_Collect);
if (translator -> insert_processor ()) {
state = P_LineState_PostRouting;
} else {
// no processor available for the recommended feature (e.g. not yet implemented) -- fast busy, per README.txt:1599
Debug << "P_Line Route: no processor available, applying fast busy\n";
treatment (Treatment_Error);
state = P_LineState_WaitingForHangup;
}
processor_factory() returns NULL for a PName_ with no implementation
(processor.h:215-216), and the caller turns that into fast busy. So you may
add your ttable row before your processor exists and nothing crashes.
Why a stack?
Each translation layer pushes a frame, so the history of interpretation is
preserved. Dial speedcall 212 which expands to 96135551212 and the stack
holds both; dial 9613... and the stack shows the 9 (trunk access) frame
and the 613... frame separately. Consumers exploit this:
- Your feature's arguments are
ccb->digits()— whatever followed your access code. - Your own access code is in
ccb->digits_previous()— howP_RingGroupPlearns which ring group (74X) invoked it. - The very first thing the subscriber dialed is
ccb->digits_[0][0]— how LNR decides whether the number was dialed directly (p_lnr.cc:71).
Handy helper for parsing (cc.h:156-166): two_digits_from_ccb(ccb, offset)
pulls a validated two-digit number out of the current row — see
P_CallExtension using it to get the extension (p_callext.cc:41).
Digit acquisition — DTMF receiver allocation, dial tone, first-digit and
inter-digit timeouts — is the job of a dedicated helper processor,
P_Collect, which P_Line inserts and later removes. If your feature
needs to collect digits mid-call, insert your own P_Collect (the flash/
second-leg path at p_line.cc:216-218 is the example). The DTMF RX itself is
refcounted at the CCB (cc.cc:341-387) with per-processor ownership
wrappers p_add_dtmf_rx()/p_release_dtmf_rx() (processor.h:148-172), so
two processors (say P_Collect and P_SMDR) can share one receiver without
double-freeing it.
9. The event vocabulary
Everything a processor will ever be handed (cc.h:10-34):
enum CCBEvent : int
{
// none
CCBEvent_none,
// both
CCBEvent_Error, // something bad happened
CCBEvent_Poll, // the periodic CCB poller
CCBEvent_NewDigits, // need to re-evaluate digits
CCBEvent_Timeout,
CCBEvent_CallProgress, // "the story so far..." progress report
// lines
CCBEvent_Digit,
CCBEvent_Flash,
CCBEvent_Offhook,
CCBEvent_Onhook,
// trunks
CCBEvent_Abandoned,
CCBEvent_Reversal,
CCBEvent_Ringing,
CCBEvent_Seized,
};
Events arrive with a message (cc.h:77-82):
struct CCBEventMessage
{
CCBEvent event = CCBEvent_none;;
void *from = NULL;
uint32_t data = 0;
};
The working reference:
| Event | Producer | m payload |
What it means to you |
|---|---|---|---|
Poll |
scan_cc() every pass; the topology-change re-poll |
m is NULL from scan_cc |
The clock tick. Do timed work here; do your initial work here (first Poll after insertion). |
Digit |
Line::emit (rotary) or P_Collect (DTMF) |
data = ASCII digit |
One dialed digit. |
Offhook |
Line::emit |
from = the Line * |
Origination — or, on a CCB with a ringing terminator, answer. Check m->from against ccb->terminator. |
Onhook |
Line::emit; also synthesized by CCB::teardown() with m == NULL |
from or NULL |
Hangup. Near-universal response: active = false; return ProcessorReturn_Next; — retire, but let everyone else see it too. |
Flash |
Line::emit |
from |
Hookflash (≥ 250 ms). Drives park/second-leg. |
Timeout |
P_Collect |
data = P_CollectEvent_* reason |
A collection timer expired; discriminate on m->data. |
NewDigits |
forwarding/speedcall features | from |
"The digit stack changed under you — re-translate." This is how call forward and speedcall re-route a call in flight. P_Line guards it with MAX_LOOPCOUNT (10) against forwarding loops (p_line.cc:193). |
CallProgress |
terminating features | data = CallProgress_Ringing/Terminated/Busy |
The observer protocol: "the story so far." Emitted recursively via ccb->handle_event() from inside a handler so ring-group secondaries, callback-busy, etc. can track outcomes without polling. |
Error |
Line::emit (bad pulse digit), P_Collect (buffer full) |
from |
Something's wrong; typically answered with Treatment_Error. |
Abandoned / Reversal / Ringing / Seized |
Trunk::emit |
from = the Trunk * |
Trunk supervision: inbound caller gave up / far-end answered / inbound ring cycle completed / outbound seizure done. |
Three sharp edges, all consequences of the message being a stack local in the emitter (ep_lines.cc:494-495):
- Never retain
mpast your handler's return. - Always null-check
mbefore dereferencing — Poll and teardown-Onhook deliver NULL. This is why the canonical Onhook short-circuit sits before anym->access. m->eventis never populated by any emitter — the event type is the separate first argument. Don't readm->event.
10. Datafill — configuration, COS, and the database
"Datafill" is telco-speak for configuration data. It lives in an EEPROM
(simulated by the files database.0/database.1 — dual pages, CRC'd), one
page per entry, each guarded by a size assert.
Per-line (LineDB, ep_lines.h:37-48):
struct LineDB
{
Digits last_number; // last number dialed (includes trunk access code, e.g., "96137451576")
uint8_t extension; // which 2-digit extension does this line present as?
uint8_t cos; // index into line class of service database
uint8_t p_cfwd; // call forward destination (0xff if none) (see FEATURE_TARGET_*)
uint8_t p_cfna; // call forward no answer destination (0xff if none)
uint8_t p_cfb; // call forward busy destination (0xff if none)
uint8_t p_hotline; // hotline/warmline auto-dial target: a speedcall-range FEATURE_TARGET_* byte
// (0xff if none) -- a full number can't fit in one byte directly
};
static_assert (sizeof (LineDB) <= DATABASE_PAYLOAD_SIZE, "Line database size has exceed available payload size");
Notice the feature destinations are one byte each. The encoding (processor.h:257-278) packs every kind of target into that byte:
* 0 -> 99 feature routes to extension 100 + x
* 100 -> 199 feature routes to speed call 100 + x
* 200 -> 229 long number lookup table (LNLU)
* 230 -> 237 ring group
* 238 -> 254 ... unassigned ...
* 255 feature is not provisioned
Decoders: feature_target_string() (utils.cc, for display) and
feature_target_target() (utils.cc, producing dialable digits that go
straight back into translation). If your feature needs a datafillable
destination, use this byte — a full number can't fit, so point at a speedcall
entry that holds it (that's what p_hotline does).
Per-class-of-service (LineCOSDB, ep_lines.h:29-35) — a COS is a small
bundle of option bits shared by lines of the same "class":
struct LineCOSDB
{
LCOSType linetype;
bool ebo_enable; // may dial the executive-busy-override code (5) to join a busy station's call
bool ebo_security; // may NOT be overridden (EBO-security / data-line-security)
};
Every line has a cosdb shortcut pointer, so gating a feature by COS is one
line: if (!o -> cosdb -> ebo_enable ...) { treatment (Treatment_Error); ... }
(p_busyoverride.cc:131).
System-wide (SystemDB, sysdb.h) — e.g. cfna_timeout, the SMDR enable.
Transient state does not belong in datafill. If your feature needs
run-time state attached to a line, put it on struct Line with a comment
justifying it — the house example (ep_lines.h:121-125):
// callback-busy arming: recorded by P_CallExtension when it applies real (non-forwarded)
// busy treatment to this line as an originator; not persisted (db is for datafill, this is
// transient call-processing state, same category as `ringer` or `hwshadow`)
uint8_t last_busy_target = 0; // FEATURE_TARGET_EXTENSION-range byte of who was busy
uint64_t last_busy_time = 0; // 0 == nothing recorded
Test datafill lives in sim_database_reset_lines() (ep_lines.cc) and
sim_database_reset_sysdb() (sysdb.cc), applied by test 999 — which make reg
runs first, so the whole suite is self-contained from a wiped database.
⚠️ If you change any DB struct's size or layout, the on-disk EEPROM image is stale garbage: run
make wipedb(deletesdatabase.0/.1), and respect thestatic_assertagainstDATABASE_PAYLOAD_SIZE.
Part II — Life of a call, then life of a feature
11. Trace: off-hook to dial tone (and cleanly back down)
Here is regression test 1000 — a line goes off-hook, gets dial tone, hangs up
— straight from regression.txt, lightly trimmed. Every concept from Part I
appears in ~40 lines of log. Read it top to bottom; the annotations map each
block back to its section.
00:00:00.105 TEST LINE 0 OFFHOOK
00:00:00.165 Line[0]::emit Offhook from lnum 0 (this 0x60b73daccbd0), no CCB [ep_lines.cc:472]
Line[0]::emit adds P_Line, allocates junctor 0 [ep_lines.cc:490]
CCB[0] insert new processor 0x60b73daca630 => { P_Line } [cc.cc:109]
emit offhook from 0x60b73daccbd0
P_Line_0x60b73daca630 Originate CCB[0] event 8 (Offhook) message { from line 0, data 0 }
Line[0]::connect junctor 0
P_Line Originate -> CollectInitialize [p_line.cc:79]
CCB[0] handle_event processor ... P_Line returns Next
CCB[0] insert new processor 0x60b73dacc730 => { P_Collect P_Line }
P_Line CollectInitialize -> Collect
CCB[0] notes a topology change, going again with a poll [cc.cc:192]
allocated DTMF rx 0
connecting J0 to dtmf rx 0
CCB[0] add_dtmf_rx got rx 0
00:00:00.170 CCB[0] add_treatment Dialtone
Initialized treatment handler
disconnecting J0 from tone 0x10 (Quiet)
connecting J0 to tone 0x80 (Dial)
Walk it:
- Debounce first (§3): the test flips the hook at .105; the FSM emits at
.165 — 50 ms of
DEBOUNCE_TIMEplus tick granularity. Call control never saw the bounce. - Lazy origination (§4): "no CCB" → allocate CCB[0] and junctor 0, seed
the chain with
P_Line. The endpoint bootstraps call control. - The chain runs (§5):
P_LineinOriginateconnects the line to the junctor and steps toCollectInitialize; on the same delivery it insertsP_Collect— note the list is now{ P_Collect P_Line }, newest first. - Topology-change re-poll (§5): "going again with a poll" — and because
of
push_front, the brand-newP_Collectgets that Poll first, using it to allocate DTMF RX 0 and applyTreatment_Dialtone. - Treatment → crossbar (§6, §7): the treatment FSM initializes and swaps the Quiet tone for Dial on junctor 0. The subscriber hears dial tone, ~65 ms after lifting the handset.
And the way down:
00:00:01.235 Line[0]::emit Onhook from lnum 0 ..., CCB[0]
P_Collect_... Digit CCB[0] event 9 (Onhook) ...
CCB[0] handle_event processor ... P_Collect returns Next
P_Line_... Collect CCB[0] event 9 (Onhook) ...
CCB[0] handle_event processor ... P_Line returns Next
Processor P_Collect is no longer active, deleting
P_Collect_... destructor
... remove_dtmf_rx ... disconnecting J0 from dtmf rx 0 ...
... remove_treatment 2 (Dialtone) ... disconnecting J0 from tone 0x80 (Dial)
Processor P_Line is no longer active, deleting
CCB[0] teardown
Line[0]::teardown
Line[0]::disconnect junctor 0
CCB[0] teardown complete
CCB[0] free
- Everyone sees the hangup (§9): both processors respond to
Onhookwithactive = falseand still returnNext— retirement is cooperative, and the event reaches the whole chain. - Destructors do the cleanup (§5, §7):
P_Collect's destructor releases the DTMF RX and its treatment; nobody had to remember from outside. - The CCB evaporates (§4): no processors + no treatment → teardown (line
reset,
ccb = NULL) →ccb_freeverifies everything is NULL. The world is exactly as it was before the call.
For the terminating half (calling extension 101), the missing piece is
P_CallExtension — the processor the ttable's 1XX row routes to. Its
Connect state is the commit point (p_callext.cc:75-78):
Debug << *ccb << " P_CallExtension commits terminator " << (void *) line << " to CCB\n";
ccb -> terminator = line; // at this point, we commit the line to the CCB's terminator
line -> make_ring (ccb, rt);
treatment (Treatment_DoubleRing);
make_ring plants this CCB into the target line (§3's one-pointer trick), the
caller hears ringback, and when the target answers, its Offhook arrives in
this CCB — where P_CallExtension's Ringing state recognizes
m->from == line, clears the ringback, and reports
CallProgress_Terminated to anyone watching (p_callext.cc:106-137).
12. Anatomy of a real feature: P_Pickup
Call pickup — dial 3, answer a call that's ringing on somebody else's phone
— is the cleanest end-to-end example of a dialed-code feature: small (135 + 34
lines), uses the standard shape, and demonstrates the advanced custody move.
The header is pure template shape (p_pickup.h):
enum P_PickupState
{
P_PickupState_Idle,
P_PickupState_WaitForHangup,
};
static inline const char *
P_PickupStateString (P_PickupState s) { ... }
struct P_Pickup : Processor
{
P_Pickup () { pname = PName_Pickup; }
~P_Pickup ();
ProcessorReturn handler (CCBEvent e, CCBEventMessage *m);
P_PickupState state = P_PickupState_Idle;
void dump (void);
};
That two-state shape — Idle (do the work on the first Poll) and
WaitForHangup (a refusal tone is playing; wait out the subscriber) — is
the skeleton of nearly every access-code feature in the tree. Now the handler
(p_pickup.cc:41-119, custody core elided):
ProcessorReturn
P_Pickup::handler (CCBEvent e, CCBEventMessage *m)
{
dump_event_message (this, ccb, e, m);
switch (state) {
case P_PickupState_Idle:
if (e == CCBEvent_Poll) {
extern Line *lines;
Line *found = NULL;
for (Line *l = lines; l < lines + MAX_LINES; l++) {
if (l -> is_ringing ()) {
found = l;
break;
}
}
if (!found) {
treatment (Treatment_Error); // nothing to pick up -- reorder cadence
goto wait_for_hangup;
}
... the pickup itself (custody move, discussed below) ...
active = false; // we're done; this ccb is dissolving
}
break;
case P_PickupState_WaitForHangup:
if (e == CCBEvent_Onhook) {
active = false;
}
break;
}
return ProcessorReturn_Next;
wait_for_hangup:
state = P_PickupState_WaitForHangup;
return ProcessorReturn_Next;
}
The idioms, named:
- First line:
dump_event_message(this, ccb, e, m). Every processor, every handler. It's the tracing preamble that makes the regression log readable (it self-suppresses Poll noise). Non-negotiable. - Work happens on
CCBEvent_Poll, not on a digit event. By the time your feature exists, translation is over andP_Linehas moved on — the topology-change re-poll delivers your first Poll immediately (§5), and that's your "go" signal. - Refusal =
treatment(Treatment_Error)+goto wait_for_hangup. Play reorder, sit in WaitForHangup, retire on Onhook. Thegoto-to-a-label exit is the house style (the template calls itwait_for_death). - Success = mutate the world, then
active = false. Pickup doesn't "keep running" — it performs the surgery and retires; the surviving call carries on with its own processors. return ProcessorReturn_Nexteverywhere. Features cooperate. Consuming an event is rare and always carries an explanatory comment (p_busyoverride.cchas the one good example).
The custody move itself (p_pickup.cc:64-102) is worth reading in place once
you're comfortable — it synthesizes the answer Offhook into the ringing
call before touching any pointers (so the existing answer machinery,
including ring-group cascades, runs unmodified), then bridges the picker onto
the real call's junctor, re-points picker->ccb at the surviving CCB, and
dissolves its own. The 30-line design comment at p_pickup.cc:10-39 explains
every decision, including the one gap deliberately left unfixed. That
comment is the house standard for feature documentation — when you write
yours, write that.
Sidebar: timers and destructor-work — P_LNR
Last-Number-Redial digit capture (p_lnr.cc) is the cleanest tiny feature and shows two more idioms:
#define TIMEOUT_DTMF_RX (10 * KILO * MS) // inter-digit timeout
...
switch (state) {
case P_LNRState_Idle:
p_add_dtmf_rx ();
state = P_LNRState_AccumulateDigits;
since = now;
break;
case P_LNRState_AccumulateDigits:
if (e == CCBEvent_Poll) {
if (now - since > TIMEOUT_DTMF_RX) {
p_release_dtmf_rx ();
active = false;
}
} else if (e == CCBEvent_Digit) {
Debug << "P_LNR GOT DIGIT '" << (char) m -> data << "', adding to log\n";
ccb -> digits_store (m -> data);
}
break;
}
The timer idiom: since = now on state entry, now - since > TIMEOUT
under Poll. If you need a second concurrent timer, add your own field (see
P_BusyOverride::phase_started). And in the destructor (p_lnr.cc:62-84), LNR
writes the captured number to the database on the way out — destructors are
a legitimate place for completion work, because culling is the one guaranteed
exit path.
Part III — Now build one
13. The recipe
This checklist is not aspirational — it's what git show --stat shows for the
three most recent features (pickup, callback-busy, busy-override), each of
which touched exactly these files. Work in this order.
Step 0 — Write the behavior down first.
Add your tests to TESTS.txt as numbered prose entries in "action; verifies:"
form, in the right series (features are 4XXX; each feature gets its own
decade, e.g. pickup is 405X). If the behavior is subtle, note what you're
not covering, honestly — that's a house convention. If the feature exists on
a real SX-20, the practices manual is the authority; extract the behavior
before designing.
Step 1 — Datafill, if needed (§10).
COS bits → LineCOSDB (ep_lines.h) + the COS mapping in
sim_database_reset_lines() (ep_lines.cc). Per-line targets → a
FEATURE_TARGET_* byte in LineDB. System-wide → SystemDB (sysdb.h/cc).
Any layout change → make wipedb, and mind the static_asserts.
Step 2 — Create the processor from the template.
Copy p_TEMPLATE.h / p_TEMPLATE.cc to p_yourfeature.{h,cc} and
s/TEMPLATE/YourFeature/. The template files are deliberately non-compiling
scaffolds containing their own instructions. Your .h gets: the state enum,
the P_XxxStateString() stringifier (not optional — the event tracer calls
it), the struct with pname set in the constructor, and your private members.
Your .cc gets: #include "main.h" (the only include — it's the umbrella
header), a real design block comment (what/why/what's deliberately not
handled — model it on p_pickup.cc:10-39), the handler, destructor, and
dump().
Step 3 — Register it in processor.h: five edits, all alphabetical.
This is the complete processor.h diff from the busy-override commit
(ee91f03):
enum ProcessorName : int {
PName_3WayCalling,
+ PName_BusyOverride,
@@ ProcessorNameString()
+ case PName_BusyOverride: return "P_BusyOverride";
@@ the #include block
+#include "p_busyoverride.h"
@@ processor_factory()
+ case PName_BusyOverride: return new P_BusyOverride;
@@ dump_event_message()
+ case PName_BusyOverride: DebugAdd << P_BusyOverrideStateString ... -> state) << " "; break;
Step 4 — Bind the dial code: one ttable row in translate.cc (§8).
Check whether your code is already reserved (pickup's 3 was); if you're
taking a previously-unassigned code, check whether any test used that code as
its "unassigned code" example — callback-busy's 6 broke test 3000, which had
to move to 4.
Step 5 — Both build systems.
CMakeLists.txt: one .cc line in add_executable. Makefile: the .o in
OBJECTS and the .h in COMMON. Grep for p_ppap in both files to find
the insertion points.
Step 6 — Tests: five mechanical edits (sim side).
(1) TESTS.txt prose — already done in step 0. (2) The one-line summaries in
the usage banner in sim_tests.cc. (3) The test functions — see §14. (4) A
case NNNN: per test in the scan_tests() dispatch switch. (5) A
./callp -VNNNN,... batch line in the Makefile reg: target.
Step 7 — Special structures, only if your feature needs them.
- Runs with no CCB (a queued request, a monitor)? → a plain
xxx_tick()called from the superloop in main.cc, likecallback_busy_tick(). - Needs to know something another processor learns? → add a hook in that
processor (the way
P_CallExtensionarmslast_busy_targetfor callback-busy and EBO, p_callext.cc:94-98). - Cross-CCB custody (adopting a call, parking)? → study PPAP and pickup first; the leak checks will judge you.
- New datafill field? → also add it to the
html.cctables (real gap: the EBO COS bits never made it there — don't repeat that).
Step 8 — Verify like the commits do.
Run your batch in isolation (./callp -V4050,4051,...), then
make wipedb && make reg and compare against a baseline regression.txt:
grep for *** FAIL *** (only the 4 known expected soft lines) and STILL
(must be zero). For memory bugs, a one-off sanitizer build
(clang++ -fsanitize=address,undefined over the same source list) has caught
every use-after-free so far. If the Pico toolchain is on PATH, do a clean
cross-build.
Step 9 — Journal it.
A dated, timestamped entry in JOURNAL.md: what you built, what make reg
showed, what surprised you, and — most valuable — what you investigated and
deliberately did not change, with reasoning.
14. Testing your feature
There is no unit-test framework, and that's a feature: every test runs the
whole PBX in simulated time and asserts on observable hardware state —
crossbar connections, ringing lines, tone cadences — not on internal
variables. The DSL (CompactTestRunner, sim_testrunner.h) makes tests read
like call scripts. Here is the basic pickup test, complete
(sim_tests.cc:3566-3592):
static void
test_4050_basic_pickup (void)
{
static CompactTestRunner test;
if (!sim_initialized) {
test.clear ();
test
.line_offhook (0)
.dial_string (0, "101") // A calls B
.require_line_ringing (1)
.line_offhook (2) // C picks up
.dial_string (2, "3")
.require_line_not_ringing (1)
.require_line_line_connection (0, 2) // A and C are now bridged
.line_hangup (0)
.line_hangup (2)
.finish ();
cout << banner ("TEST 4050, basic call pickup");
sim_initialized = true;
} else {
test.run();
}
}
(Gotcha: stimulus verbs take extensions ("101"); require_line_* take
LENs (line 1). Extension = 100 + LEN.)
What you need to know:
- Stimulus verbs:
line_offhook/hangup/flash/glitch,dial_string(DTMF by default;'P'/'T'switch pulse/tone mid-string,'F'is a hookflash),trunk_offhook/hangup/ring,wait(ms),custom(...). - Assertions: the
require_*family — dialtone/busy/ring present/absent, line ringing, line↔line and line↔trunk connections, DTMF RX presence, LNR/SMDR/CCB digits, and the cadence-awarerequire_treatment(line, Treatment_X)which decodes the actual tone table cadence off the junctor (Busy vs Error vs Fault are the same facility — only cadence differs). Each takes a timeout (default 5 s simulated) and aborts the process on expiry. require_stable_for(ms): therequire_*_absentforms pass the instant the condition is momentarily true — including "it just hasn't happened yet". Chain.require_X_absent(...).require_stable_for(5000)to get the strong claim "and it stays that way".- Three failure channels, none of them an assert macro: a
require_*timeout, a stability flip, and — the real judge — the end-of-run leak checks insimulator_exit()(sim_main.cc): any crosspoint still connected or any CCB still active fails the test. This is why every test hangs up everything and ends with.finish(). - Prove your assertion can fail. House ritual: temporarily invert the
condition, watch
TEST ABORTED, restore. An assertion that can't fail is decoration.
Commands:
make sim # build ./callp
./callp -t 4050 # run one test, full debug log to stdout
./callp -V4050,4051,4052 # regression mode: batch, reset between tests
make reg # the whole suite -> regression.txt
make wipedb && make reg # the canonical from-scratch verification
./callp -t? # list all tests
Useful flags: -k 1 (1 ms ticks — needed for trunk_ring and helpful for
cadence assertions), -r 0 -M 24h (soak), -d (dump the EEPROM databases),
-L/-T/-R (fewer lines/trunks/DTMF RXs, for exhaustion tests).
When something breaks, the log is the debugger: every line carries the
simulated timestamp, call-depth indentation, and [file:line]. CCB::dump()
prints the entire call state (and runs automatically when the leak check
fails). Dial **999 (or run -t 2) to toggle debug from a phone. And
remember the trace preamble you wrote in step 2 is what makes your feature
visible in all of this.
15. Sharp edges
Collected traps, each of which has actually bitten:
Debug <<compiles away on the Pico (main.hforcesLOG_LEVEL_INFO). Never put side effects inside aDebugstatement — they vanish on hardware.mmay be NULL (Poll from scan_cc, Onhook from teardown). Null-check before every dereference; handle Onhook before touchingm.- The message is a stack local. Never store
morm->frombeyond the handler call. Andm->eventis never filled in — use theeargument. - Never erase from
ccb->processorsin a handler.active = falseonly; the reentrancy machinery (§5) depends on it. ProcessorReturn_Errordoes not stop the chain, and there is noProcessorReturn_Delete(stale comment at cc.h:104).Consumedskips the topology-change re-poll. If you insert a processor and returnConsumed, your insertee waits until the next superloop pass (~5 ms) for its first Poll. Usually harmless — but know it's there (P_Line's Treatment state does exactly this).- Treatment churn: don't call
treatment(t)on every Poll from a resting state; guard withif (curtreat_ != t)(§7). - Makefile
COMMON: forgetting your.hthere doesn't break the link — it silently stops rebuilding dependents. Stale-object bugs are miserable; just don't skip it. dump_event_message()case forgotten → no compile error, just(unknown processor)in every trace line for your feature.- A new ttable row can break an existing test that used your code as its
"unassigned code" specimen (it happened with
6). - DB layout changes without
make wipedb→ you're reading garbage datafill and will chase ghosts. add_treatmentis one-per-CCB: a second add while one is live silently returns false. TheProcessor::treatment()helper's remove-then-add sidesteps this; directccb->add_treatment()calls don't.@@@is the marker for known shortcuts (notTODO/FIXME). Grep for it before assuming something is finished; leave one when you take a shortcut.
16. Where to go next
README.txt— the day-by-day design journal. When you wonder why something is shaped the way it is, the answer is usually in here, dated.PLAN.md— "Key architecture facts (verified — do not re-derive)" is the densest orientation page in the repo; the Phase 4 sections are worked examples of planning a feature before building it.TESTS.txt— the behavior spec, test by test, including the honest gaps.JOURNAL.md— how the recent features actually went: what worked, what didn't, what was deliberately left alone.- Read features in this order:
p_lnr(smallest),p_pickup(custody),p_callext(termination, intercepts, CallProgress),p_ringgroup(primary/secondary cooperation),p_callbackbusy(superloop monitor + processor),p_busyoverride(three-party custody, hand-rolled cadence). - Then dial
**999, run./callp -t 4050, and read the log until every line makes sense. When it does, you're ready.