Files
pm3py/docs/PYTHON_SIM_DESIGN.md
michael ab3d37e7b3 docs: sync CLAUDE.md with worktree, fix PYTHON_SIM_DESIGN line count
- Add table compiler match pattern rules to main CLAUDE.md (was only in worktree)
- Update sim framework description (750+ tests, add DNA/NTAG5 to IC list)
- Update "planned" → "in progress" for Python-driven card sim
- Fix firmware patch size: ~320 lines (was inconsistently ~340 on line 49)
2026-03-18 19:07:26 -07:00

26 KiB

Python-Driven Card Simulation for Proxmark3

For Future Claude Instances

This document describes how to add Python-controlled card simulation to pm3py via a minimal PM3 firmware patch. No new hardware — works on PM3 Easy and RDV4 as-is. The design was developed through analysis of ISO timing constraints and the existing sim framework in the feature/sim-framework worktree (257 tests, 11 phases complete).


Problem

pm3py's card simulation is fire-and-forget: Python sends static parameters (UID, ATQA, SAK), firmware runs autonomously, Python gets a status back. No way for Python to handle dynamic challenge-response (DESFire MUA, JCOP/EMV crypto, NTAG 5 Boost mutual auth, etc.).

Solution: Two Mechanisms

1. Response Table (Layer 3 — 86µs FDT constraint)

Python pre-computes a table of {command_pattern → response} entries and uploads it to firmware's BigBuf (~40KB) before sim starts. Firmware does a linear scan lookup (~1-2µs on 48MHz ARM7) and responds at wire speed. Handles:

  • Anticollision (REQA→ATQA, SELECT→UID/SAK)
  • RATS→ATS
  • Static APDUs (READ RECORD, SELECT AID, GET DATA)
  • Pre-computed MIFARE Classic auth (tag chooses nonce, pre-computes full exchange)
  • Any command where the response is known ahead of time

Sizing: NTAG 5 Link ~5KB, NTAG 5 Boost ~13KB, MIFARE Classic 1K ~8KB, JCOP/EMV static APDUs ~3.5KB. All fit in BigBuf.

2. WTX Relay (Layer 4 — seconds available)

ISO 14443-4 (ISO-DEP) has S(WTX) — the card requests more time and the reader must wait. On a Layer 4 table miss:

  1. Firmware sends S(WTX) to reader (spec-compliant, readers expect this)
  2. Firmware relays the APDU to Python over USB (~1ms)
  3. Python computes crypto response using transponder model (~1-5ms)
  4. Python sends response back to firmware (~1ms)
  5. Firmware sends response to reader. Total: ~3-7ms (well within WTX limits)

Handles: DESFire AES auth, JCOP GENERATE AC, EMV INTERNAL AUTHENTICATE, any dynamic Layer 4 APDU.

3. 15693 Retry Relay

ISO 15693 FDT is 311µs — too tight for USB but readers retry 2-3x per spec. On a table miss, firmware doesn't respond (reader times out), relays to Python, caches response, serves it on retry. Already spec'd in FIRMWARE_PATCH_15693_RELAY.md in the worktree.


Firmware Patch Specification

Overview

~320 lines of C across 6 files. All changes are additive — no existing behavior is modified. Existing sim commands continue to work exactly as before; the table/relay only activates when a table has been uploaded.

New Command IDs

Add to include/pm3_cmd.h:

// Response table management
#define CMD_SIM_TABLE_UPLOAD          0x0900  // Host→FW: upload response table (NG frame)
#define CMD_SIM_TABLE_CLEAR           0x0901  // Host→FW: clear table
#define CMD_SIM_TABLE_UPDATE          0x0902  // Host→FW: add/modify single entry

// Generic relay (14443-A Layer 4 WTX relay)
#define CMD_SIM_RELAY_EVENT           0x0910  // FW→Host: table miss, needs response
#define CMD_SIM_RELAY_RESP            0x0911  // Host→FW: response for relay event

// 15693-specific relay (retry-based)
#define CMD_HF_ISO15693_SIM_RELAY     0x0334  // FW→Host: unknown 15693 cmd received
#define CMD_HF_ISO15693_SIM_RELAY_RESP 0x0335 // Host→FW: response for 15693 relay

These IDs are in unused ranges — 0x0900-0x09FF is currently unallocated, and 0x0334-0x0335 sit between existing 15693 commands.

Response Table Structure

New file armsrc/sim_table.c + armsrc/sim_table.h:

#define SIM_TABLE_MAX_MATCH    32
#define SIM_TABLE_MAX_RESPONSE 64

