# 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`: ```c // 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`: ```c #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: ```c // --- 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: ```c 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_*.py` → `tests/` **Verify**: `python -m pytest tests/ -v` — all 257+ tests pass ### Phase 2: Protocol Constants Add new command IDs to `pm3py/protocol.py`: ```python # 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`: ```python @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`: ```python 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: ```python # 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 ```bash # 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 ```yaml # .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: ```python # 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) |