From 2d7ace07e2d80b3a7558ca6ed16d93404c8f40fb Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 10 Nov 2026 14:34:29 +0100 Subject: [PATCH] Documenta il formato PSB dei file Creo e aggiungi il parser di riferimento Reverse engineering di 9253000117a.prt.35: contenitore (offset dall'ultimo campo dell'intestazione, indice a catena), grammatica completa delle sezioni (id progressivi dei campi, tabella "dopo X viene Y", marcatori di chiusura), tabella dei double verificata sulla norma delle matrici, sezioni compresse e catalogo del contenuto. Parser Python in scripts/psb/. docs/history.md raccoglie stato, metodo e punti aperti per riprendere il lavoro. Co-Authored-By: Claude --- .gitignore | 1 + CHANGELOG.md | 1 + docs/README.md | 3 + docs/architettura.md | 4 + docs/formato-prt-geometria.md | 3 + docs/formato-psb.md | 371 ++++++++++++++++++++++++ docs/history.md | 160 ++++++++++ scripts/psb/batch.py | 25 ++ scripts/psb/ctx.py | 9 + scripts/psb/norms.py | 30 ++ scripts/psb/pbin.py | 11 + scripts/psb/prt.py | 40 +++ scripts/psb/psb.py | 529 ++++++++++++++++++++++++++++++++++ scripts/psb/tagmap.py | 47 +++ scripts/psb/unz.py | 75 +++++ 15 files changed, 1309 insertions(+) create mode 100644 docs/formato-psb.md create mode 100644 docs/history.md create mode 100644 scripts/psb/batch.py create mode 100644 scripts/psb/ctx.py create mode 100644 scripts/psb/norms.py create mode 100644 scripts/psb/pbin.py create mode 100644 scripts/psb/prt.py create mode 100644 scripts/psb/psb.py create mode 100644 scripts/psb/tagmap.py create mode 100644 scripts/psb/unz.py diff --git a/.gitignore b/.gitignore index 4394214..3b6f533 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ publish/ # Setup generato da Inno Setup (installer/PrepStep.iss) installer/Output/ +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bc018ae..ba3528e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ aderisce a [Semantic Versioning](https://semver.org/lang/it/). - `docs/formato-prt-feature.md`: decodifica (da reverse engineering) dell'albero delle feature di un `.prt`. - `docs/formato-prt-geometria.md`: numeri, quote e geometria degli schizzi di un `.prt`, con la tabella di cosa resta da decodificare. +- `docs/formato-psb.md` e `scripts/psb/`: formato di serializzazione completo delle sezioni dei file Creo (reverse engineering su `9253000117a.prt.35`) e parser Python di riferimento. ## [1.1.0] - 2026-10-07 diff --git a/docs/README.md b/docs/README.md index 4d8d3e1..bcaa87f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,8 @@ e cambia solo quando cambia il programma. - [`formato-asm.md`](formato-asm.md) — euristica testuale di fallback. - [`formato-prt-feature.md`](formato-prt-feature.md) — come leggere l'albero delle feature di un `.prt` (reverse engineering, non ancora nel codice). - [`formato-prt-geometria.md`](formato-prt-geometria.md) — numeri, quote e geometria degli schizzi di un `.prt`, con la tabella di cosa manca per determinarlo per intero. +- [`formato-psb.md`](formato-psb.md) — il formato di serializzazione di tutte le sezioni (`.prt` e `.asm`): contenitore, grammatica, numeri, cosa c'è in ogni sezione, con un parser di riferimento in `scripts/psb/`. +- [`history.md`](history.md) — stato del reverse engineering dei file Creo: cosa è provato, cosa manca, da dove ripartire. - [`glossario.md`](glossario.md) — i termini usati nei doc e nell'app. - [`../CHANGELOG.md`](../CHANGELOG.md) — storico delle versioni. @@ -41,6 +43,7 @@ Ogni informazione vive in **un** posto; altrove si mette un link, non una copia. | Formato dei record `.asm` e casi limite | [`formato-asm-record.md`](formato-asm-record.md) | | Albero delle feature di un `.prt` | [`formato-prt-feature.md`](formato-prt-feature.md) | | Quote, coordinate e geometria degli schizzi di un `.prt` | [`formato-prt-geometria.md`](formato-prt-geometria.md) | +| Come è scritta una sezione qualsiasi di un `.prt`/`.asm` | [`formato-psb.md`](formato-psb.md) | | Processo delle 5 fasi, mappa dei file | [`architettura.md`](architettura.md) | | Passi dell'utente | [`guida-utente.md`](guida-utente.md) | | Build, test, regole sui doc | [`sviluppo.md`](sviluppo.md) | diff --git a/docs/architettura.md b/docs/architettura.md index 16fab51..298bc81 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -286,6 +286,10 @@ Comandi di build del setup in [`rilascio.md`](rilascio.md). ## Script standalone (`scripts/`) +`scripts/psb/` fa eccezione: sono tre file Python (`prt.py`, `psb.py`, `unz.py`), lo strumento +del reverse engineering descritto in [`formato-psb.md`](formato-psb.md). Non referenziano il +progetto e l'app non li usa. + C# file-based apps (.NET 10, `dotnet run scripts/Nome.cs -- arg1 arg2`, nessun `.csproj`, dipendenze extra con `#:package Nome@versione` in testa al file): diff --git a/docs/formato-prt-geometria.md b/docs/formato-prt-geometria.md index 2fd4126..b9745db 100644 --- a/docs/formato-prt-geometria.md +++ b/docs/formato-prt-geometria.md @@ -4,6 +4,9 @@ Seguito di [`formato-prt-feature.md`](formato-prt-feature.md), che spiega come t del file e leggere l'**albero** delle feature (ordine, tipo, nome). Qui: come leggere i **numeri** (quote, coordinate) e come ricostruire da quelli la forma di una feature. +> **Aggiornamento.** La lettura campo per campo di tutte le sezioni, e la tabella completa dei +> numeri, sono in [`formato-psb.md`](formato-psb.md), che corregge la §1 di questo doc (vedi lì §9). + **Stato, da leggere prima di fidarsi.** Questo doc contiene *tutto quello che è stato decodificato e verificato*, e dice per ogni cosa quanto è provata. **Non basta ancora a determinare in modo univoco com'è fatto un `.prt`**: manca il posizionamento delle feature nello spazio e il diff --git a/docs/formato-psb.md b/docs/formato-psb.md new file mode 100644 index 0000000..e211c0d --- /dev/null +++ b/docs/formato-psb.md @@ -0,0 +1,371 @@ +# Il formato PSB: leggere tutte le sezioni di un `.prt`/`.asm` senza Creo + +Come è scritto **per intero** il contenuto di un file Creo, e come leggerlo campo per campo con +nome e valore, senza àncore a pattern. Ricavato per reverse engineering su +`9253000117a.prt.35` (Creo 2.0 M200, generazione `1632`, 2 MB) e poi provato sugli 89 file di +`raw/`. Il nome "PSB" è quello che il formato dà a se stesso (`PSB_Tokens`, §7). + +Questo doc **sostituisce** le parti di [`formato-prt-feature.md`](formato-prt-feature.md) e +[`formato-prt-geometria.md`](formato-prt-geometria.md) che leggevano i record cercando sequenze +di byte: quelle àncore restano valide come esempi, ma il modo giusto di leggere è quello qui +sotto. Le correzioni puntuali a quei due doc sono in §9. + +Implementazione di riferimento: [`scripts/psb/psb.py`](../scripts/psb/psb.py) (parser), +[`scripts/psb/prt.py`](../scripts/psb/prt.py) (contenitore), +[`scripts/psb/unz.py`](../scripts/psb/unz.py) (sezioni compresse). È Python (solo libreria +standard), non C#: è lo strumento con cui è stato fatto il reverse engineering, non codice +dell'app. Nessun codice di `PrepStep/` lo usa. + +**Livelli di prova** (come negli altri doc): + +1. **Verifica interna forte**: la sezione si legge fino all'ultimo byte, con i conteggi degli + array rispettati; i numeri reali sono controllati su vincoli geometrici (righe delle matrici + di rotazione a norma 1: 5397 su 5397 in `9253000117a.prt.35`). +2. **Su scala**: 2152 sezioni su 2226 lette fino in fondo negli 89 file di `raw/` (tutte le + generazioni, da `1322` a `2063`). Le 74 che falliscono sono in §8. +3. **Contro Creo**: nessuna. Nessun valore di questo doc è stato confrontato con il modello + aperto in Creo. I nomi dei campi sono quelli scritti nel file; il loro *significato* è + dedotto dal nome. + +## 1. Il contenitore + +``` +#UGC:2 PART 1632 1060 805 1 1 15 3300 2015440 000002cc \ +... +#-END_OF_UGC_HEADER +#Creo TM 2.0 (c) 2020 by PTC Inc. All Rights Reserved. M200 +#UGC_TOC 2 32 81 17####... +DEPDB_DATA ab1 19 d 1632 9 -1 05e1 260f#### +... +NEXT_TOC_ENTRY 1f8679 3cc 0 1632 1 -1 0000 0000#### +``` + +- **L'ultimo campo della prima riga** (`000002cc`) è la lunghezza dell'intestazione: è la + costante da sommare a tutti gli offset dell'indice. `inizio = 0x2cc + offset`. +- Ogni sezione comincia con una riga `#\n`, **compresa** nell'offset e nella lunghezza. + Il corpo è quindi `[0x2cc + offset + len(nome) + 2, 0x2cc + offset + lunghezza)`. +- **L'indice è a catena**: la voce `NEXT_TOC_ENTRY` punta (stessa regola) a un secondo + `#UGC_TOC`, in fondo al file, con altre sezioni. Vanno seguiti tutti fino a `#END_OF_UGC`. +- Campi di una riga: `nome offset lunghezza dimensione generazione tipo metodo crc crc`. + `dimensione` è la lunghezza del corpo **decompresso** (per le sezioni in chiaro è + `lunghezza − len(nome) − 2`). + +Il campo `tipo` in questo file: + +| Tipo | Contenuto | Sezioni | +|---|---|---| +| `9` | PSB in chiaro | quasi tutte | +| `a` | PSB compresso Unix `compress` (`1f 9d 10`, LZW a 16 bit): si decomprime e si legge come le altre; la dimensione ottenuta coincide con quella dichiarata | `ND:0:*`, `SolidPersistTable`, `SolidPrimdata` | +| `c` | comincia con `1f 9d 10` ma **non** è un LZW standard | `DispDataTable` (non letto) | +| `11` | JPEG (`ff d8 ff e0 ... JFIF`): la miniatura del modello | `THMB_IMG_MAIN` | +| `b` | non decodificato: primi 4 byte in chiaro (`e0 00 Fi`, `e0 00 Fe`), poi entropia massima, stessa lunghezza del testo in chiaro | `IndentTag`, `FeatRefData` | + +Il tipo `b` ha lo stesso aspetto delle sezioni "tipo `11`" dei file Wildfire descritte in +`formato-prt-feature.md` §1.3. Resta il blocco principale. + +## 2. La grammatica + +Una sezione è un albero di campi. I byte `e0`-`ff` sono marcatori, gli altri dati. + +| Byte | Significato | +|---|---| +| `e0 \0` | **dichiara** un campo e ne dà subito il valore | +| `e1` | puntatore nullo | +| `e2`, `e3` | apre una struttura (non distinte dal lettore) | +| `e4`, `e5`, `e6` | dentro un array: ripeti il valore precedente 1, 2, 3 volte | +| `e7 ` | dentro un array: ripeti il valore precedente `n` volte | +| `e8`-`ed` | forme "grezze" dei numeri (§3) | +| `f0`-`f3` | chiudi 0, 1, 2, 3 contenitori (§2.3) | +| `f4 ` | chiudi `n` contenitori | +| `f5` | prefisso di un intero: bit 31 acceso | +| `f6` | intero −1 | +| `f7 ` | il prossimo valore è del campo numero `id` (§2.2) | +| `f8 ` | array a una dimensione di `n` elementi | +| `f9 ` | array a due dimensioni (`a`×`b` numeri; per gli array di strutture `a` è il totale) | +| `fa ..` | array a `k` dimensioni | +| `fb` | segue l'intestazione di un array di strutture, prima del primo elemento | +| `fc ` | array a due dimensioni: cambia solo il numero di righe, colonne come l'ultima volta | + +Tipi di campo (il byte dopo `e0`): + +| Tipo | Valore | +|---|---| +| `00` | puntatore: `e1`, struttura (`e2`/`e3`), o array di strutture (`f8 n [f7 id] [fb]`) | +| `01`, `05`, `08`, `09` | intero (§3.1). `09` è senza segno (flag); `05`/`08` non distinti | +| `02` | `double` (§3.2) | +| `06` | `float` a 32 bit (§3.3) | +| `04`, `07` | array di byte: ogni byte vale se stesso; un byte ≥ `e0` è preceduto da `e8` | +| `03`, `0a` | stringa Latin1 terminata da `\0`, o `e1` | + +Un campo numerico o stringa può essere un array: `f8`/`f9`/`fa` prima dei valori. + +### 2.1 Gli id dei campi + +Ogni `e0` riceve un **numero progressivo** (dal primo `e0` della sezione = 1). Lo stesso nome in +due strutture diverse ha due id diversi. È la chiave di tutto: `f7 ` si riferisce a quel +numero. Verifica: l'intestazione di ogni array di strutture ripete l'id del proprio campo +(`unit_arr` è il 20° campo di `BasicText` e l'array è `f8 03 f7 14 fb`). + +### 2.2 Modalità posizionale: la tabella "dopo X viene Y" + +Un campo è scritto col nome **una sola volta**. Dopo, i valori arrivano nudi. Per sapere di +quale campo è un valore nudo, chi scrive e chi legge tengono la stessa tabella: + +``` +next[id] = l'id del campo scritto subito dopo, l'ultima volta che id è stato scritto +``` + +- Se il prossimo campo è `next[ultimo]`, il valore è scritto e basta. +- Altrimenti è preceduto da `f7 `. +- La tabella è **piatta** (non tiene conto dell'annidamento) e si aggiorna a ogni campo, anche + dopo un `f7` e dopo un campo dichiarato per nome. +- Ogni elemento di un array di strutture è una nuova occorrenza del campo-array: per questo + dopo il primo elemento si trova `f1 f7 ` (insegna che "dopo l'ultimo campo + dell'elemento viene un altro elemento") e dopo gli altri non c'è nulla. +- Un campo mai dichiarato e nullo **non viene scritto**: compare col nome la prima volta che ha + un valore, anche in mezzo a un record posizionale. + +**Lunghezza degli array in modalità posizionale**: `f8 ` compare solo se `n` è diverso +dall'ultima volta che quel campo è stato scritto. Eccezione osservata: un array di stringhe più +corto del precedente arriva senza `f8` (`edit_header` in `BasicData`: 6 stringhe, poi 5); il +lettore si ferma al primo byte < `20`. È un'euristica, non una regola capita. + +### 2.3 Dove va un campo: la pila dei contenitori + +Strutture, array e loro elementi sono contenitori su una pila. Tre regole: + +1. **Valore nudo senza `f7`**: va nel contenitore più vicino alla cima che possiede quel campo; + quelli sopra si chiudono in silenzio. +2. **Campo con nome o `f7`, con marcatore `f0`-`f4`**: si chiudono esattamente quel numero di + contenitori (`f0` = nessuno). Il marcatore sta **dopo** il nome del campo. +3. **Campo con nome o `f7`, senza marcatore**: se il campo atteso (`next[ultimo]`) appartiene al + contenitore **padre** di quello in cui è stato scritto l'ultimo campo, quest'ultimo si chiude; + altrimenti non si chiude nulla. + +Casi particolari, tutti osservati: + +- **Alias di tipo**: `e3 f7 ` con `id` di un'altra struttura vuol dire "questa struttura ha + lo stesso schema di quella" (`next_feat_ptr 00 f2 e3 f7 03`: il record successivo usa i campi + del primo). +- **Puntatori "al successivo"** (`next_feat_ptr`, `next_cntr_ptr`, `relcode_tree_next`): sono + scritti **dopo aver chiuso** la struttura a cui appartengono, quindi al livello del padre. Le + liste concatenate risultano così sequenze piatte, non annidamenti. +- **Campi a livello di array**: `next` in `ref_part_table` è dichiarato dopo aver chiuso il + primo elemento e ricompare (`e1`) dopo ogni elemento. +- **Varianti di unione**: il nome porta la variante tra parentesi (`data(sld_trace)`, + `srf_prim_ptr(cylinder)`, `typed_data(MTTyped_CreateData)`). Lo stesso id può comparire in + contenitori diversi. + +**Cosa è solido e cosa no.** La sequenza *campo → valore* dipende solo da `next[]` e dai tipi: +è la parte provata (tutte le sezioni lette fino all'ultimo byte). L'annidamento dipende dalle +regole 1-3, che sono ricavate per tentativi: in `AllFeatur`, `MdlStatus` e `FeatDefs` di +questo file la pila alla fine non torna a zero (un contenitore in più o in meno). I valori sono +giusti, il livello di qualche campo può non esserlo. + +## 3. I numeri + +### 3.1 Interi + +| Primo byte | Valore | +|---|---| +| `00`-`7f` | il byte | +| `80`-`bf` | 2 byte, 14 bit | +| `c0`-`df` | 3 byte, 21 bit | +| `e8` / `e9` / `ea` / `eb` | 1 / 2 / 3 / 4 byte grezzi (`e9` ipotizzato) | +| `f5 ` | intero con il bit 31 acceso (`hostid` `f5 eb 2c 14 fd cf` = `ac14fdcf` = 172.20.253.207) | +| `f6` | −1 | + +### 3.2 `double` + +Il primo byte è un tag. `T` è il byte alto del `double` IEEE big-endian. + +| Tag | Byte dopo | Valore | +|---|---|---| +| `00`-`03` | 1 costante + (tag+1) byte | la costante, con gli ultimi (tag+1) byte sostituiti: `00 0c 9a` = 0,1 esatto; `02 11 66 89 30` = π con rumore | +| `04`-`18` | 0 | costante (tabella sotto) | +| `19`-`2f` | vedi modi | positivi, 5 tag per ogni `T` da `3c` a `40`: `T = 0x3c + (tag − 0x17) / 5` | +| `30`-`48` | vedi modi | negativi, `T` da `bc` a `c0`: `T = 0xbc + (tag − 0x30) / 5` | +| `49`-`a4` | 6 | positivi: i primi 2 byte sono `0x3f75 + tag` (da `3fbe` a `4019`) | +| `a5`-`df` | 6 | negativi: i primi 2 byte sono, in ordine, `bfd0`...`c00d` **saltando** `bfd2`, `bfdf` e `bffe` | +| `ea` | 3 | 3 byte alti, il resto zero | +| `ec` | 7 | 7 byte alti, l'ultimo zero | +| `ed` | 8 | il `double` intero | + +Modi dei tag a famiglie di 5 (resto di `(tag − base) / 5`): + +| Resto | Byte dopo | Composizione | +|---|---|---| +| 4 | 2 | `T b1 b2` e 5 zeri (`2f 59 00` = 100) | +| 3 | 2 | `T b1 b2` e `b2` ripetuto (`2e 1b 33` = 6,8) | +| 2 | 7 | `T` e 7 byte | +| 1 | 6 | `T`, 6 byte, uno zero | +| 0 | 3 | `T`, 3 byte, 4 zeri | + +La famiglia di `T = 3c` ha solo i tag `19`, `1a`, `1b`: `17` e `18` sono costanti. + +Costanti a un byte: + +| Tag | Valore | Prova | +|---|---|---| +| `07` / `0a` / `0c` | 0,001 / 0,01 / 0,1 | tolleranze di default per numero di decimali (`BasicData`), ISO 2768 sulle quote | +| `0d` | −0,5 | **ipotesi** (un solo caso) | +| `0e` / `0f` / `10` | 0,5 / 1 / −1 | matrici ortonormali | +| `11` / `12` / `13` / `14` | π / π/2 / 2π / 3π/2 | estremi angolari dei cilindri; stesso rumore (`+0x112e0c` sulla mantissa) nelle forme `02 xx ...` | +| `15` / `16` / `17` | −π / −π/2 / −2π | idem | +| `18` | 0 | ovunque | +| `04` | **ignoto** | compare come componente di tangente e come estremo di un inviluppo | +| `05`, `06`, `08`, `09`, `0b` | mai visti | | + +Prove: le righe e colonne di tutte le matrici `local_sys` hanno norma 1 con questa tabella +(5397 su 5397 in questo file); i prefissi `3fd0`-`3fef` e `bfd0`-`bfef` sono verificati uno a +uno sugli 89 file risolvendo il modulo dalla norma. Fuori da quell'intervallo valgono solo +alcuni punti fermi: `86` = `3ffb` (√3), `8c` = `4001` (2,2046 lb/kg), `8f` = `4004` (5π/6), +`ce` = `bffb`, `d9` = `c007` (−3 + ε). Il buco `bffe` (e non `bffd`) è una **scelta non +provata**: uno dei due manca, non so quale. + +### 3.3 `float` a 32 bit (tipo `06`) + +Usati nei dati di visualizzazione (`SolidPrimdata`, `SolidPersistTable`). + +| Tag | Byte dopo | Valore | +|---|---|---| +| `00` / `0b` / `28` | 0 | 0 / +1 / −1 (segno ricavato dalle normali delle facce estreme) | +| `30`-`3f` | 3 | byte alto `0x37 + (tag − 0x30)`, positivi | +| `40`-`4f` | 3 | byte alto `0xb7 + (tag − 0x40)`, negativi | +| `50`-`a3` | 2 | positivi: i primi 2 byte da una **tabella non lineare** | +| `a4`-`df` | 2 | negativi: idem | +| `e8` / `e9` / `ea` / `eb` | 1 / 2 / 3 / 4 | byte alti grezzi, il resto zero | + +La tabella dei tag `50`-`df` **non è ricostruita**: sono prefissi scelti per frequenza, non una +formula. Voci ricavate dalle normali unitarie: `63`=`3e1d`, `67`=`3e46`, `68`=`3e80`, +`69`=`3e84`, `6a`=`3ea6`, `6d`=`3f08`, `6e`=`3f0a`, `6f`=`3f0b`, `70`=`3f0c`, `71`=`3f0d`, +`72`=`3f0e`, `73`=`3f0f`, `74`=`3f10`, `75`=`3f17`, `76`=`3f21`, `77`=`3f57`; `bc`=`be80`, +`bd`=`be84`, `bf`=`becf`, `c0`=`bee4`, `c1`=`beea`, `c4`=`bf0a`, `c5`=`bf1c`, `c6`=`bf20`, +`c7`=`bf27`, `c8`=`bf32`, `ca`=`bf60`, `cb`=`bf66`, `cc`=`bf7a`. Il parser di riferimento usa +una formula lineare **sbagliata** per questi tag: la lunghezza è giusta (la lettura prosegue), +il valore no. Finché la tabella non è completa la mesh non si può esportare. + +## 4. Algoritmo di lettura + +``` +pila = [], ultimo = nessuno, next = {}, campi = {} +finché ci sono byte: + m = marcatore f0-f4 se presente + se il byte è e0: # campo con nome + leggi tipo e nome; m = marcatore dopo il nome, se presente + applica la regola 2 (m presente) o 3 (m assente) di §2.3 + id = nuovo numero; il campo appartiene al contenitore in cima + altrimenti: + se m presente: chiudi m contenitori + se il byte è f7: id = intero che segue (poi regola 3 se m assente) + altrimenti: id = next[ultimo] + porta in cima il contenitore di id (regola 1; alias se la cima è una struttura vuota) + next[ultimo] = id; ultimo = id + leggi il valore secondo il tipo di id; strutture e array vanno sulla pila +``` + +Uso: + +```powershell +python scripts/psb/prt.py 9253000117a.prt.35 # indice delle sezioni +python scripts/psb/psb.py 9253000117a.prt.35 '*' out # ogni sezione in out/.txt +python scripts/psb/psb.py 9253000117a.prt.35 FullMData out +python scripts/psb/unz.py 9253000117a.prt.35 out # decomprime le sezioni .Z in out/*.bin +``` + +## 5. Cosa c'è in ogni sezione + +Nomi di radice e campi come nel file. "Letto" = arriva all'ultimo byte. + +| Sezione | Radice | Contenuto | +|---|---|---| +| `BasicText` | `Sld_BasicText` | proprietà di massa (`density`, `volume`, `cg`, `inertia`, `surfarea`), unità (`unit_arr`: `MM`, `KILOGRAM`, `SECS`; `principal_sys_units` = `millimeter Kilogram Sec (mmKs)`) | +| `BasicData` | `Sld_BasicData` | tolleranze di default, tabelle ISO (`iso_tol_tbls_ptr`, classi `Fine`...`Very Coarse`), tabelle dei fori (`holes_arr`) | +| `FullMData` | `Sld_FullData` | **quote** (`dim_array`, 788 voci: `dim_type`, `feat_id`, `value`, `tol_ptr.value`), modelli referenziati (`ref_part_table`) | +| `AllFeatur` | `Sld_Features` | lista delle feature (`first_feat_ptr`/`next_feat_ptr`): `id`, `feat_type_ptr.type`, `sect_name`, tabelle delle geometrie create, date | +| `FeatDefs` | `Sld_FeatDefs` | definizione di ogni feature (`feat_defs_`), schizzi, istruzioni di posizionamento | +| `FeatDefsIndex` | `Sld_FeatDefsIndex` | i tipi di feature presenti (`feat_defs_index`: 923, 979, 1084, 917, 916, 914, 926, 1104, 1232, 911, 949, 1070, 913, 1052) | +| `MdlStatus` | `Sld_MdlStatus` | storico: un record per azione, con utente, data e nome (`Extrude 9`, `Specchiare 4`) | +| `VisibGeom` / `NovisGeom` | `Sld_VisGeom` / `Sld_NonVisGeom` | **B-rep**: `srf_array` (superfici: `geom_id`, `geom_type`, `feat_id`, `srf_prim_ptr(plane/cylinder/...)` con `local_sys` e raggio), contorni (`next_cntr_ptr`), `crv_array` (spigoli con i punti) | +| `ActEntity` | `Sld_Entities` | entità di riferimento: punti (`PNT0`...), linee, sistemi di coordinate | +| `GeomDepen` | `Sld_GeomDepend` | ultima feature rigenerata, data dell'ultima rigenerazione, proprietà delle geometrie | +| `LargeText` | `Sld_LargeText` | **nomi** (`names_table`), **parametri** (`symtbl`: `DENOMINAZIONE`, `PRO_MP_*`...), **relazioni** come albero (`relset_code_tree`) con il testo sorgente e i commenti | +| `DispCntrl` | `Sld_DispCntrl` | layer (`01___PRT_ALL_DTM_PLN`, ...) con gli id contenuti e le regole | +| `FamilyInf` | `Sld_FamilyInfo` | family table (`gen_name`, `info_items`) | +| `Xsections` | `Sld_Xsections` | sezioni trasversali, ognuna con la propria geometria (`xsec_geom`) | +| `ModelView#n` | `View` | viste salvate: matrici `orient`/`local_sys` 4×4, nome | +| `NeuPrtSld` | `Sld_NeutPart` | dati "neutral" della parte | +| `ColorSchemeInfo*`, `DispDataColor`, `AssMrgPrt`, `PipeInfos`, `NeuAsmSld`, `DEPDB_DATA`, `THMB_IMG_INDX` | | piccole o vuote | +| `SolidPrimdata` (compressa) | `pgl_primdata_group_table` | **geometria di visualizzazione**: archi (`prim_arc`: raggio, angoli, centro, piano), linee e la **mesh** (`mv_p_NxNyNzxyz`: normale + vertice, `float`) | +| `SolidPersistTable` (compressa) | `pgl_persist_table` | ingombro (`xyz_min_max`: x ±45,57, y −74,07..90, z −807..0), `bounding_radius` 414,27 | +| `ND:0:Model_*`, `ND:0:UnitSystemDef_*`, `ND:0:PDMTrail_*` (compresse) | `PSB_Tokens` | §7 | + +Valori letti da questo file, come esempio (nessuno confrontato con Creo): densità +7,85·10⁻⁶ kg/mm³, volume 3,11·10⁶ mm³, dipendenze `9053004005` e `9253018001`. + +## 6. Cosa cambia rispetto alla lettura a pattern + +- **Il primo record non è più un caso speciale**: è solo quello che dichiara i campi. "La prima + quota dell'array può mancare" (`formato-prt-geometria.md` §2) non vale più. +- **I record posizionali si leggono con i nomi**: ogni valore ha il nome del suo campo. +- **Le quote**: `dim_array` → `dim_dat_ptr.value`, `tol_ptr.value` (i due scostamenti: 0,1 per + la quota 2; 0,2 per 20; 0,3 per 59, cioè ISO 2768-m), `dim_cosm_ptr`. +- **Le unità** si leggono (`BasicText`), non vanno più assunte. + +## 7. Le sezioni `ND:0:*`: `PSB_Tokens` + +Decompresse sono un PSB con una sola struttura: + +``` +PSB_Tokens: + format = 0 + type_har = "MOMOIOIOIOIOFCOFOM..." un carattere per token + int_har = [0, 1, 2, 3, 4, 5, -1, ...] + double_xar = [-1.0, 0.001, 1e-09, ...] + string_xar = ['ndmodel_l05::Model_L05', 'Descriptor', 'Name', '9253000117a', 'Type', 'prt', ...] +``` + +È un secondo livello di serializzazione: `type_har` è la sequenza dei token, gli altri tre array +sono i valori a cui i token attingono in ordine. **Il significato delle lettere non è +decodificato** (`M`, `O`, `I`, `F`, `C`, `P`, `Q`, `N`, `H`, `R`, `S`, `U`). Le stringhe però si +leggono già: nome e tipo del modello, dipendenze, nomi dei parametri. + +## 8. Dove non regge + +| Problema | Dove | Stato | +|---|---|---| +| Sezioni tipo `b` (`IndentTag`, `FeatRefData`) e tipo `11` dei file Wildfire | tutti i file | **illeggibili** | +| `DispDataTable` (tipo `c`) | file recenti | non letto | +| Tabella dei prefissi `float` `50`-`df` | `SolidPrimdata` | parziale: la mesh non è esportabile | +| Costante `double` `04` | geometria | valore ignoto | +| Annidamento | `AllFeatur`, `MdlStatus`, `FeatDefs` | la pila non torna a zero a fine sezione | +| Alberi bilanciati (`lesser_child`/`greater_child`/`balance_factor`) | `LargeText` di 24 file di `raw/` | il lettore si ferma: la regola dei puntatori "al successivo" non basta | +| `srf_flip_dat`, `end2`, `backup_size` "contenitore non in pila" | 26 sezioni di `raw/` | stesso tipo di errore: è la pila, non i valori | +| Array di stringhe senza `f8` | `BasicData` | euristica (§2.2) | +| `PSB_Tokens` | `ND:0:*` | solo gli array, non la struttura | +| Significato dei campi | ovunque | dedotto dal nome, mai verificato in Creo | + +Sugli 89 file di `raw/`: 2152 sezioni lette per intero, 74 no. Tutti i fallimenti sono del tipo +"contenitore non in pila" o "pila vuota": la sequenza dei valori fino a quel punto è corretta. + +## 9. Correzioni agli altri doc + +- `formato-prt-feature.md` §1.1: la costante `K` non va ricavata dall'ultima sezione: è + l'ultimo campo della prima riga del file, e la sezione comincia con la riga `#Nome`. L'indice + può essere a catena (`NEXT_TOC_ENTRY`). La formula data lì funziona solo per caso. +- `formato-prt-feature.md` §1.2: le sezioni `ND:0:*` decompresse non sono XML ma `PSB_Tokens` + (§7); i nomi tipo SOAP sono le stringhe di `string_xar`. +- `formato-prt-geometria.md` §1: la tabella dei tag è sostituita da §3.2. In particolare + `e4`-`e7` non sono "zeri" ma **ripetizioni del valore precedente**, e i tag `49`-`df` + ("non decodificato") sono i prefissi a 2 byte. +- `formato-prt-geometria.md` §1: `UnitSystemDef` non serve per le unità, stanno in `BasicText`. + +## 10. Come proseguire + +1. **Confrontare con Creo** un file piccolo: è l'unica prova che manca a tutto il doc. +2. Completare la tabella dei `float` raccogliendo le normali su più file (stesso metodo di + §3.3): sblocca l'esportazione della mesh senza Creo. +3. Sistemare la pila sugli alberi di `LargeText` (§8), poi portare il parser in C# accanto ad + `AsmRecordReader`, con la stessa regola: se la sezione non si legge fino in fondo, + `InvalidDataException`. +4. Sezioni tipo `b`: servono due salvataggi dello stesso modello che differiscono di poco. diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000..58d66a1 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,160 @@ +# Reverse engineering dei file Creo: stato e ripresa + +Diario di passaggio di consegne per il lavoro sul formato dei `.prt`/`.asm`. Serve a chi riprende +(anche in un'altra sessione) per sapere **dove siamo, cosa è provato, da dove ripartire**, senza +rifare la strada. Il *risultato* del lavoro sta in [`formato-psb.md`](formato-psb.md): qui c'è +solo lo stato, le decisioni e i vicoli ciechi. Quando un punto si chiude, il contenuto va nel doc +di formato e qui resta una riga. + +Ultimo aggiornamento: 2026-11-10, ramo `reverse-engineering-creo`. + +## Stato in una riga + +Il formato di serializzazione (PSB) è decodificato e tutte le sezioni in chiaro di +`9253000117a.prt.35` si leggono fino all'ultimo byte con nome e valore di ogni campo; **nessun +valore è stato ancora confrontato con Creo**, e tre cose bloccano "tutti i dati senza Creo" +(vedi "Cosa manca"). + +## Da non perdere + +- **Niente è committato.** In sospeso: `docs/formato-psb.md`, `docs/history.md`, `scripts/psb/`, + e le modifiche a `CHANGELOG.md`, `docs/README.md`, `docs/architettura.md`, + `docs/formato-prt-geometria.md`. +- `9253000117a.prt.35` è nella root, non tracciato: è il campione di tutto il lavoro. Decidere + se tenerlo nel repo (2 MB) o in `raw/`. +- `scripts/psb/__pycache__/` va ignorato, non committato. +- `docs/formato-prt-feature.md` contiene byte NUL: gli strumenti di modifica lo rifiutano. Le + correzioni che lo riguardano sono in `formato-psb.md` §9, non applicate al file. Va + ripulito dai NUL (conservando la codifica) prima di poterlo correggere. + +## Come ripartire in cinque minuti + +Dalla root del repo, con Python 3.12 (solo libreria standard): + +```powershell +python scripts/psb/prt.py 9253000117a.prt.35 # indice delle sezioni +python scripts/psb/psb.py 9253000117a.prt.35 '*' out # dump di ogni sezione in out/ +python scripts/psb/psb.py 9253000117a.prt.35 LargeText - t # traccia campo per campo (debug) +python scripts/psb/unz.py 9253000117a.prt.35 out # decomprime le sezioni .Z +python scripts/psb/pbin.py out/SolidPrimdata.bin # legge una sezione decompressa +python scripts/psb/batch.py "raw/*" # regressione sugli 89 file +python scripts/psb/norms.py 9253000117a.prt.35 # controllo norma 1 delle matrici +python scripts/psb/tagmap.py 9253000117a.prt.35 "raw/*" # tag double -> prefisso, dalle norme +python scripts/psb/ctx.py raw/x.prt.1,VisibGeom,cf8 # hexdump attorno a un offset +``` + +Valori attesi oggi, da usare come regressione prima e dopo ogni modifica al parser: + +| Controllo | Atteso | +|---|---| +| `psb.py 9253000117a.prt.35` | 29 sezioni `OK`, 5 no: `AllFeatur` (`pop underflow` sull'ultimo byte), `MdlStatus` e `FeatDefs` (pila non a zero), più l'intestazione delle due ultime | +| `norms.py` | `good rows 5397 bad 0` | +| `batch.py "raw/*"` | 2152 sezioni lette, 74 no (misurato **prima** delle ultime correzioni ai tag: va rilanciato) | + +Se `norms.py` dà righe "bad" dopo una modifica, si è rotta la tabella dei `double`: è il +controllo più sensibile che abbiamo. + +## Cosa è provato, e come + +| Cosa | Prova | +|---|---| +| Contenitore (offset = ultimo campo della prima riga, riga `#Nome` inclusa, indice a catena) | tutte le sezioni cominciano con `e0 00 ` | +| Grammatica e tabella "dopo X viene Y" | lettura fino all'ultimo byte di tutte le sezioni; l'id nell'intestazione degli array coincide con quello calcolato | +| Tag dei `double` | norma 1 su 5397 righe; prefissi `3fd0`-`3fef` e `bfd0`-`bfef` verificati uno a uno sugli 89 file | +| Costanti π, π/2, 2π, 3π/2 e negative | stesso rumore di mantissa nelle forme modificate (`02 xx ...`) | +| Sezioni `.Z` | la dimensione decompressa coincide con quella dichiarata nell'indice | +| Significato dei campi | **nessuna**: dedotto dai nomi | + +## Cosa manca, in ordine di valore + +1. **Confronto con Creo.** Aprire un file piccolo, annotare quote, parametri, massa, posizione + di una faccia, e confrontare con il dump. Finché non c'è, tutto il doc è "livello 1 e 2". +2. **Tabella dei `float` (tag `50`-`df`).** È una tabella di prefissi scelti per frequenza, non + una formula. Il parser usa `FBASE` lineare, **sbagliato** per quei tag (lunghezza giusta, + valore no). Senza, la mesh di `SolidPrimdata` (`mv_p_NxNyNzxyz`) non si esporta. Metodo che + ha funzionato per le 29 voci note: normali unitarie, risolvere il modulo dalla norma. Va + ripetuto su tutti i file di `raw/` (ognuno ha `SolidPrimdata`), poi sostituire `FBASE` con + un dizionario e far fallire il parser sui tag ignoti invece di inventare. +3. **La pila dei contenitori.** I valori sono giusti, l'annidamento no in alcuni punti: + - `LargeText` di 24 file di `raw/`: alberi bilanciati (`lesser_child`, `greater_child`, + `balance_factor`, `backup_size`). La regola `post` (puntatore "al successivo" scritto al + livello del padre) copre un solo campo per struttura; qui i figli sono due. + - `srf_flip_dat` (geometria), `end2` (`FeatDefs`): "contenitore non in pila". + - fine di `AllFeatur`/`MdlStatus`/`FeatDefs`: pila non a zero. + Punto di partenza: `default_close` e `locate` in `psb.py`, con la traccia (`- t`). +4. **Sezioni tipo `b`** (`IndentTag`, `FeatRefData`) e **tipo `11` Wildfire**: illeggibili. + Stessa lunghezza del chiaro, primi 4 byte in chiaro. Già escluso (vedi + `formato-prt-feature.md` §1.3): XOR a chiave fissa, zlib, LZW. Non riprovare senza una + coppia chiaro/cifrato dello stesso modello. +5. **`DispDataTable`** (tipo `c`): comincia con `1f 9d 10` ma l'LZW standard fallisce al + secondo codice. Non indagato. +6. **`PSB_Tokens`** (`ND:0:*`): letti i quattro array, non il significato delle lettere di + `type_har`. Ipotesi da cui partire: `I` consuma un intero, `F` un double, una lettera per le + stringhe; `M`/`O` aprono oggetto e campo. Contare le occorrenze contro le lunghezze degli + array la conferma o la smentisce in pochi minuti. +7. **Costanti `double` ignote**: `04` (vista come componente di tangente e come estremo di + inviluppo), `0d` (−0,5 da un solo caso), buco `bffe` contro `bffd` non deciso. +8. **Portare il parser in C#** accanto ad `AsmRecordReader`: solo dopo i punti 1 e 3. + +## Il ragionamento da adottare + +È il metodo che ha portato fin qui. Va seguito anche per i punti aperti: ogni volta che è stato +saltato, si è perso tempo. + +1. **Partire dal modello di chi scrive, non dai byte.** Il file è l'uscita di un serializzatore + generico che percorre strutture C. La domanda utile non è "cosa significano questi byte" ma + "che regola semplice userebbe un programma per scriverli". Ogni marcatore ridondante (`f7`, + `f0`-`f4`) esiste perché il lettore, da solo, avrebbe previsto un'altra cosa: capire la + previsione spiega il marcatore. +2. **Un'ipotesi alla volta, e un controllo che la possa smentire.** Prima di scrivere codice + dire cosa ci si aspetta di vedere se l'ipotesi è vera. Esempio: "se gli id sono progressivi, + l'id nell'intestazione di `unit_arr` deve essere 0x14" (contato a mano: lo era). +3. **Il parser è lo strumento di misura.** Una regola è buona se fa arrivare la lettura + all'ultimo byte di *tutte* le sezioni; l'errore dice dove guardare. Si corregge la regola + generale, mai il caso singolo: un'eccezione scritta per una sezione ha sempre rotto un'altra. +4. **Cercare i controesempi, non le conferme.** Quando una regola spiega cinque casi, elencare + per iscritto i casi noti (con i byte) e verificare che li spieghi tutti, compreso quello + scomodo. La regola sulle chiusure implicite è nata mettendo in tabella dieci casi e cercando + l'unica formulazione che non ne contraddiceva nessuno. +5. **Ancorare i numeri a vincoli fisici, non all'aspetto.** Un valore "plausibile" non è una + prova. Lo sono: la norma 1 delle righe di una matrice di rotazione, valori noti (π, √2/2, + 0,1, 25,4), la coerenza tra sezioni (lo stesso punto scritto due volte). Per i tag dei + numeri: far risolvere il prefisso al vincolo (`tagmap.py`), non estrapolare una formula. +6. **Separare ciò che è provato da ciò che è dedotto.** Tokenizzazione (sequenza campo-valore), + annidamento e significato dei campi sono tre cose con tre livelli di prova diversi. Nel doc + ognuna va dichiarata col suo livello; "regge sui campioni" non è "è così". +7. **Allargare il campione appena una regola regge.** Un file solo nasconde le tabelle: i buchi + nei prefissi negativi e la tabella dei `float` sono emersi solo confrontando più file. + Dopo ogni modifica: `norms.py` sul campione, poi `batch.py` su `raw/`. +8. **Quando due spiegazioni reggono entrambe, cercare il dato che le distingue** invece di + scegliere la più elegante (esempio: i tag `91`-`a0` sembravano negativi; li ha decisi una + curva i cui parametri dovevano crescere da 3π/2). +9. **Scrivere subito nel doc, con il sintomo e i byte.** Una scoperta non scritta si rifà. + Scrivere anche ciò che è stato escluso e perché. +10. **Dire quello che non si sa.** Una costante ignota resta "ignota" (`` nel dump), non + riceve un valore inventato; un parser che non capisce si ferma, non restituisce un risultato + parziale in silenzio. + +## Vicoli ciechi e trappole, per non rifarli + +- **Modello "pop = chiudi N livelli" puro**: non basta. I marcatori `f0`-`f4` compaiono solo + quando la chiusura reale è diversa da quella che il lettore farebbe da solo; `f0` vuol dire + "resta qui". Ci sono volute quattro riscritture per arrivarci. +- **Ordine dei campi per struttura** (primo/successivo per schema): sbagliato. La tabella è + piatta, per id, e vale l'ultima transizione vista. +- **`e4`-`e7` come "zeri"**: sono ripetizioni del valore precedente. +- **Lo stesso id in contenitori diversi**: succede con le varianti di unione + (`typed_data(...)`); il parser le accetta e le conta in `lenient`. +- **Tag `double` negativi lineari**: no, ci sono tre buchi. Non fidarsi di un'estrapolazione + fuori dagli intervalli verificati. +- **Heredoc in Bash con `\\0`**: lo strumento collassa le barre e scrive un NUL nel sorgente. + Per creare file Python usare lo strumento di scrittura, non un heredoc. +- **Git Bash e `cd`**: gli script vanno lanciati dalla root del repo; `dotnet run` dentro + `PrepStep/` avvia la GUI. + +## Cronologia + +| Data | Fatto | +|---|---| +| 2026-10-21 | Albero delle feature, quote e geometria degli schizzi letti a pattern (`formato-prt-feature.md`, `formato-prt-geometria.md`) | +| 2026-11-10 | Contenitore corretto; grammatica PSB completa; tabella dei `double`; sezioni `.Z`; catalogo delle sezioni; parser di riferimento in `scripts/psb/`; `formato-psb.md` | diff --git a/scripts/psb/batch.py b/scripts/psb/batch.py new file mode 100644 index 0000000..64facff --- /dev/null +++ b/scripts/psb/batch.py @@ -0,0 +1,25 @@ +import sys,os,glob,collections +sys.path.insert(0,os.path.dirname(os.path.abspath(__file__))) +import prt,psb +files=sorted(glob.glob(sys.argv[1])) +tot=collections.Counter(); errs=collections.Counter(); bygen=collections.defaultdict(collections.Counter) +samples={} +for f in files: + try: d,secs,order=prt.load(f) + except Exception as e: + print('LOADERR',f,e); continue + gen=d[:60].split()[2].decode() + for n in order: + o,l,fl=secs[n] + typ=fl[2] + if typ!='9': bygen[gen]['type_'+typ]+=1; continue + b=d[o:o+l] + p,root,e=psb.parse(b) + full = p.p==len(b) + if e is None and full: bygen[gen]['ok']+=1; bygen[gen]['okbytes']+=l + else: + bygen[gen]['err']+=1; bygen[gen]['errbytes']+=l + key=(n.split('#')[0], str(e).split('@')[0][:50]) + errs[key]+=1; samples.setdefault(key,(os.path.basename(f),str(e))) +for g,c in sorted(bygen.items()): print(g,dict(c)) +for k,v in errs.most_common(40): print(v,k,samples[k]) diff --git a/scripts/psb/ctx.py b/scripts/psb/ctx.py new file mode 100644 index 0000000..88130b9 --- /dev/null +++ b/scripts/psb/ctx.py @@ -0,0 +1,9 @@ +import sys,os +sys.path.insert(0,os.path.dirname(os.path.abspath(__file__))) +import prt +for a in sys.argv[1:]: + f,sec,off=a.split(',') + off=int(off,16) + d,secs,order=prt.load(f); o,l,_=secs[sec]; b=d[o:o+l] + print('==',a) + prt.hexdump(b,max(0,off-96),192) diff --git a/scripts/psb/norms.py b/scripts/psb/norms.py new file mode 100644 index 0000000..5a8ce77 --- /dev/null +++ b/scripts/psb/norms.py @@ -0,0 +1,30 @@ +import sys,os,math,collections +sys.path.insert(0,os.path.dirname(os.path.abspath(__file__))) +import prt,psb +d,secs,order=prt.load(sys.argv[1]) +bad=collections.Counter(); good=0; unk=collections.Counter() +def walk(v,name=''): + global good + if isinstance(v,psb.Node): + for n,x in v: walk(x,n) + elif isinstance(v,psb.Arr): + for x in v: walk(x,name) + elif isinstance(v,list) and v and isinstance(v[0],list) and len(v) in (3,4) and len(v[0])==3 and name in ('local_sys','orient','matrix'): + for r in v[:3]: + if any(isinstance(c,psb.Unk) for c in r): + u=[c for c in r if isinstance(c,psb.Unk)] + if len(u)==1: + rest=sum(c*c for c in r if not isinstance(c,psb.Unk)) + unk[(u[0].code, round(math.sqrt(max(0,1-rest)),6))]+=1 + continue + n=math.sqrt(sum(c*c for c in r)) + if abs(n-1)<1e-6: good+=1 + else: bad[(name,tuple(round(c,4) for c in r))]+=1 +for nme in order: + o,l,f=secs[nme] + if f[2]!='9': continue + p,root,e=psb.parse(d[o:o+l]) + if root: walk(root[1]) +print('good rows',good,'bad',sum(bad.values())) +for k,v in bad.most_common(15): print(v,k) +print('unknown consts solved by norm:',unk.most_common(20)) diff --git a/scripts/psb/pbin.py b/scripts/psb/pbin.py new file mode 100644 index 0000000..d1d32f8 --- /dev/null +++ b/scripts/psb/pbin.py @@ -0,0 +1,11 @@ +import sys,os +sys.path.insert(0,os.path.dirname(os.path.abspath(__file__))) +import prt,psb +for f in sys.argv[1:]: + b=open(f,'rb').read() + p,root,e=psb.parse(b) + print(os.path.basename(f),len(b),p.p,'fields',p.nid,'lenient',p.lenient,'OK' if e is None else 'ERR '+repr(e)) + if e is not None: prt.hexdump(b,max(0,p.p-64),128) + if root: + out=[root[0]+':']; psb.fmt(root[1],1,out) + open(f[:-4]+'.txt','w',encoding='utf-8').write('\n'.join(out)) diff --git a/scripts/psb/prt.py b/scripts/psb/prt.py new file mode 100644 index 0000000..a5c0997 --- /dev/null +++ b/scripts/psb/prt.py @@ -0,0 +1,40 @@ +import sys,re +sys.stdout.reconfigure(encoding='utf-8',errors='replace') +def load(p): + d=open(p,'rb').read() + base=int(d[:d.find(b'\n')].split()[-2],16) + secs={} + order=[] + i=d.find(b'#UGC_TOC') + while True: + e=d.find(b'\n',i); pos=e+1; nxt=None + while True: + e2=d.find(b'\n',pos); line=d[pos:e2] + if line.startswith(b'#'): break + f=line.rstrip(b'#').split() + name=f[0].decode('latin1'); off=int(f[1],16); ln=int(f[2],16) + if name=='NEXT_TOC_ENTRY': nxt=base+off + else: + mk=len(name)+2 + secs[name]=(base+off+mk,ln-mk,[x.decode() for x in f[3:]]); order.append(name) + pos=e2+1 + if nxt is None: break + i=nxt + return d,secs,order +def sec(p,name): + d,secs,_=load(p); o,l,_=secs[name]; return d[o:o+l] +def hexdump(b,start=0,n=None,w=32): + b=b[start:start+n] if n else b[start:] + for i in range(0,len(b),w): + c=b[i:i+w] + print(f"{start+i:6x} "+' '.join(f"{x:02x}" for x in c).ljust(w*3)+' '+''.join(chr(x) if 32<=x<127 else '.' for x in c)) +if __name__=='__main__': + d,secs,order=load(sys.argv[1]) + if len(sys.argv)>2: + o,l,_=secs[sys.argv[2]]; b=d[o:o+l] + st=int(sys.argv[3],0) if len(sys.argv)>3 else 0 + n=int(sys.argv[4],0) if len(sys.argv)>4 else None + hexdump(b,st,n) + else: + for nme in order: + o,l,f=secs[nme]; print(f"{nme:26} {o:8} {l:8} {' '.join(f):34} {d[o:o+20]!r}") diff --git a/scripts/psb/psb.py b/scripts/psb/psb.py new file mode 100644 index 0000000..73ab674 --- /dev/null +++ b/scripts/psb/psb.py @@ -0,0 +1,529 @@ +"""Parser generico delle sezioni a marcatori dei file Creo (.prt/.asm). Reverse engineering. +Modello: ogni campo dichiarato per nome (e0 nome\\0) riceve un id progressivo. +In modalita' posizionale il campo atteso e' next[ultimo id letto]; f7 lo forza. +""" +import struct, sys, os + + +class Err(Exception): + pass + + +class Node(list): + """struct: lista di (nome, valore)""" + + def get(s, k, d=None): + for n, v in s: + if n == k: + return v + return d + + def all(s, k): + return [v for n, v in s if n == k] + + +class Arr(list): + pass + + +class Unk(float): + """costante a un byte non ancora identificata""" + def __new__(cls, code): + o = float.__new__(cls, 'nan') + o.code = code + return o + + def __repr__(s): + return f"" + + +class P: + def __init__(s, b, trace=False): + s.b = b + s.p = 0 + s.fields = {} + s.nid = 0 + s.next = {} + s.alias = {} + s.stack = [] + s.trace = trace + s.root = None + s.last = None + s.lenient = 0 + s.lastc = None + s.popped = None + + def u8(s): + v = s.b[s.p] + s.p += 1 + return v + + def peek(s): + return s.b[s.p] if s.p < len(s.b) else None + + def rint(s): + b = s.u8() + if b < 0x80: + return b + if b < 0xc0: + return (b & 0x3f) << 8 | s.u8() + if b < 0xe0: + v = (b & 0x1f) << 16 | s.b[s.p] << 8 | s.b[s.p + 1] + s.p += 2 + return v + if b == 0xe8: + return s.u8() + if b == 0xe9: + v = int.from_bytes(s.b[s.p:s.p + 2], 'big') + s.p += 2 + return v + if b == 0xea: + v = int.from_bytes(s.b[s.p:s.p + 3], 'big') + s.p += 3 + return v + if b == 0xeb: + v = int.from_bytes(s.b[s.p:s.p + 4], 'big') + s.p += 4 + return v + if b == 0xf5: + return s.rint() | 0x80000000 + if b == 0xf6: + return -1 + raise Err(f"int tag {b:02x} @{s.p - 1:x}") + + CONST = {0x17: -6.283185307179586, 0x14: 4.71238898038469, 0x0d: -0.5, 0x11: 3.141592653589793, 0x15: -3.141592653589793, 0x12: 1.5707963267948966, 0x16: -1.5707963267948966, 0x13: 6.283185307179586, 0x18: 0.0, 0x0f: 1.0, 0x0e: 0.5, 0x10: -1.0, 0x0c: 0.1, 0x0a: 0.01, 0x07: 0.001} + + FBASE = 0x3f9d + FCONST = {0x0b: 1.0, 0x28: -1.0} + + def rflt(s): + t = s.u8() + if 0x30 <= t <= 0x4f: + T = (0x37 + t - 0x30) if t < 0x40 else (0xb7 + t - 0x40) + raw = bytes([T]) + s.b[s.p:s.p + 3] + s.p += 3 + elif t == 0xea: + raw = s.b[s.p:s.p + 3] + bytes(1) + s.p += 3 + elif t == 0xeb: + raw = s.b[s.p:s.p + 4] + s.p += 4 + elif t == 0xe9: + raw = s.b[s.p:s.p + 2] + bytes(2) + s.p += 2 + elif t == 0xe8: + raw = s.b[s.p:s.p + 1] + bytes(3) + s.p += 1 + elif t == 0x00: + return 0.0 + elif t in s.FCONST: + return s.FCONST[t] + elif t < 0x30: + return Unk(t) + elif 0x50 <= t <= 0xdf: + raw = (s.FBASE + t).to_bytes(2, 'big') + s.b[s.p:s.p + 2] + s.p += 2 + else: + raise Err(f"flt tag {t:02x} @{s.p - 1:x}") + return struct.unpack('>f', raw)[0] + + def rbyte(s): + b = s.u8() + if b == 0xe8: + return s.u8() + return b + + # prefissi negativi a 16 bit dei tag a5-df: bfd0..c00d meno tre buchi (bfd2, bfdf, bffe) + NEG = [x for x in range(0xbfd0, 0xc00e) if x not in (0xbfd2, 0xbfdf, 0xbffe)] + + def rdbl(s): + t = s.u8() + if t in s.CONST: + return s.CONST[t] + if t <= 0x16 and t > 0x03: + return Unk(t) + if t <= 0x03: + c = s.u8() + k = t + 1 + pl = s.b[s.p:s.p + k] + s.p += k + if c not in s.CONST: + if 0x04 <= c <= 0x16: + return Unk(c) + raise Err(f"dbl const {c:02x} after modifier @{s.p - 1:x}") + raw = bytearray(struct.pack('>d', s.CONST[c])) + raw[8 - k:] = pl + return struct.unpack('>d', bytes(raw))[0] + if t == 0xed: + v = struct.unpack('>d', s.b[s.p:s.p + 8])[0] + s.p += 8 + return v + if t == 0xea: + v = struct.unpack('>d', s.b[s.p:s.p + 3] + bytes(5))[0] + s.p += 3 + return v + if t == 0xec: + v = struct.unpack('>d', s.b[s.p:s.p + 7] + bytes(1))[0] + s.p += 7 + return v + if 0x19 <= t <= 0x48: + if t <= 0x2f: + T = 0x3c + (t - 0x17) // 5 + m = (t - 0x17) % 5 + else: + T = 0xbc + (t - 0x30) // 5 + m = (t - 0x30) % 5 + if m == 4: + pl = s.b[s.p:s.p + 2] + s.p += 2 + raw = bytes([T]) + pl + b'\x00' * 5 + elif m == 3: + pl = s.b[s.p:s.p + 2] + s.p += 2 + raw = bytes([T]) + pl + pl[1:] * 5 + elif m == 2: + pl = s.b[s.p:s.p + 7] + s.p += 7 + raw = bytes([T]) + pl + elif m == 1: + pl = s.b[s.p:s.p + 6] + s.p += 6 + raw = bytes([T]) + pl + b'\x00' + else: + pl = s.b[s.p:s.p + 3] + s.p += 3 + raw = bytes([T]) + pl + bytes(4) + return struct.unpack('>d', raw)[0] + if 0x49 <= t <= 0xdf: + hi = (0x3f75 + t) if t <= 0xa4 else s.NEG[t - 0xa5] + pl = s.b[s.p:s.p + 6] + s.p += 6 + return struct.unpack('>d', hi.to_bytes(2, 'big') + pl)[0] + raise Err(f"dbl tag {t:02x} @{s.p - 1:x}") + + def rstr(s): + if s.peek() == 0xe1: + s.p += 1 + return None + e = s.b.index(0, s.p) + v = s.b[s.p:e].decode('latin1') + s.p = e + 1 + return v + + def rarr(s, n, rd): + out = [] + while len(out) < n: + b = s.peek() + if 0xe4 <= b <= 0xe6 and out: + s.p += 1 + out += [out[-1]] * (b - 0xe3) + elif b == 0xe7 and out: + s.p += 1 + k = s.rint() + out += [out[-1]] * k + else: + out.append(rd()) + if len(out) != n: + raise Err(f"array overrun {len(out)}>{n} @{s.p:x}") + return out + + def scalar(s, f): + t = f['t'] + if t in (0x0a, 0x03): + if s.peek() == 0xf8: + s.p += 1 + f['dim'] = (s.rint(),) + return [s.rstr() for _ in range(f['dim'][0])] + if f.get('dim'): + out = [] + while len(out) < f['dim'][0] and 0x20 <= s.peek() < 0xe0: + out.append(s.rstr()) + return out + return s.rstr() + rd = s.rdbl if t == 0x02 else s.rbyte if t in (0x07, 0x04) else s.rflt if t == 0x06 else s.rint + b = s.peek() + if b == 0xe1: + s.p += 1 + return None + if b == 0xf8: + s.p += 1 + f['dim'] = (s.rint(),) + elif b == 0xf9: + s.p += 1 + f['dim'] = (s.rint(), s.rint()) + elif b == 0xfc: + s.p += 1 + f['dim'] = (s.rint(),) + tuple(f['dim'][1:]) + elif b == 0xfa: + s.p += 1 + f['dim'] = tuple(s.rint() for _ in range(s.rint())) + d = f.get('dim') + if d is None: + return rd() + n = 1 + for k in d: + n *= k + v = s.rarr(n, rd) + if len(d) == 2: + v = [v[i * d[1]:(i + 1) * d[1]] for i in range(d[0])] + return v + + def sch(s, fid): + return s.alias.get(fid, fid) + + # --- contenitori ------------------------------------------------- + def owner_index(s, E, post=False): + own = s.fields[E]['own'] + for i in range(len(s.stack) - 1, -1, -1): + c = s.stack[i] + if c['kind'] == 'struct': + if c['schema'] in own: + if post and s.fields[E].get('post') and i > 0: + return i - 1 + return i + elif c['occ'] == E or ('A', c['occ']) in own: + return i + return None + + def default_close(s): + """chiusura implicita prima di un campo con nome o di un salto senza marcatore f0-f4: + se il campo atteso appartiene al contenitore padre di quello in cui e' stato scritto + l'ultimo campo, quest'ultimo contenitore si considera chiuso.""" + if s.last is None or not s.stack or s.lastc is None: + return + E = s.next.get(s.last) + if E is None: + return + p = None + for k in range(len(s.stack) - 1, -1, -1): + if s.stack[k] is s.lastc: + p = k + break + if not p: + return + i = s.owner_index(E, True) + if i == p - 1: + del s.stack[p:] + + def marker(s): + b = s.peek() + if b is None or not (0xf0 <= b <= 0xf4): + return None + s.p += 1 + if b == 0xf4: + return s.rint() + return b - 0xf0 + + def explicit(s, n): + for _ in range(n): + if not s.stack: + raise Err(f"pop underflow @{s.p:x}") + s.popped = s.stack.pop() + + def trans(s, E): + if s.last is not None: + s.next[s.last] = E + s.last = E + + def locate(s, E, jump, stay): + """porta in cima allo stack il contenitore che riceve il campo E. Ritorna 'elem' o 'field'.""" + f = s.fields[E] + hdr = s.peek() in (0xf8, 0xf9) + top = s.stack[-1] + if not hdr and f['t'] == 0: + for i in range(len(s.stack) - 1, -1, -1): + c = s.stack[i] + if c['kind'] == 'array' and c['occ'] == E: + del s.stack[i + 1:] + return 'elem' + par = f['parent'] + i = s.owner_index(E, not jump) + if i is None and jump and top['kind'] == 'struct' and top['node']: + # variante di unione riusata in un altro contenitore: resta dove siamo + f['own'].add(top['schema']) + s.lenient += 1 + return 'field' + fresh = top['kind'] == 'struct' and not top['node'] + if jump and fresh and i != len(s.stack) - 1: + s.alias[top['field']] = par + top['schema'] = par + return 'field' + if i is not None: + if jump and i != len(s.stack) - 1: + s.lenient += 1 + del s.stack[i + 1:] + return 'field' + if jump and top['kind'] == 'array' and not top['node'] and f['t'] == 0 and not hdr: + top['occ'] = E + return 'elem' + raise Err(f"field {E} {f['name']}: container {par} not on stack @{s.p:x}") + + def element(s, E): + c = s.stack[-1] + s.trans(E) + s.lastc = c + if s.peek() == 0xfb: + s.p += 1 + b = s.u8() + if len(c['node']) >= c['n']: + raise Err(f"too many elements in {s.fields[c['field']]['name']} @{s.p:x}") + if b == 0xe1: + c['node'].append(None) + elif b in (0xe2, 0xe3): + v = Node() + c['node'].append(v) + s.stack.append(dict(kind='struct', field=c['field'], schema=s.sch(c['occ']), node=v, elem=True, occ=None, occ0=c['occ'])) + else: + raise Err(f"array elem tag {b:02x} @{s.p - 1:x} in {s.fields[c['field']]['name']}") + + def value(s, fid): + c = s.stack[-1] if s.stack else None + f = s.fields[fid] + s.trans(fid) + s.lastc = c + if f['t'] != 0: + v = s.scalar(f) + else: + b = s.u8() + if b == 0xe1: + v = None + elif b in (0xe2, 0xe3): + v = Node() + elif b == 0xf8: + v = Arr() + n = s.rint() + elif b == 0xf9: + v = Arr() + n = s.rint() + s.rint() + else: + raise Err(f"ptr tag {b:02x} @{s.p - 1:x} field {f['name']}") + if c is None: + s.root = (f['name'], v) + elif c['kind'] == 'array': + c.setdefault('tail', []).append((f['name'], v)) + else: + c['node'].append((f['name'], v)) + if isinstance(v, Arr): + s.stack.append(dict(kind='array', field=fid, occ=fid, n=n, node=v)) + elif isinstance(v, Node): + s.stack.append(dict(kind='struct', field=fid, schema=s.sch(fid), node=v, occ=None)) + + def run(s): + b = s.b + while s.p < len(b): + m = s.marker() + x = s.peek() + if x is None: + s.explicit(m or 0) + break + if x == 0xe0: + s.p += 1 + t = s.u8() + e = b.index(0, s.p) + name = b[s.p:e].decode('latin1') + s.p = e + 1 + m2 = s.marker() + if m2 is not None: + m = (m or 0) + m2 + s.popped = None + if m is None: + s.default_close() + else: + s.explicit(m) + c = s.stack[-1] if s.stack else None + s.nid += 1 + fid = s.nid + if c is None: + par = 0 + elif c['kind'] == 'array': + par = ('A', c['occ']) + else: + par = c['schema'] + s.fields[fid] = dict(name=name, t=t, parent=par, own={par}) + pp = s.popped + if pp is not None and c is not None and pp['kind'] == 'struct' and c['kind'] == 'struct' and pp['schema'] == c['schema'] and t == 0: + s.fields[fid]['post'] = True + if s.trace: + print(f"{s.p:6x} {' ' * len(s.stack)}#{fid} {name} t={t}") + s.value(fid) + continue + if m: + s.explicit(m) + jump = False + if x == 0xf7: + s.p += 1 + E = s.rint() + jump = True + if m is None: + s.default_close() + if E not in s.fields: + raise Err(f"jump to unknown field {E} @{s.p:x}") + else: + E = s.next.get(s.last) + if E is None: + raise Err(f"no expected field @{s.p:x} after {s.last} " + f"{s.fields[s.last]['name'] if s.last else ''}") + kind = s.locate(E, jump or m is not None, m == 0) + if s.trace: + print(f"{s.p:6x} {' ' * len(s.stack)}{'>' if jump else '='}{E} {s.fields[E]['name']} {kind}") + if kind == 'elem': + s.element(E) + else: + s.value(E) + return s.root + + +def short(x, maxarr=40): + if isinstance(x, Unk): + return repr(x) + if isinstance(x, float): + return repr(round(x, 9)) + if isinstance(x, list): + r = ', '.join(short(y) for y in x[:maxarr]) + return f"[{r}{', ...' if len(x) > maxarr else ''}] (n={len(x)})" + return repr(x) + + +def fmt(v, ind=0, out=None, maxarr=40): + pad = ' ' * ind + if isinstance(v, Node): + for n, x in v: + if isinstance(x, (Node, Arr)): + out.append(f"{pad}{n}:") + fmt(x, ind + 1, out, maxarr) + else: + out.append(f"{pad}{n} = {short(x, maxarr)}") + elif isinstance(v, Arr): + for i, x in enumerate(v): + out.append(f"{pad}[{i}]") + fmt(x, ind + 1, out, maxarr) + + +def parse(b, trace=False): + p = P(b, trace) + try: + root = p.run() + return p, root, None + except (Err, IndexError, ValueError, KeyError) as e: + return p, p.root, e + + +if __name__ == '__main__': + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import prt + d, secs, order = prt.load(sys.argv[1]) + names = sys.argv[2].split(',') if len(sys.argv) > 2 and sys.argv[2] != '*' else [n for n in order if secs[n][2][2] == '9'] + outdir = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] != '-' else None + for nme in names: + o, l, f = secs[nme] + b = d[o:o + l] + p, root, e = parse(b, trace=len(sys.argv) > 4) + ok = e is None and not p.stack + print(f"{nme:22} len={l:7} parsed={p.p:7} fields={p.nid:5} lenient={p.lenient} {'OK' if ok else 'ERR ' + repr(e) + ' stack=' + str(len(p.stack))}") + if e is not None: + prt.hexdump(b, max(0, p.p - 64), 128) + if outdir and root: + out = [root[0] + ':'] + fmt(root[1], 1, out) + open(f"{outdir}/{nme.replace(':', '_').replace('#', '_')}.txt", 'w', encoding='utf-8').write('\n'.join(out)) diff --git a/scripts/psb/tagmap.py b/scripts/psb/tagmap.py new file mode 100644 index 0000000..0d6693f --- /dev/null +++ b/scripts/psb/tagmap.py @@ -0,0 +1,47 @@ +import sys,os,math,collections,struct,glob,itertools +sys.path.insert(0,os.path.dirname(os.path.abspath(__file__))) +import prt,psb +class TF(float): + pass +orig=psb.P.rdbl +def rdbl(s): + p0=s.p; t=s.b[p0] + v=orig(s) + if 0x49<=t<=0xdf and not isinstance(v,psb.Unk): + o=TF(v); o.tag=t; o.pl=bytes(s.b[p0+1:p0+7]); return o + return v +psb.P.rdbl=rdbl +OFFS=[0]+list(range(0x44,0x4d)) +def mag(tf,off): + return struct.unpack('>d',(0x3f75+tf.tag-off).to_bytes(2,'big')+tf.pl)[0] +votes=collections.defaultdict(collections.Counter) +def walk(v,name=''): + if isinstance(v,psb.Node): + for n,x in v: walk(x,n) + elif isinstance(v,psb.Arr): + for x in v: walk(x,name) + elif isinstance(v,list) and v and isinstance(v[0],list) and len(v) in (3,4) and len(v[0])==3 and name in ('local_sys','orient','matrix','transf_to_local'): + rows=v[:3] + if any(isinstance(c,psb.Unk) for r in rows for c in r): return + vecs=rows+[[rows[i][j] for i in range(3)] for j in range(3)] + for r in vecs: + tf=[c for c in r if isinstance(c,TF)] + if not tf or len(tf)>2: continue + rest=sum(c*c for c in r if not isinstance(c,TF)) + sols=[] + for offs in itertools.product(OFFS,repeat=len(tf)): + n=rest+sum(mag(c,o)**2 for c,o in zip(tf,offs)) + if abs(n-1)<1e-9: sols.append(offs) + if len(sols)==1: + for c,o in zip(tf,sols[0]): votes[c.tag][o]+=1 +for f in sys.argv[1:]: + for g in glob.glob(f): + try: d,secs,order=prt.load(g) + except Exception: continue + for nme in order: + o,l,fl=secs[nme] + if fl[2]!='9' or l<200: continue + p,root,e=psb.parse(d[o:o+l]) + if root: walk(root[1]) +for t in sorted(votes): + print(hex(t), {hex(o):n for o,n in votes[t].items()}, ' -> prefix', [hex((0x3f75+t-o) | (0x8000 if o else 0)) for o in votes[t]]) diff --git a/scripts/psb/unz.py b/scripts/psb/unz.py new file mode 100644 index 0000000..17d516a --- /dev/null +++ b/scripts/psb/unz.py @@ -0,0 +1,75 @@ +"""Decompressore Unix compress (.Z, LZW) minimale, come ncompress.""" +import sys, os + + +def unz(data): + assert data[0] == 0x1f and data[1] == 0x9d, "non e' un flusso .Z" + flags = data[2] + maxbits = flags & 0x1f + block = bool(flags & 0x80) + out = bytearray() + n_bits = 9 + maxcode = (1 << n_bits) - 1 + table = {i: bytes([i]) for i in range(256)} + free = 257 if block else 256 + pos = 3 * 8 # posizione in bit + total = len(data) * 8 + start = pos # inizio del gruppo corrente di codici (per il riallineamento) + old = None + while pos + n_bits <= total: + byte = pos >> 3 + chunk = int.from_bytes(data[byte:byte + 4], 'little') + code = (chunk >> (pos & 7)) & ((1 << n_bits) - 1) + pos += n_bits + if old is None: + old = table[code] + out += old + continue + if code == 256 and block: + table = {i: bytes([i]) for i in range(256)} + free = 256 + # riallinea a multipli di 8 codici + grp = n_bits * 8 + pos = start + ((pos - start + grp - 1) // grp) * grp + n_bits = 9 + maxcode = (1 << n_bits) - 1 + start = pos + old = None + free = 257 + continue + if code in table: + entry = table[code] + elif code == free: + entry = old + old[:1] + else: + raise ValueError(f"codice LZW non valido {code} (free={free}) a bit {pos}") + out += entry + if free <= (1 << maxbits) - 1: + table[free] = old + entry[:1] + free += 1 + old = entry + if free > maxcode and n_bits < maxbits: + grp = n_bits * 8 + pos = start + ((pos - start + grp - 1) // grp) * grp + n_bits += 1 + maxcode = (1 << n_bits) - 1 + start = pos + return bytes(out) + + +if __name__ == '__main__': + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import prt + d, secs, order = prt.load(sys.argv[1]) + outdir = sys.argv[2] + for n in order: + o, l, f = secs[n] + b = d[o:o + l] + if b[:2] == b'\x1f\x9d': + try: + u = unz(b) + except Exception as e: + print(n, 'ERR', e) + continue + print(n, 'compressa', l, '->', len(u), 'dichiarata', int(f[0], 16), u[:60]) + open(os.path.join(outdir, n.replace(':', '_') + '.bin'), 'wb').write(u)