typedef struct {
    uint8_t  match[SIM_TABLE_MAX_MATCH];      // command bytes to match
    uint8_t  match_len;                        // how many bytes to compare
    uint8_t  match_mode;                       // 0=exact, 1=prefix, 2=masked
    uint8_t  match_mask[SIM_TABLE_MAX_MATCH];  // for masked mode
    uint8_t  response[SIM_TABLE_MAX_RESPONSE]; // response bytes
    uint8_t  response_len;
    uint8_t  flags;                            // bit 0: append CRC
                                               // bit 1: stateful (consume after use)
    uint8_t  _pad[2];                          // alignment
} sim_table_entry_t;  // 132 bytes per entry

// API
void     sim_table_init(void);                          // point table at BigBuf
void     sim_table_clear(void);                         // zero all entries
uint16_t sim_table_count(void);                         // current entry count
bool     sim_table_add(const uint8_t *data, uint16_t len);  // deserialize + add entry
sim_table_entry_t *sim_table_lookup(const uint8_t *cmd, uint8_t cmd_len);  // find match

// BigBuf layout during sim:
// [0 .. sim_table_count * 132] = table entries
// Remaining BigBuf = available for other sim buffers

~300 entries fit in 40KB BigBuf. Lookup is linear scan — at 50 entries, ~1µs on ARM7. For larger tables, entries are ordered by frequency (most common commands first).

Modified ISO 14443-A Sim Handler

In armsrc/iso14443a.c, function SimulateIso14443aTag(). Insert at the top of the command processing loop, before the existing switch statement:

// --- BEGIN PATCH: Response table + WTX relay ---

// Check response table (works for both Layer 3 and Layer 4)
if (sim_table_count() > 0) {
    sim_table_entry_t *entry = sim_table_lookup(receivedCmd, len);
    if (entry) {
        if (entry->flags & 0x01) {
            // Append CRC-A
            AddCrc14A(entry->response, entry->response_len);
            EmSendCmd(entry->response, entry->response_len + 2);
        } else {
            EmSendCmd(entry->response, entry->response_len);
        }
        if (entry->flags & 0x02) {
            // Stateful: remove entry after use (for one-time auth nonces)
            entry->match_len = 0;  // mark as empty
        }
        continue;  // skip firmware's default handler
    }

    // Layer 4 (ISO-DEP) table miss → WTX relay
    // Detect ISO-DEP by checking if we've completed RATS (ATS sent)
    if (iso_dep_active) {
        // Send S(WTX) — request waiting time extension
        uint8_t wtx_req[2] = {0xF2, 0x3B};  // S(WTX), WTXM=59 (~4.8s max)
        AddCrc14A(wtx_req, 2);
        EmSendCmd(wtx_req, 4);

        // Wait for reader's S(WTX) response (reader echoes back)
        uint8_t wtx_ack[10];
        int wtx_ack_len = 0;
        GetIso14443aCommandFromReader(wtx_ack, &wtx_ack_len, 2000);

        // Relay APDU to host
        reply_ng(CMD_SIM_RELAY_EVENT, PM3_SUCCESS, receivedCmd, len);

        // Wait for host response
        PacketCommandNG rx;
        int res = PM3_SUCCESS;
        // Use existing data_available() + receive_ng() pattern
        uint32_t start = GetTickCount();
        while ((GetTickCount() - start) < 2000) {  // 2s timeout
            if (data_available()) {
                res = receive_ng(&rx);
                if (res == PM3_SUCCESS && rx.cmd == CMD_SIM_RELAY_RESP) {
                    EmSendCmd(rx.data.asBytes, rx.length);
                    break;
                } else if (res == PM3_SUCCESS) {
                    // Other command (BREAK_LOOP etc) — exit sim
                    goto out;
                }
            }
            WaitMS(1);
        }
        continue;
    }
}

// --- END PATCH ---

// ... existing switch(receivedCmd[0]) follows unchanged ...

Modified ISO 15693 Sim Handler

In armsrc/iso15693.c, function SimTagIso15693(). Changes as already spec'd in FIRMWARE_PATCH_15693_RELAY.md:

  1. Replace default: case — relay unknown commands to host, cache response
  2. Add data_available() check in main loop — receive cached responses from host
  3. Serve cached response when reader retries the same command

See the worktree doc for exact code (~38 lines).

