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)
This commit is contained in:
michael
2026-03-18 19:07:26 -07:00
parent 75cb3d893d
commit ab3d37e7b3
2 changed files with 661 additions and 0 deletions

633
docs/PYTHON_SIM_DESIGN.md Normal file
View File

@@ -0,0 +1,633 @@
# 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) |