Skip to content

The PC Access LargeVocabulary bug

While reverse-engineering the .pca body parser we hit a desync: our parser walked off the rails by exactly 6684 bytes between the Voices blocks and the Connection block. The cause turned out to be a real bug in PC Access itself — a copy-paste mistake in the C# that’s been shipping for at least a decade. It’s harmless on every panel currently in deployment, except for one corner case nobody seems to have hit. We documented it here because if you write your own .pca parser, you have to replicate the asymmetry to stay aligned.

The Omni panel can be programmed to speak the names of objects when events happen — “front door opened”, “alarm activated in main area”. Each nameable object (zone, unit, area, thermostat, button, message) gets up to six “voice phrases” associated with it. The phrases are stored on the panel as an integer index into the panel’s vocabulary table.

Two flavours:

  • Small vocabulary — vocabulary index fits in one byte. Six phrases per object → 6 bytes per object.
  • Large vocabulary — vocabulary index needs two bytes (UInt16). Six phrases per object → 12 bytes per object.

OMNI_PRO_II has the LargeVocabulary feature. So a real Omni Pro II .pca file stores 12 bytes per voice slot, not 6.

Each voice block in .pca is read by a loop like this (paraphrased from clsHAC.cs):

byte[] B = new byte[CAP.numVoicePhrases]; // 6 bytes
for (int i = 1; i <= GetFileMaxX(); i++) {
num = (i > Count)
? num + FS.ReadByteArray(out B, B.Length) // skip path: 6 bytes
: num + _Items[i-1].Voice.Read(FS); // structured path
}

The structured path calls clsVoiceWordArray.Read, which branches on LargeVocabulary:

  • LargeVocabulary present → 6 phrases × 2 bytes = 12 bytes
  • LargeVocabulary absent → 6 phrases × 1 byte = 6 bytes

But the skip path in the loop above always reads 6 bytes from the buffer B = new byte[CAP.numVoicePhrases], no matter what. There is no if (LargeVocabulary) B = new byte[12]; next to it.

So when LargeVocabulary is on:

  • For slots i <= Count (defined): the structured path reads 12 bytes.
  • For slots i > Count (undefined / padding): the skip path reads 6 bytes.

The mismatch is silent unless Count != GetFileMaxX(). If every slot is filled (Count == Max), the skip path is never taken and the bug never fires.

For nearly every block on a shipping OMNI_PRO_II panel, Count == Max:

ItemsCountGetFileMaxXstructured slotsskip slots
Zones1761761760
Units5115125111
Buttons2551281280
Codes9999990
Thermostats6464640
Areas8880
Messages1281281280

Only Units has the asymmetry, and only by exactly one slot. That single skipped slot reads 6 bytes from a buffer where 12 bytes were written. The 6 bytes that should have been the second half of the last unit’s voice slot get treated as the start of the next block, and the parser walks 6 bytes off the rails.

The C# code in the wild gets away with this on Units specifically because nothing downstream cares about the contents of the over-skipped block — the parser is just trying to count bytes to advance past Voices and reach the next field. PC Access doesn’t actually use the voice data on the skipped slot for anything visible. So the bug is structurally present but operationally invisible.

The real concern is hypothetical: if a future model ever shipped with LargeVocabulary AND Count < Max for any of Buttons / Messages / something that does get used downstream, the same off-by-N would silently misparse the file from that point on. PC Access would behave correctly today, on every panel currently in deployment. But the failure mode lives in the code, waiting.

Backwards. The .pca plaintext had the panel’s IP address (192.168.1.9) embedded somewhere in the body. A hex search for the ASCII bytes 31 39 32 2e 31 36 38 2e 31 2e 39 found them at file offset 0xe2d8 (58072 decimal). Our parser, using a 6-byte voice slot, was landing at a different offset.

expected (parser) offset: 64756
actual offset of IP: 58072
diff: 6684 bytes

6684 = (512 - 1) * 6 + 6 = (511 * 6) + 6. That’s 511 * 6 bytes of voice slots read at half the right size, plus the one extra skip-path slot also read at the wrong size. The arithmetic matched too cleanly to be coincidence — somewhere our parser was reading 6 bytes per Units voice slot when it should have been reading 12.

Cross-referencing clsVoiceWordArray.Read against the Voices loop in clsHAC.ReadFromFile made the asymmetry obvious. The structured path already knew about LargeVocabulary; the skip path didn’t.

We patched our parser:

def voices_block_size(count: int, max_slots: int, large_vocab: bool) -> int:
structured = min(count, max_slots) * (12 if large_vocab else 6)
skipped = max(0, max_slots - count) * 6 # always 6, mirroring the C# bug
return structured + skipped

That formula matches the C#‘s observed behaviour exactly. With it, our parser lands on 0xe2d8 and the Connection block parses cleanly.

Latent bugs in shipping software can survive a decade because nobody runs the unhappy path. The PC Access voice-block code has been wrong since LargeVocabulary was added — quite a long time ago — and shipped to every Omni Pro II customer. It hasn’t caused a visible failure because the only block where the asymmetry triggers is the one block where it doesn’t matter downstream.

The fix in HAI’s source is one line:

byte[] B = new byte[CAP.numVoicePhrases * (LargeVocabulary ? 2 : 1)];

But we don’t get to make it. So our parser keeps the wrong-sized skip buffer for byte-count parity with the file, and our docs document that parity so the next person looking at this file format doesn’t lose half an afternoon to it.

  • File format reference — the byte-level layout of .pca, with the LargeVocabulary slot size correctly applied to the Units Voices block.
  • The Journey — how this bug surfaced during the initial parse.