Note (2026-03-17): The 15693-inventory-enhancements branch in /home/work/proxmark3 adds 16-slot inventory with anti-collision to SendRawCommand15693() (reader path). The sim handler SimTagIso15693() is not modified by those changes — our default: case insertion point is safe. However, the sim handler currently only responds to 1-slot inventory. If 16-slot reader support is needed in sim mode, that's a future enhancement (respond to EOF slot advancement) separate from the relay patch.

Command Dispatch

In armsrc/appmain.c, add to the command switch:

case CMD_SIM_TABLE_UPLOAD:
    sim_table_init();
    // Payload: concatenated serialized entries
    for (uint16_t off = 0; off < packet->length; off += 132) {
        sim_table_add(packet->data.asBytes + off, 132);
    }
    reply_ng(CMD_SIM_TABLE_UPLOAD, PM3_SUCCESS, NULL, 0);
    break;

case CMD_SIM_TABLE_CLEAR:
    sim_table_clear();
    reply_ng(CMD_SIM_TABLE_CLEAR, PM3_SUCCESS, NULL, 0);
    break;

case CMD_SIM_TABLE_UPDATE: {
    sim_table_add(packet->data.asBytes, packet->length);
    reply_ng(CMD_SIM_TABLE_UPDATE, PM3_SUCCESS, NULL, 0);
    break;
}

case CMD_SIM_RELAY_RESP:
    // Handled inline in sim handlers via receive_ng(), not here
    break;

case CMD_HF_ISO15693_SIM_RELAY_RESP:
    // Handled inline in iso15693.c sim loop
    break;

Firmware Files Changed

File Change Lines
include/pm3_cmd.h 7 new command IDs +7
armsrc/sim_table.h New: table struct + API declarations ~30
armsrc/sim_table.c New: init, clear, add, lookup ~170
armsrc/iso14443a.c Table check + WTX relay in SimulateIso14443aTag ~50
armsrc/iso15693.c Table check + retry relay in SimTagIso15693 ~40
armsrc/appmain.c Dispatch for 3 new commands ~20
armsrc/Makefile Add sim_table.o +1
Total ~320 lines

Python Implementation Plan

Phase 1: Merge Sim Framework

Merge the feature/sim-framework worktree into main. This provides all the transponder models (Tag14443A, MifareClassicTag, DesfireTag, Tag15693, etc.) that the table compiler needs.

Files: Everything under .worktrees/sim-framework/pm3py/sim/pm3py/sim/ Tests: Everything under .worktrees/sim-framework/tests/test_sim_*.pytests/ Verify: python -m pytest tests/ -v — all 257+ tests pass

Phase 2: Protocol Constants

Add new command IDs to pm3py/protocol.py:

# In Cmd enum:
SIM_TABLE_UPLOAD = 0x0900
SIM_TABLE_CLEAR = 0x0901
SIM_TABLE_UPDATE = 0x0902
SIM_RELAY_EVENT = 0x0910
SIM_RELAY_RESP = 0x0911
HF_ISO15693_SIM_RELAY = 0x0334
HF_ISO15693_SIM_RELAY_RESP = 0x0335

Phase 3: Table Compiler

New file pm3py/sim/table_compiler.py:

@dataclass
class TableEntry:
    match: bytes
    match_mode: int  # 0=exact, 1=prefix, 2=masked
    match_mask: bytes
    response: bytes
    flags: int  # bit 0: CRC, bit 1: stateful

    def serialize(self) -> bytes:
        """Pack into 132-byte firmware format."""
        buf = bytearray(132)
        buf[0:len(self.match)] = self.match
        buf[32] = len(self.match)
        buf[33] = self.match_mode
        buf[34:34+len(self.match_mask)] = self.match_mask
        buf[66:66+len(self.response)] = self.response
        buf[130] = len(self.response)
        buf[131] = self.flags
        return bytes(buf)

class ResponseTable:
    entries: list[TableEntry]

    def serialize(self) -> bytes:
        """Concatenate all entries for CMD_SIM_TABLE_UPLOAD."""
        return b"".join(e.serialize() for e in self.entries)

    def overlay(self, other: ResponseTable) -> None:
        """Merge another table — other's entries win on match conflict."""

    @classmethod
    def from_trace(cls, trace: list[TraceEntry]) -> ResponseTable:
        """Extract card responses from sniffed trace as table entries."""
        entries = []
        reader_cmds = [e for e in trace if e.direction == "reader"]
        tag_resps = [e for e in trace if e.direction == "tag"]
        for cmd, resp in zip(reader_cmds, tag_resps):
            entries.append(TableEntry(
                match=cmd.frame.data, match_mode=0, match_mask=b"",
                response=resp.frame.data, flags=0
            ))
        return cls(entries=entries)

