diff --git a/CLAUDE.md b/CLAUDE.md index 1d63550..7f73cdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,13 +59,16 @@ designer visuale): 3. **Crea cartella raw** — `TrovaPercorsiCad` + `CopiaFileTrovati` (parallela, vedi `docs/copia-raw-parallela.md`) copiano in `raw/` i file CAD dei componenti del CSV trovati nell'indice. -4. **Trova componenti referenziati** — `AssemblyComponentScanner` legge euristicamente i - `.asm` in `raw/` per risolvere ricorsivamente sotto-assiemi/parti (`RisolviAlberoComponenti`), - copiandoli anch'essi in `raw/`. Euristica e falsi positivi noti in `docs/formato-asm.md`. +4. **Trova componenti referenziati** — legge i `.asm` in `raw/` per risolvere ricorsivamente + sotto-assiemi/parti (`RisolviAlberoComponenti`), copiandoli anch'essi in `raw/`. Usa + `AsmRecordReader` (parser esatto dei record binari, `docs/formato-asm-record.md`) e ripiega + su `AssemblyComponentScanner` (euristica testuale, `docs/formato-asm.md`) solo se il file + non rispetta il formato validato (`InvalidDataException`). 5. **Verifica completezza** — mostra i `.asm` della distinta già in `raw/`, trascinabili su Creo per l'esportazione STEP (Distributed Batch, manuale, fuori dall'app); "Verifica - completezza assiemi" controlla ricorsivamente sull'indice (non su `raw/`) se tutti i - sotto-componenti di ogni assieme sono presenti. + completezza assiemi" controlla ricorsivamente sull'indice (non su `raw/`, stessa + `AsmRecordReader`/fallback della fase 4) se tutti i sotto-componenti di ogni assieme sono + presenti. ## Script standalone (`scripts/`) @@ -74,10 +77,13 @@ lanciati dalla root del repo — **non** da dentro `PrepStep/`, che ha un `.cspr `dotnet run` lì avvia la GUI vera invece dello script). Elenco e dettaglio di ciascuno in `docs/architettura.md`. -Il pattern da preservare: `ExtractCreoStrings.cs`/`BomViaDb.cs` referenziano -`PrepStep/PrepStep.csproj` via `#:project` invece di duplicare l'euristica di -estrazione/placement, che vive **solo** in `PrepStep/AssemblyComponentScanner.cs` — un'unica -fonte di verità condivisa da GUI e script (vedi `docs/formato-asm.md` per l'euristica e i -falsi positivi noti). `CreoBom.cs` è l'eccezione deliberata: legge la struttura a record del -binario invece di contare occorrenze testuali (`docs/formato-asm-record.md`), non passa da -`AssemblyComponentScanner`/`cad_index.db` e non la sostituisce nella GUI. +Il pattern da preservare: gli script referenziano `PrepStep/PrepStep.csproj` via `#:project` +invece di duplicare la logica di lettura dei `.asm`, che vive **solo** nel progetto +principale — un'unica fonte di verità condivisa da GUI e script. Due famiglie distinte, non +intercambiabili: + +- `ExtractCreoStrings.cs`/`BomViaDb.cs` usano `AssemblyComponentScanner` (euristica testuale, + `docs/formato-asm.md`) e risolvono i sottoassiemi contro `cad_index.db`. +- `CreoBom.cs` usa `AsmRecordReader` (parser esatto dei record binari, + `docs/formato-asm-record.md`, condiviso con la GUI in fase 4/5) e risolve i sottoassiemi + cercandoli in cartelle locali (`-p`), non contro `cad_index.db`. diff --git a/PrepStep.Tests/AsmRecordReaderTests.cs b/PrepStep.Tests/AsmRecordReaderTests.cs new file mode 100644 index 0000000..74ef976 --- /dev/null +++ b/PrepStep.Tests/AsmRecordReaderTests.cs @@ -0,0 +1,123 @@ +using System.Text; +using PrepStep; + +namespace PrepStep.Tests; + +public class AsmRecordReaderTests +{ + private static string ScriviFile(byte[] dati) + { + string percorso = Path.GetTempFileName(); + File.WriteAllBytes(percorso, dati); + return percorso; + } + + private static byte[] Bytes(params object[] parti) + { + using var ms = new MemoryStream(); + foreach (var p in parti) + { + switch (p) + { + case byte b: ms.WriteByte(b); break; + case int i: ms.WriteByte((byte)i); break; + case string s: var buf = Encoding.Latin1.GetBytes(s); ms.Write(buf); break; + case byte[] arr: ms.Write(arr); break; + default: throw new ArgumentException($"tipo non gestito: {p.GetType()}"); + } + } + return ms.ToArray(); + } + + // \xe3 id type name\0 family_name\0 - id/type come singolo byte (0x00-0x7f, un byte) + private static byte[] Membro(int id, int tipo, string nome, string? nomeFamiglia = null) => + Bytes(0xe3, id, tipo, nome, 0, nomeFamiglia ?? nome, 0); + + // \xe3 ConfigName\0 \xe23 \0 \x82\x7b + private static byte[] ConfigName(string istanza, int idMembro) => + Bytes(0xe3, "ConfigName", 0, 0xe2, (byte)'3', istanza, 0, idMembro, 0x82, 0x7b); + + private static byte[] Asm(int conteggioDichiarato, byte[] membri, byte[]? extra = null) => + Bytes("header ", extra ?? Array.Empty(), "\0members\0", 0xf1, 0xf8, conteggioDichiarato, membri, "#MdlStatus"); + + [Fact] + public void Legge_membri_semplici_senza_family_table() + { + byte[] dati = Asm(2, Bytes( + Membro(1, 2, "9244206098"), + Membro(2, 1, "9244228558_C01"))); + + var membri = AsmRecordReader.LeggiMembri(dati); + + Assert.Equal(2, membri.Count); + Assert.Equal((1, 2, "9244206098", "9244206098"), (membri[0].Id, membri[0].Tipo, membri[0].Nome, membri[0].Generico)); + Assert.Equal((2, 1, "9244228558_C01", "9244228558_C01"), (membri[1].Id, membri[1].Tipo, membri[1].Nome, membri[1].Generico)); + } + + [Fact] + public void Risolve_il_nome_istanza_di_una_family_table_da_ConfigName() + { + // Il membro cita solo il generico (UNI6592); il nome che la BOM mostra + // (R8--_-_U6592) sta nel parametro ConfigName, collegato per id membro. + byte[] dati = Asm(1, Membro(1, 2, "UNI6592"), extra: ConfigName("R8--_-_U6592", idMembro: 1)); + + var membri = AsmRecordReader.LeggiMembri(dati); + + Assert.Single(membri); + Assert.Equal("R8--_-_U6592", membri[0].Nome); + Assert.Equal("UNI6592", membri[0].Generico); + } + + [Fact] + public void Componente_senza_ConfigName_non_e_toccato_da_istanze_di_altri_membri() + { + byte[] dati = Asm(2, Bytes( + Membro(1, 2, "UNI6592"), + Membro(2, 2, "9244206098")), + extra: ConfigName("R8--_-_U6592", idMembro: 1)); + + var membri = AsmRecordReader.LeggiMembri(dati); + + Assert.Equal("R8--_-_U6592", membri.Single(m => m.Id == 1).Nome); + Assert.Equal("9244206098", membri.Single(m => m.Id == 2).Nome); + } + + [Fact] + public void Conteggio_dichiarato_diverso_da_quello_letto_lancia_eccezione() + { + // Dichiara 2 membri ma ne fornisce solo 1: lettura disallineata, va segnalata invece + // di restituire un risultato parziale silenzioso. + byte[] dati = Asm(2, Membro(1, 2, "9244206098")); + + Assert.Throws(() => AsmRecordReader.LeggiMembri(dati)); + } + + [Fact] + public void Sezione_members_assente_lancia_eccezione() + { + byte[] dati = Encoding.Latin1.GetBytes("un file qualunque senza la sezione attesa"); + + Assert.Throws(() => AsmRecordReader.LeggiMembri(dati)); + } + + [Fact] + public void EstraiCodiciReferenziati_restituisce_i_generici_distinti() + { + byte[] dati = Asm(3, Bytes( + Membro(1, 2, "9144210327"), + Membro(2, 2, "9144210327"), // stesso codice montato due volte: un solo codice distinto + Membro(3, 1, "9244238563_D100"))); + string percorso = ScriviFile(dati); + try + { + var codici = AsmRecordReader.EstraiCodiciReferenziati(percorso); + Assert.Equal(new[] { "9144210327", "9244238563_D100" }, codici.OrderBy(c => c)); + } + finally { File.Delete(percorso); } + } + + [Fact] + public void File_inesistente_lancia_eccezione_IO() => + Assert.Throws(() => + AsmRecordReader.EstraiCodiciReferenziati(@"C:\non\esiste\pippo.asm.1")); +} diff --git a/PrepStep/AsmRecordReader.cs b/PrepStep/AsmRecordReader.cs new file mode 100644 index 0000000..02aa00a --- /dev/null +++ b/PrepStep/AsmRecordReader.cs @@ -0,0 +1,126 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace PrepStep; + +/// Lettura esatta della struttura a record del binario .asm Creo (sezione +/// "members" + parametro "ConfigName" per le family table), ricavata per reverse +/// engineering e verificata confrontando l'output con le BOM esportate da Creo - vedi +/// docs/formato-asm-record.md per il dettaglio byte-per-byte. +/// +/// Alternativa esatta all'euristica testuale di +/// (docs/formato-asm.md): quella conta le occorrenze dei codici nel testo, questa legge +/// la struttura dei record. Copre solo gli 8 campioni usati per il reverse engineering: su +/// un file che non rispetta il formato atteso lancia +/// invece di restituire un risultato parziale silenzioso - il chiamante () ripiega sull'euristica in quel caso, non essendoci garanzia di +/// copertura completa su tutte le versioni Creo mai incontrate sulla share. +/// +/// Unica fonte di verità condivisa da GUI e script (stesso pattern di +/// AssemblyComponentScanner): scripts/CreoBom.cs referenzia questa classe via +/// "#:project" per la parte di lettura, aggiungendo solo la resa in formato BOM. +public static class AsmRecordReader +{ + public sealed record Membro(int Id, int Tipo, string Nome, string Generico); + + // interi big-endian, lunghezza nei bit alti del primo byte (0xe0+ e' riservato ai + // marcatori di struttura): 0x00-0x7f 1 byte, 0x80-0xbf 2 byte, 0xc0-0xdf 3. + private const string Int = @"([\x00-\x7f]|[\x80-\xbf][\x00-\xff]|[\xc0-\xdf][\x00-\xff][\x00-\xff])"; + + // \xe3 id type name\0 family_name\0 + private static readonly Regex MemberRegex = new( + @"\xe3" + Int + Int + @"([\x20-\x7e]+)\x00([\x20-\x7e]+)\x00", RegexOptions.Compiled); + + // parametro ConfigName: nome istanza family table + id del membro a cui si applica. + private static readonly Regex ConfigNameRegex = new( + @"\xe3ConfigName\x00\xe23(?:\xf7[\x00-\xff])?([\x20-\x7e]+)\x00" + Int + @"\x82\x7b", + RegexOptions.Compiled); + + // nome proprio del modello (quello che la BOM stampa in intestazione). + private static readonly Regex ObjNameRegex = new( + @"\x00objname\x00\xe2\xe0\x01object_type\x00[\x00-\x7f]\xe0\x0aname\x00([^\x00]+)\x00", + RegexOptions.Compiled); + + // primo elemento dell'array "members": porta le chiavi per esteso. + private static readonly Regex FirstMemberRegex = new( + @"\x01id\x00" + Int + @"\xe0\x01type\x00" + Int + + @"\xe0\x0aname\x00([^\x00]+)\x00\xe0\x0afamily_name\x00([^\x00]+)\x00", + RegexOptions.Compiled); + + private static int AsInt(string bytes) + { + int mask = bytes.Length switch { 1 => 0x7F, 2 => 0x3F, 3 => 0x1F, _ => throw new ArgumentException("lunghezza intero non valida") }; + int valore = bytes[0] & mask; + for (int i = 1; i < bytes.Length; i++) + valore = (valore << 8) | bytes[i]; + return valore; + } + + /// Nome proprio del modello (quello che Creo stampa in intestazione BOM), + /// dalla prima occorrenza del record "objname". + public static string NomeProprio(byte[] dati) + { + var m = ObjNameRegex.Match(Encoding.Latin1.GetString(dati)); + if (!m.Success) + throw new InvalidDataException("record 'objname' non trovato"); + return m.Groups[1].Value; + } + + /// Componenti diretti di un assieme, in ordine di montaggio. "Nome" e' il nome + /// che mostrerebbe la BOM (istanza di family table se presente), "Generico" e' il file + /// realmente su disco (coincide con "Nome" per i componenti che non sono istanze). + /// Lancia se il file non ha una sezione "members" o + /// se il conteggio dichiarato non coincide con i record letti (lettura disallineata: + /// il formato del file non e' quello coperto dal reverse engineering). + public static List LeggiMembri(byte[] dati) + { + string testo = Encoding.Latin1.GetString(dati); + + var istanze = new Dictionary(); + foreach (Match m in ConfigNameRegex.Matches(testo)) + istanze[AsInt(m.Groups[2].Value)] = m.Groups[1].Value; + + const string chiaveMembers = "\0members\0"; + int start = testo.IndexOf(chiaveMembers, StringComparison.Ordinal); + if (start < 0) + throw new InvalidDataException("sezione 'members' assente"); + start += chiaveMembers.Length; + + int conteggio = dati[start + 2]; // \xf1 \xf8 + int end = testo.IndexOf("#MdlStatus", start, StringComparison.Ordinal); + string region = testo.Substring(start, (end > 0 ? end : testo.Length) - start); + + var grezzi = new List<(int Id, int Tipo, string Nome)>(); + + // il primo record e' esteso: ricava i campi dalle chiavi, i successivi sono posizionali + var primo = FirstMemberRegex.Match(region); + if (primo.Success) + { + grezzi.Add((AsInt(primo.Groups[1].Value), AsInt(primo.Groups[2].Value), primo.Groups[3].Value)); + region = region.Substring(primo.Index + primo.Length); + } + + foreach (Match m in MemberRegex.Matches(region)) + { + grezzi.Add((AsInt(m.Groups[1].Value), AsInt(m.Groups[2].Value), m.Groups[3].Value)); + if (grezzi.Count == conteggio) + break; + } + + if (grezzi.Count != conteggio) + throw new InvalidDataException($"attesi {conteggio} membri, trovati {grezzi.Count}: lettura disallineata"); + + return grezzi.Select(g => new Membro(g.Id, g.Tipo, istanze.GetValueOrDefault(g.Id, g.Nome), g.Nome)).ToList(); + } + + /// Codici dei componenti diretti (il generico su disco, non il nome istanza), + /// distinti, nell'ordine in cui compaiono. Equivalente esatto di + /// per i file coperti dal + /// formato: stesso contratto di ritorno (un insieme di codici da cercare nell'indice), + /// lettura strutturale invece di euristica testuale. + public static HashSet EstraiCodiciReferenziati(string percorsoAsm) + { + var membri = LeggiMembri(File.ReadAllBytes(percorsoAsm)); + return new HashSet(membri.Select(m => m.Generico), StringComparer.OrdinalIgnoreCase); + } +} diff --git a/PrepStep/Form1.cs b/PrepStep/Form1.cs index 35053af..653870c 100644 --- a/PrepStep/Form1.cs +++ b/PrepStep/Form1.cs @@ -743,6 +743,26 @@ public partial class Form1 : Form return codiciNoti; } + /// Codici referenziati direttamente in un assieme: prova prima il parser esatto + /// dei record binari (, vedi docs/formato-asm-record.md), che + /// non ha i falsi positivi/negativi noti dell'euristica testuale (docs/formato-asm.md); se + /// il file non rispetta il formato validato ( - capita su + /// .asm di versioni Creo mai riscontrate nei campioni usati per il reverse engineering) + /// ripiega su , che ha copertura più ampia anche se + /// meno precisa. Un'eventuale IOException (file irraggiungibile) non viene intercettata qui: + /// resta responsabilità del chiamante, come prima di questo cambiamento. + private static IEnumerable EstraiCodiciReferenziati(string percorsoAsm, ICollection codiciNoti) + { + try + { + return AsmRecordReader.EstraiCodiciReferenziati(percorsoAsm); + } + catch (InvalidDataException) + { + return AssemblyComponentScanner.EstraiCodiciReferenziati(percorsoAsm, codiciNoti); + } + } + /// Risolve ricorsivamente l'albero dei componenti a partire dai file .asm indicati /// (già copiati in "raw"): per ogni assieme legge i codici referenziati al suo interno, li /// cerca nell'indice, copia in "raw" quelli trovati e, se sono a loro volta assiemi, li @@ -769,7 +789,7 @@ public partial class Form1 : Form { var asm = daScansionare.Dequeue(); processati++; - foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm, codiciNoti.Keys)) + foreach (var codice in EstraiCodiciReferenziati(asm, codiciNoti.Keys)) { if (!codiciVisti.Add(codice)) continue; @@ -873,7 +893,7 @@ public partial class Form1 : Form { try { - codiciReferenziati = AssemblyComponentScanner.EstraiCodiciReferenziati(file, codiciNoti.Keys); + codiciReferenziati = EstraiCodiciReferenziati(file, codiciNoti.Keys).ToHashSet(StringComparer.OrdinalIgnoreCase); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { diff --git a/docs/architettura.md b/docs/architettura.md index bace18c..0a91640 100644 --- a/docs/architettura.md +++ b/docs/architettura.md @@ -79,14 +79,26 @@ Distinta (.xls/.xml) → CSV (componente;descrizione) → raw/ (CAD copiati `ROW_NUMBER() OVER (...)`, non self-join+`GROUP BY` per evitare tempi quasi quadratici su ~200k righe). -## Scansione binaria dei `.asm` +## Lettura dei componenti referenziati in un `.asm` +`Form1.EstraiCodiciReferenziati` (usata da `RisolviAlberoComponenti`, fase 4, e da +`AssiemeCompleto` dentro `VerificaCompletezzaAssiemi`, fase 5) prova prima +`AsmRecordReader.EstraiCodiciReferenziati` e ripiega su +`AssemblyComponentScanner.EstraiCodiciReferenziati` solo se il primo lancia +`InvalidDataException` (file che non rispetta il formato validato dal reverse engineering). + +- **AsmRecordReader.cs** — legge esattamente la sezione `members` del binario `.asm` (id/tipo/ + nome dei componenti diretti, con risoluzione del nome istanza di family table dal parametro + `ConfigName`): nessuna euristica, struttura a record. Lancia `InvalidDataException` se il + file non ha una sezione `members` o se il conteggio dichiarato non torna. Dettagli in + `docs/formato-asm-record.md`. Condivisa con `scripts/CreoBom.cs` via `#:project`. - **AssemblyComponentScanner.cs** — estrae euristicamente i codici componente referenziati - dentro un `.asm` (formato per lo più binario, codici come stringhe ASCII leggibili al suo - interno). Due strategie: codici numerici Pro-E riconosciuti da soli via regex (come nei nomi - file), e nomi alfabetici di parti commerciali tokenizzati genericamente e validati contro - l'elenco di codici noti del DB (altrimenti il rumore binario sarebbe ingestibile). Dettagli - sul formato, l'euristica e i falsi positivi noti in `docs/formato-asm.md`. + contando le occorrenze di stringhe candidate nel testo binario. Due strategie: codici + numerici Pro-E riconosciuti da soli via regex (come nei nomi file), e nomi alfabetici di + parti commerciali tokenizzati genericamente e validati contro l'elenco di codici noti del DB + (altrimenti il rumore binario sarebbe ingestibile). Dettagli sull'euristica e i falsi + positivi noti in `docs/formato-asm.md`. Resta il fallback per i file che il parser esatto + non copre, e la fonte di verità per `ExtractCreoStrings.cs`/`BomViaDb.cs`. ## Import distinta base @@ -151,11 +163,9 @@ dipendenze extra con `#:package Nome@versione` in testa al file): Pro-E). - **BomViaDb.cs** — come sopra ma con la tokenizzazione generica validata contro il DB, per intercettare anche i nomi alfabetici delle parti commerciali. -- **CreoBom.cs** — indipendente dagli altri: legge esattamente la struttura a record del - binario `.asm` (sezione `members` + parametro `ConfigName`, vedi - `docs/formato-asm-record.md`) invece di contare occorrenze di codici nel testo. Risolve i - sottoassiemi cercandoli in cartelle locali (`-p`), non contro `cad_index.db`. Non sostituisce - l'euristica di `AssemblyComponentScanner` nella GUI né negli altri script. +- **CreoBom.cs** — usa `AsmRecordReader` (stesso lettore esatto della GUI, fase 4/5, via + `#:project`) invece dell'euristica testuale. Risolve i sottoassiemi cercandoli in cartelle + locali (`-p`), non contro `cad_index.db` come `ExtractCreoStrings.cs`/`BomViaDb.cs`. - **Analyze.cs** — dump del contesto testo/hex attorno a un pattern in un file binario, per investigare a mano nuovi falsi positivi prima di toccare la regex di `AssemblyComponentScanner`. diff --git a/docs/formato-asm-record.md b/docs/formato-asm-record.md index 039ee25..f90ef02 100644 --- a/docs/formato-asm-record.md +++ b/docs/formato-asm-record.md @@ -7,7 +7,11 @@ l'euristica testuale ("codice che compare almeno due volte") descritta in [`formato-asm.md`](formato-asm.md): quella era una statistica sui nomi, questa legge la struttura effettiva dei record. -Implementazione: [`scripts/CreoBom.cs`](../scripts/CreoBom.cs). +Implementazione: lettura in [`PrepStep/AsmRecordReader.cs`](../PrepStep/AsmRecordReader.cs) +(condivisa con la GUI, `Form1.EstraiCodiciReferenziati` in fase "Trova componenti +referenziati"/"Verifica completezza" - con fallback su `AssemblyComponentScanner` per i file +che non rispettano il formato validato), resa in formato BOM e ricorsione sui sottoassiemi +in [`scripts/CreoBom.cs`](../scripts/CreoBom.cs) (via `#:project`). **Stato della validazione.** Durante il reverse engineering, il livello 1 estratto da 3 assiemi con BOM Creo di riferimento coincideva **esattamente**: stesse voci, stesso nome @@ -75,7 +79,7 @@ conteggio dei membri: con la lettura a 1 byte fisso i campioni grandi si disallineavano. Non è confermata da una specifica; regge sui campioni disponibili al momento del reverse engineering. -In `CreoBom.cs` è `CreoAsm.AsInt`, applicata al testo decodificato Latin1 (ogni `char` +In `CreoBom.cs` è `AsmRecordReader.AsInt`, applicata al testo decodificato Latin1 (ogni `char` della stringa corrisponde 1:1 a un byte, quindi indicizzare la stringa o l'array di byte originale dà lo stesso offset). @@ -128,15 +132,15 @@ codici distinti: una parte montata 4 volte conta 4 e compare in 4 record con id diversi. Il conteggio va usato come controllo di correttezza: se i record trovati non sono esattamente `N`, la lettura si è disallineata e va segnalata un'eccezione invece di restituire un risultato parziale silenzioso — vedi -`CreoAsm.LeggiMembri`, che lancia `InvalidDataException` in questo caso. +`AsmRecordReader.LeggiMembri`, che lancia `InvalidDataException` in questo caso. La sezione termina a `\xf4\x04#\n#MdlStatus\n`. -### 3.1 Come lo legge `CreoAsm.LeggiMembri` +### 3.1 Come lo legge `AsmRecordReader.LeggiMembri` Il parser non ricostruisce lo schema completo (servirebbe decodificare le matrici). Si ancora invece al pattern `\xe3 \0 \0` -(`CreoAsm.MemberRegex`, una regex .NET sul testo Latin1 del file) e salta il +(`AsmRecordReader.MemberRegex`, una regex .NET sul testo Latin1 del file) e salta il resto del record fino al `\xe3` successivo che soddisfa il pattern. La doppia stringa terminata da `\0` rende i falsi positivi improbabili, e il conteggio dichiarato `N` fa da rete di sicurezza. @@ -172,14 +176,14 @@ Il collegamento sta in un record di **parametro** applicato al membro: linea), poi è omesso. - `\x82\x7b` — costante (= 635) identica su tutte le occorrenze di tutti i campioni. Significato ignoto, presumibilmente attributi/flag del parametro. - `CreoAsm.ConfigNameRegex` la usa come ancora di fine record; se un campione + `AsmRecordReader.ConfigNameRegex` la usa come ancora di fine record; se un campione futuro avesse un valore diverso, il match salterebbe silenziosamente (nessun contatore di sicurezza analogo a quello di `members` copre questo caso). `ConfigName` compare **esattamente** per i membri che sono istanze, e per nessun altro. L'algoritmo è quindi: leggere `members`, costruire la mappa `id membro → nome istanza` dai record `ConfigName` -(`CreoAsm.LeggiMembri`, dizionario `istanze`), e sostituire il nome dove la +(`AsmRecordReader.LeggiMembri`, dizionario `istanze`), e sostituire il nome dove la mappa ha una voce. Il generico resta disponibile come `Membro.File`. > **Avvertenza.** `ConfigName` sta in mezzo a parametri palesemente aziendali @@ -218,7 +222,7 @@ primo record `objname` del file: ``` La **prima** occorrenza è il modello stesso; quelle successive sono altri -oggetti referenziati (disegni, modelli citati). `CreoAsm.NomeProprio` cerca +oggetti referenziati (disegni, modelli citati). `AsmRecordReader.NomeProprio` cerca solo la prima occorrenza (`Regex.Match`, non `Matches`), verificato su tutti i campioni disponibili durante il reverse engineering. @@ -297,7 +301,7 @@ Elencato esplicitamente per non far passare per dimostrato ciò che non lo è: situazione divergerebbe. - **La costante `\x82\x7b`** nei record `ConfigName`. - **I campi numerici dell'header** dopo `ASSEMBLY`. -- **Occorrenze multiple di `members`.** `CreoAsm.LeggiMembri` prende la prima. +- **Occorrenze multiple di `members`.** `AsmRecordReader.LeggiMembri` prende la prima. Sui campioni disponibili funziona, ma non è stato verificato se le rappresentazioni semplificate (simplified rep) generino sezioni `members` aggiuntive. diff --git a/docs/formato-asm.md b/docs/formato-asm.md index 61121b2..e5b1658 100644 --- a/docs/formato-asm.md +++ b/docs/formato-asm.md @@ -1,23 +1,24 @@ # Come leggere un .asm Creo per dedurne i componenti Il file `.asm` di Pro/ENGINEER-Creo è un formato binario proprietario, non -documentato pubblicamente. Questo documento descrive l'euristica testuale usata da -`AssemblyComponentScanner.cs` (GUI, fase "Trova componenti referenziati"/"Verifica -completezza") e dagli script che risolvono l'albero componenti contro `cad_index.db` -(`ExtractCreoStrings.cs`, `BomViaDb.cs`): conta le occorrenze di stringhe candidate nel -testo binario, validata a mano confrontando l'output con l'albero reale mostrato da Creo. -Riassume cosa si è osservato e i suoi limiti, in particolare i falsi positivi che -un conteggio testuale non può escludere con certezza (riferimenti fantasma, componenti -soppressi con placement residuo — vedi sotto). +documentato pubblicamente. Questo documento descrive l'euristica testuale di +`AssemblyComponentScanner.cs`: conta le occorrenze di stringhe candidate nel testo binario, +validata a mano confrontando l'output con l'albero reale mostrato da Creo. Riassume cosa si +è osservato e i suoi limiti, in particolare i falsi positivi che un conteggio testuale non +può escludere con certezza (riferimenti fantasma, componenti soppressi con placement +residuo — vedi sotto). -Un reverse engineering successivo ha invece decodificato la **struttura a record** del -file (non solo pattern testuali): legge esattamente la sezione `members` di un assieme, -senza contare occorrenze né rischiare i falsi positivi descritti in questo documento. -Implementato in [`scripts/CreoBom.cs`](../scripts/CreoBom.cs), documentato in -[`formato-asm-record.md`](formato-asm-record.md). Resta però uno script di analisi -indipendente: legge solo `.asm` locali dato un percorso di ricerca esplicito (non -`cad_index.db`), e non è integrato in `AssemblyComponentScanner`/nella GUI, che -continuano a usare l'euristica descritta qui. +Un reverse engineering successivo ha decodificato la **struttura a record** del file (non +solo pattern testuali): legge esattamente la sezione `members` di un assieme, senza contare +occorrenze né rischiare i falsi positivi descritti in questo documento. Implementato in +`PrepStep/AsmRecordReader.cs`, documentato in [`formato-asm-record.md`](formato-asm-record.md). +È il metodo **preferito** in GUI (fase "Trova componenti referenziati"/"Verifica +completezza", `Form1.EstraiCodiciReferenziati`) e nello script `CreoBom.cs`: +`AssemblyComponentScanner` (l'euristica di questo documento) resta come **fallback**, usato +solo quando `AsmRecordReader` lancia `InvalidDataException` (file che non rispetta il +formato validato dal reverse engineering — copertura limitata agli 8 campioni disponibili al +momento), e come fonte di verità per gli script `ExtractCreoStrings.cs`/`BomViaDb.cs`, che +risolvono l'albero contro `cad_index.db` invece che tramite un percorso di ricerca locale. ## Struttura osservata diff --git a/scripts/CreoBom.cs b/scripts/CreoBom.cs index 3d669ba..d97a796 100644 --- a/scripts/CreoBom.cs +++ b/scripts/CreoBom.cs @@ -1,18 +1,17 @@ +#:project ../PrepStep/PrepStep.csproj +#:property TargetFramework=net10.0-windows // dotnet run scripts/CreoBom.cs -- [-p cartella]... [-o out.bom.1] // -// Parser esatto del formato binario .asm di Creo, ricavato per reverse engineering (vedi -// docs/formato-asm-record.md per la spiegazione byte-per-byte dei record, verificata -// confrontando l'output con le BOM esportate da Creo). Legge la sezione "members" di un -// assieme (id/tipo/nome dei componenti diretti, con risoluzione del nome istanza di family -// table dal parametro "ConfigName") e, dato un elenco di cartelle dove cercare i -// sottoassiemi, genera la stessa distinta base (BOM) esportata da Creo. -// -// Alternativa esatta all'euristica testuale di PrepStep/AssemblyComponentScanner.cs (docs/ -// formato-asm.md): quella conta le occorrenze dei codici nel testo, questa legge la -// struttura dei record. Per ora resta uno script di analisi indipendente: non sostituisce -// AssemblyComponentScanner nella GUI (fase 4) ne' negli altri script. +// Genera la distinta base (BOM) di un assieme Creo, dato un elenco di cartelle dove +// cercare i sottoassiemi (-p). La lettura del formato binario (sezione "members" + +// parametro "ConfigName", vedi docs/formato-asm-record.md) vive in +// PrepStep.AsmRecordReader, via "#:project" - stessa fonte usata da Form1 nella GUI (fase +// 4/5, con fallback automatico all'euristica di AssemblyComponentScanner.cs per i file che +// non rispettano il formato validato): qui solo la ricorsione sui sottoassiemi, il +// riepilogo delle quantita' e la resa nel formato esatto della BOM Creo. using System.Text; using System.Text.RegularExpressions; +using PrepStep; if (args.Length == 0) { @@ -45,106 +44,17 @@ else } return 0; -/// Lettura del formato .asm Creo (sezione "members" + parametro "ConfigName") e resa -/// nel formato della BOM esportata da Creo. Vedi docs/formato-asm-record.md per il dettaglio -/// dei record e i limiti noti (stato soppresso non gestito, matrice di posizionamento non -/// decodificata, deduplica dei blocchi non dimostrata su assiemi che montano lo stesso -/// sottoassieme due volte). +/// Ricorsione sui sottoassiemi e resa nel formato della BOM esportata da Creo. La +/// lettura del formato binario vive in (PrepStep/, condivisa +/// con la GUI). Vedi docs/formato-asm-record.md per il dettaglio dei record e i limiti noti +/// (stato soppresso non gestito, matrice di posizionamento non decodificata, deduplica dei +/// blocchi non dimostrata su assiemi che montano lo stesso sottoassieme due volte). public static class CreoAsm { - public sealed record Membro(int Id, int Tipo, string Nome, string File); - - // interi big-endian, lunghezza nei bit alti del primo byte (0xe0+ e' riservato ai - // marcatori di struttura): 0x00-0x7f 1 byte, 0x80-0xbf 2 byte, 0xc0-0xdf 3. - private const string Int = @"([\x00-\x7f]|[\x80-\xbf][\x00-\xff]|[\xc0-\xdf][\x00-\xff][\x00-\xff])"; - - // \xe3 id type name\0 family_name\0 - private static readonly Regex MemberRegex = new( - @"\xe3" + Int + Int + @"([\x20-\x7e]+)\x00([\x20-\x7e]+)\x00", RegexOptions.Compiled); - - // parametro ConfigName: nome istanza family table + id del membro a cui si applica. - private static readonly Regex ConfigNameRegex = new( - @"\xe3ConfigName\x00\xe23(?:\xf7[\x00-\xff])?([\x20-\x7e]+)\x00" + Int + @"\x82\x7b", - RegexOptions.Compiled); - - // nome proprio del modello (quello che la BOM stampa in intestazione). - private static readonly Regex ObjNameRegex = new( - @"\x00objname\x00\xe2\xe0\x01object_type\x00[\x00-\x7f]\xe0\x0aname\x00([^\x00]+)\x00", - RegexOptions.Compiled); - - // primo elemento dell'array "members": porta le chiavi per esteso. - private static readonly Regex FirstMemberRegex = new( - @"\x01id\x00" + Int + @"\xe0\x01type\x00" + Int - + @"\xe0\x0aname\x00([^\x00]+)\x00\xe0\x0afamily_name\x00([^\x00]+)\x00", - RegexOptions.Compiled); - private static readonly Dictionary TipoNome = new() { [1] = "Sottoassieme", [2] = "Parte" }; - private static int AsInt(string bytes) - { - int mask = bytes.Length switch { 1 => 0x7F, 2 => 0x3F, 3 => 0x1F, _ => throw new ArgumentException("lunghezza intero non valida") }; - int valore = bytes[0] & mask; - for (int i = 1; i < bytes.Length; i++) - valore = (valore << 8) | bytes[i]; - return valore; - } - - /// Nome proprio del modello (quello che Creo stampa in intestazione BOM), - /// dalla prima occorrenza del record "objname". - public static string NomeProprio(byte[] dati) - { - var m = ObjNameRegex.Match(Encoding.Latin1.GetString(dati)); - if (!m.Success) - throw new InvalidDataException("record 'objname' non trovato"); - return m.Groups[1].Value; - } - - /// Componenti diretti di un assieme, in ordine di montaggio. "Nome" e' il nome - /// che mostrerebbe la BOM (istanza di family table se presente), "File" e' il generico - /// realmente su disco (coincide con "Nome" per i componenti che non sono istanze). - public static List LeggiMembri(byte[] dati) - { - string testo = Encoding.Latin1.GetString(dati); - - var istanze = new Dictionary(); - foreach (Match m in ConfigNameRegex.Matches(testo)) - istanze[AsInt(m.Groups[2].Value)] = m.Groups[1].Value; - - const string chiaveMembers = "\0members\0"; - int start = testo.IndexOf(chiaveMembers, StringComparison.Ordinal); - if (start < 0) - throw new InvalidDataException("sezione 'members' assente"); - start += chiaveMembers.Length; - - int conteggio = dati[start + 2]; // \xf1 \xf8 - int end = testo.IndexOf("#MdlStatus", start, StringComparison.Ordinal); - string region = testo.Substring(start, (end > 0 ? end : testo.Length) - start); - - var grezzi = new List<(int Id, int Tipo, string Nome)>(); - - // il primo record e' esteso: ricava i campi dalle chiavi, i successivi sono posizionali - var primo = FirstMemberRegex.Match(region); - if (primo.Success) - { - grezzi.Add((AsInt(primo.Groups[1].Value), AsInt(primo.Groups[2].Value), primo.Groups[3].Value)); - region = region.Substring(primo.Index + primo.Length); - } - - foreach (Match m in MemberRegex.Matches(region)) - { - grezzi.Add((AsInt(m.Groups[1].Value), AsInt(m.Groups[2].Value), m.Groups[3].Value)); - if (grezzi.Count == conteggio) - break; - } - - if (grezzi.Count != conteggio) - throw new InvalidDataException($"attesi {conteggio} membri, trovati {grezzi.Count}: lettura disallineata"); - - return grezzi.Select(g => new Membro(g.Id, g.Tipo, istanze.GetValueOrDefault(g.Id, g.Nome), g.Nome)).ToList(); - } - /// Voci distinte con quantita', nell'ordine di prima comparsa (come la BOM). - public static List<((int Tipo, string Nome) Chiave, int Qta)> Rollup(IEnumerable membri) + public static List<((int Tipo, string Nome) Chiave, int Qta)> Rollup(IEnumerable membri) { var ordine = new List<(int, string)>(); var qty = new Dictionary<(int, string), int>(); @@ -163,7 +73,7 @@ public static class CreoAsm /// nome modello -> membri, cercando <nome>.asm.<rev> nelle cartelle /// date. A parita' di nome vince la revisione piu' alta presente. Nome null se il file /// non c'e' in nessuna delle cartelle. - private static Func?> CreaFinder(IEnumerable cartelle) + private static Func?> CreaFinder(IEnumerable cartelle) { var indice = new Dictionary(StringComparer.OrdinalIgnoreCase); var revisioni = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -184,12 +94,12 @@ public static class CreoAsm } } - var cache = new Dictionary?>(); + var cache = new Dictionary?>(); return nome => { if (!cache.TryGetValue(nome, out var mem)) { - mem = indice.TryGetValue(nome, out var percorso) ? LeggiMembri(File.ReadAllBytes(percorso)) : null; + mem = indice.TryGetValue(nome, out var percorso) ? AsmRecordReader.LeggiMembri(File.ReadAllBytes(percorso)) : null; cache[nome] = mem; } return mem; @@ -202,7 +112,7 @@ public static class CreoAsm /// Blocchi in ordine depth-first pre-order. Ogni modello compare una volta sola /// (deduplica per nome) anche se montato piu' volte. - private static List Blocchi(string root, Func?> get) + private static List Blocchi(string root, Func?> get) { var risultato = new List(); var visti = new HashSet(); @@ -231,7 +141,7 @@ public static class CreoAsm /// Totale per parte, moltiplicato lungo l'albero, in ordine di prima comparsa: una /// parte che compare a piu' livelli occupa la posizione della sua prima comparsa e somma /// le quantita'. - private static void Sommario(string root, Func?> get, int mult, + private static void Sommario(string root, Func?> get, int mult, List ordine, Dictionary acc) { var mem = get(root); @@ -258,7 +168,7 @@ public static class CreoAsm /// Testo della BOM, identico byte per byte a quello esportato da Creo (UTF-8 con /// BOM, CRLF - vedi GeneraBom per la scrittura su file). - private static string Render(string root, Func?> get) + private static string Render(string root, Func?> get) { var pezzi = new List(); foreach (var blocco in Blocchi(root, get)) @@ -286,6 +196,6 @@ public static class CreoAsm public static string GeneraBom(string percorsoAsm, IEnumerable cartelle) { byte[] dati = File.ReadAllBytes(percorsoAsm); - return Render(NomeProprio(dati), CreaFinder(cartelle)); + return Render(AsmRecordReader.NomeProprio(dati), CreaFinder(cartelle)); } }