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:


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:

  1. Time is a global snapshot. now (microseconds) is captured once at the top of each pass. Every state machine compares against now; nobody reads a fresh clock mid-pass, so all state machines within one pass agree on what time it is. "Set a timer" means: record since = now when entering a state, and check now - since > TIMEOUT on a later pass. There is no timer subsystem, on purpose.

  2. 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.

  3. 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 (-k to change it). That's why a test can wait(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:

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 poolCCB 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:

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:

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):

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 the Treatment object 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:

  1. Prefix-match the dialed digits against every row (X = any digit).
  2. 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.
  3. No row matches → Treatment_Fault. More digits can't fix a non-match.
  4. 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 choseTranslator 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:

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):

  1. Never retain m past your handler's return.
  2. Always null-check m before dereferencing — Poll and teardown-Onhook deliver NULL. This is why the canonical Onhook short-circuit sits before any m-> access.
  3. m->event is never populated by any emitter — the event type is the separate first argument. Don't read m->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 (deletes database.0/.1), and respect the static_assert against DATABASE_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:

  1. Debounce first (§3): the test flips the hook at .105; the FSM emits at .165 — 50 ms of DEBOUNCE_TIME plus tick granularity. Call control never saw the bounce.
  2. Lazy origination (§4): "no CCB" → allocate CCB[0] and junctor 0, seed the chain with P_Line. The endpoint bootstraps call control.
  3. The chain runs (§5): P_Line in Originate connects the line to the junctor and steps to CollectInitialize; on the same delivery it inserts P_Collect — note the list is now { P_Collect P_Line }, newest first.
  4. Topology-change re-poll (§5): "going again with a poll" — and because of push_front, the brand-new P_Collect gets that Poll first, using it to allocate DTMF RX 0 and apply Treatment_Dialtone.
  5. 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
  1. Everyone sees the hangup (§9): both processors respond to Onhook with active = false and still return Next — retirement is cooperative, and the event reaches the whole chain.
  2. Destructors do the cleanup (§5, §7): P_Collect's destructor releases the DTMF RX and its treatment; nobody had to remember from outside.
  3. The CCB evaporates (§4): no processors + no treatment → teardown (line reset, ccb = NULL) → ccb_free verifies 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:

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.

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:

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:

16. Where to go next