class TableCompiler:
    @staticmethod
    def compile_14a(tag: Tag14443A) -> ResponseTable:
        """Walk tag state machine, enumerate all cmd→resp pairs."""
        table = ResponseTable(entries=[])
        # Anticollision
        table.entries.append(TableEntry(b"\x26", 0, b"", tag.atqa, 0))   # REQA
        table.entries.append(TableEntry(b"\x52", 0, b"", tag.atqa, 0))   # WUPA
        # SELECT cascade levels
        for cl, uid_chunk in tag._cascade_entries():
            table.entries.append(TableEntry(
                bytes([cl, NVB_ANTICOL]), 1, b"",  # prefix match
                uid_chunk + bytes([_compute_bcc(uid_chunk)]), 0
            ))
            # Full SELECT → SAK
            table.entries.append(TableEntry(
                bytes([cl, NVB_SELECT]), 1, b"",
                bytes([tag.sak]), 0x01  # append CRC
            ))
        # RATS → ATS (if Part 4)
        if hasattr(tag, 'ats') and tag.ats:
            table.entries.append(TableEntry(b"\xe0", 1, b"", tag.ats, 0x01))
        # Application-specific (delegate to tag model)
        if hasattr(tag, 'enumerate_responses'):
            for cmd, resp, flags in tag.enumerate_responses():
                table.entries.append(TableEntry(cmd, 0, b"", resp, flags))
        return table

    @staticmethod
    def compile_15693(tag: Tag15693) -> ResponseTable:
        """Compile 15693 tag responses — inventory, read/write blocks, sys info."""
        # Similar pattern: walk the tag's known command set

    @staticmethod
    def compile_mifare(tag: MifareClassicTag) -> ResponseTable:
        """Compile MIFARE Classic with pre-computed auth sequences."""
        # For each sector+key: pre-pick nT, compute full auth exchange

    @staticmethod
    def compile_desfire(tag: DesfireTag) -> ResponseTable:
        """Compile DESFire static APDUs. Auth goes through WTX relay."""
        # Static: SELECT, GetVersion, ReadData, GetFileSettings
        # Auth: NOT tabled — handled by WTX relay + Python crypto

Phase 4: Sim Session

New file pm3py/sim/sim_session.py:

class SimSession:
    """Manages an active card simulation with table + relay."""

    def __init__(self, transport: PM3Transport):
        self._t = transport
        self._active = False
        self._relay_task = None

    async def upload_table(self, table: ResponseTable) -> None:
        """Upload response table to firmware BigBuf."""
        data = table.serialize()
        # May need chunking if table > max NG payload (512 bytes)
        for i in range(0, len(data), 512):
            chunk = data[i:i+512]
            cmd = Cmd.SIM_TABLE_UPLOAD if i == 0 else Cmd.SIM_TABLE_UPDATE
            await self._t.send_ng(cmd, chunk)

    async def start_14a(self, tag: Tag14443A) -> None:
        """Compile table, upload, start 14443-A sim with WTX relay loop."""
        table = TableCompiler.compile_14a(tag)
        await self.upload_table(table)
        # Start sim (existing command, unchanged)
        self._active = True
        self._relay_task = asyncio.create_task(self._relay_loop(tag))
        await self._t.send_mix(Cmd.HF_ISO14443A_SIMULATE, ...)

    async def start_15693(self, tag: Tag15693) -> None:
        """Start 15693 sim with table + relay."""
        table = TableCompiler.compile_15693(tag)
        await self.upload_table(table)
        self._active = True
        self._relay_task = asyncio.create_task(self._relay_loop(tag))
        await self._t.send_ng(Cmd.HF_ISO15693_SIMULATE, ...)

    async def _relay_loop(self, tag: Transponder) -> None:
        """Background: listen for relay events, compute responses."""
        while self._active:
            try:
                resp = await self._t.read_response(timeout=0.1)
            except TimeoutError:
                continue
            if resp is None:
                continue
            if resp.cmd in (Cmd.SIM_RELAY_EVENT, Cmd.HF_ISO15693_SIM_RELAY):
                frame = RFFrame.from_bytes(resp.data)
                tag_resp = await tag.handle_frame(frame)
                if tag_resp:
                    reply_cmd = (Cmd.SIM_RELAY_RESP
                                 if resp.cmd == Cmd.SIM_RELAY_EVENT
                                 else Cmd.HF_ISO15693_SIM_RELAY_RESP)
                    await self._t.send_ng(reply_cmd, tag_resp.data)

    async def stop(self) -> None:
        self._active = False
        await self._t.send_ng_no_response(Cmd.BREAK_LOOP)
        if self._relay_task:
            await self._relay_task

