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.
What the LargeVocabulary feature is
Section titled “What the LargeVocabulary feature is”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.
The mismatch
Section titled “The mismatch”Each voice block in .pca is read by a loop like this (paraphrased from
clsHAC.cs):
byte[] B = new byte[CAP.numVoicePhrases]; // 6 bytesfor (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.
Why it’s harmless in deployment
Section titled “Why it’s harmless in deployment”For nearly every block on a shipping OMNI_PRO_II panel, Count == Max:
| Items | Count | GetFileMaxX | structured slots | skip slots |
|---|---|---|---|---|
| Zones | 176 | 176 | 176 | 0 |
| Units | 511 | 512 | 511 | 1 |
| Buttons | 255 | 128 | 128 | 0 |
| Codes | 99 | 99 | 99 | 0 |
| Thermostats | 64 | 64 | 64 | 0 |
| Areas | 8 | 8 | 8 | 0 |
| Messages | 128 | 128 | 128 | 0 |
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.
How we found it
Section titled “How we found it”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: 64756actual offset of IP: 58072diff: 6684 bytes6684 = (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 + skippedThat formula matches the C#‘s observed behaviour exactly. With it, our
parser lands on 0xe2d8 and the Connection block parses cleanly.
Lesson
Section titled “Lesson”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.
See also
Section titled “See also”- 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.