Phase 5: Sniff-to-Sim Pipeline

Extend ResponseTable.from_trace() and add convenience methods:

# One-liner: sniff a card, then sim it
async def clone_and_sim(pm3, timeout=10):
    """Sniff a real card transaction, build table, sim it."""
    trace = await pm3.hf.a14.sniff(timeout=timeout)
    table = ResponseTable.from_trace(trace)
    # Extract UID/ATQA/SAK from anticollision in trace
    uid, atqa, sak = extract_card_identity(trace)
    session = SimSession(pm3._t)
    await session.start_14a_with_table(table, uid, atqa, sak)
    return session

Phase 6: Advanced Crypto (DESFire, JCOP, EMV)

The WTX relay means Python handles crypto in real-time for Layer 4 commands. The transponder models in the sim framework already implement the protocol logic — they just need the cryptography library for real key operations:

  • DesfireTag.handle_frame() already handles AuthenticateAES challenge-response
  • Add real AES-CMAC computation (currently stubbed with test vectors)
  • Add RSA/ECC for JCOP applets
  • EMV GENERATE AC with real session key derivation

Firmware Maintenance Strategy

The Problem

The PM3 firmware is actively developed (~3-5 commits/day). Our patch touches 6 files. We need to rebase cleanly on upstream updates.

Approach: Minimal, Isolated, Additive

The patch is designed to be maximally rebasing-friendly:

  1. sim_table.c/h are new files — never conflict with upstream
  2. pm3_cmd.h changes are appends — add IDs at the end, never modify existing
  3. appmain.c changes are new case statements — add at end of switch, never modify existing
  4. iso14443a.c changes are a single insertion point — block inserted before existing code, clearly delimited with // --- BEGIN/END PATCH --- comments
  5. iso15693.c changes are in the default: case — replace one small block

Git Workflow

upstream/master ─────────────────────────→ (PM3 official repo)
                 \
                  └── pm3py/sim-patch ────→ (our fork, rebased regularly)
                       ├── commit 1: "feat: add sim_table.c/h"
                       ├── commit 2: "feat: add CMD_SIM_* to pm3_cmd.h"
                       ├── commit 3: "feat: table lookup + WTX relay in iso14443a.c"
                       ├── commit 4: "feat: retry relay in iso15693.c"
                       └── commit 5: "feat: dispatch in appmain.c"

Each commit is atomic and touches one file (except commit 1 which adds two new files). This makes git rebase upstream/master trivial — conflicts can only happen in commits 2-5, and each is small enough to resolve by inspection.

Rebase Procedure

# Update from upstream
cd proxmark3-firmware/
git fetch upstream
git checkout sim-patch
git rebase upstream/master

# If conflicts (rare, since our changes are additive):
# - pm3_cmd.h: just re-add our IDs at the end
# - appmain.c: just re-add our cases at the end of switch
# - iso14443a.c: find SimulateIso14443aTag(), re-insert our block before the switch
# - iso15693.c: find default: case in SimTagIso15693(), re-apply

# Verify build
make clean && make -j

# Run PM3 test suite
./pm3 --test

Automated Rebase Check

Add a CI job (GitHub Actions) that:

  1. Fetches latest upstream PM3 master nightly
  2. Attempts git rebase of our patch branch
  3. If clean: push updated branch
  4. If conflicts: open an issue with the conflicting files
# .github/workflows/rebase-check.yml
name: Upstream Rebase Check
on:
  schedule:
    - cron: '0 6 * * *'  # daily at 6 AM
jobs:
  rebase:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: sim-patch
          fetch-depth: 0
      - run: |
          git remote add upstream https://github.com/RfidResearchGroup/proxmark3.git
          git fetch upstream master
          if git rebase upstream/master; then
            git push --force-with-lease origin sim-patch
            echo "Rebase clean"
          else
            git rebase --abort
            echo "::error::Rebase conflict — manual resolution needed"
            # Create issue via gh cli
            gh issue create --title "Upstream rebase conflict $(date +%Y-%m-%d)" \
              --body "The sim-patch branch has conflicts with upstream master. Files to check: $(git diff --name-only)"
          fi

Conflict Likelihood Assessment

File Conflict risk Why
sim_table.c/h Zero New files, never touched by upstream
pm3_cmd.h Low We append at end; conflicts only if upstream uses our IDs (unlikely, they're in 0x0900 range)
appmain.c Low We add cases at end of switch; conflicts only if upstream restructures the switch
iso14443a.c Medium Our insertion is in SimulateIso14443aTag() which upstream modifies occasionally. But our block is clearly delimited and self-contained
iso15693.c Medium We modify the default: case. If upstream adds new commands, the default: moves but our logic stays the same

Version Pinning

In pm3py's metadata, track the upstream commit we've validated against:

# pm3py/firmware_compat.py
UPSTREAM_TESTED_COMMIT = "abc123def"  # upstream commit hash
UPSTREAM_TESTED_DATE = "2026-03-17"
PATCH_VERSION = "1.0.0"

def check_firmware_compat(fw_version: str) -> bool:
    """Warn if connected PM3 firmware doesn't include our patch."""

Timing Reference

Scenario Layer Timing constraint Mechanism Who computes
REQA/WUPA → ATQA 3 86µs FDT Response table Firmware
SELECT → UID/SAK 3 86µs FDT Response table Firmware
RATS → ATS 3→4 86µs FDT Response table Firmware
Static APDUs (READ, SELECT AID) 4 WTX available Table (fast path) Pre-compiled by Python
MIFARE Classic AUTH 3 86µs FDT Table (pre-computed nonces) Pre-compiled by Python
DESFire AES AuthenticateAES 4 WTX available WTX + USB relay Python real-time
JCOP GENERATE AC / INTERNAL AUTH 4 WTX available WTX + USB relay Python real-time
15693 Inventory / Read 311µs FDT Response table Firmware
15693 Custom / unknown cmd 311µs FDT Retry relay Python real-time
NTAG 5 Boost mutual auth 311µs FDT Retry relay Python real-time

Key Design Decisions and Rationale

  1. Why not new hardware? The AT91SAM7S512 at 48MHz does table lookups in ~1-2µs. BigBuf (40KB) fits all realistic card models. USB round-trip (~1-2ms) works for WTX relay and 15693 retry relay. No hardware bottleneck exists.

  2. Why response table instead of live relay for 14443-A? ISO 14443-A Layer 3 FDT is 86µs. USB round-trip is ~1-2ms. Even with an SBC directly connected via SPI, Linux cannot guarantee <86µs response latency. The table eliminates this constraint.

  3. Why is the table sufficient? When simming a card, we choose our own random values (nonces, challenges). Since we know the keys and we pick our randomness, the full auth exchange is deterministic and pre-computable. The only truly untableable commands are ones where the reader sends unpredictable data — and those are Layer 4 APDUs where WTX is available.

  4. Why WTX instead of other approaches? WTX is part of the ISO 14443-4 spec. Real JavaCards use it routinely (RSA operations take 50-200ms). Readers must support it. It's the standard mechanism for "card needs time to compute."

  5. Why atomic single-file commits for the firmware? Makes rebasing against upstream trivial. Each commit can be resolved independently. New files never conflict. Appended code rarely conflicts.


Files Reference

Existing files to modify

File Change
pm3py/protocol.py Add 7 new Cmd entries
pm3py/transport.py May need multi-response read for relay loop
pm3py/sim/__init__.py Export new modules

New Python files

File Purpose
pm3py/sim/table_compiler.py ResponseTable, TableEntry, TableCompiler
pm3py/sim/sim_session.py SimSession — upload table, start sim, relay loop
pm3py/firmware_compat.py Firmware version check

Existing sim framework files (from worktree merge)

File Role in this plan
pm3py/sim/iso14443a.py Tag14443A — state machine that TableCompiler walks
pm3py/sim/iso15693.py Tag15693 — same for 15693
pm3py/sim/mifare.py MifareClassicTag — auth pre-computation
pm3py/sim/desfire.py DesfireTag — WTX relay handler for AES auth
pm3py/sim/replay.py TraceEntry — basis for from_trace()
pm3py/sim/frame.py RFFrame — used in relay loop
pm3py/sim/medium.py Medium ABC — SimSession implements this

Firmware files (in PM3 firmware fork)

File New/Modified
armsrc/sim_table.h New
armsrc/sim_table.c New
include/pm3_cmd.h Modified (7 lines appended)
armsrc/appmain.c Modified (~20 lines added)
armsrc/iso14443a.c Modified (~50 lines inserted)
armsrc/iso15693.c Modified (~40 lines)
armsrc/Makefile Modified (1 line)