Integra il parser esatto del .asm nella GUI (fase 4 e 5)

AsmRecordReader (spostato da scripts/CreoBom.cs nel progetto principale,
unica fonte di verita' condivisa con lo script via #:project) sostituisce
AssemblyComponentScanner in Form1.RisolviAlberoComponenti (fase "Trova
componenti referenziati") e AssiemeCompleto (fase "Verifica completezza"):
legge esattamente la sezione "members" del binario invece di contare
occorrenze testuali, eliminando i falsi positivi/negativi noti dell'euristica
(riferimenti fantasma, placement senza suffisso, ecc.).

Se un file non rispetta il formato validato (InvalidDataException, coperto
solo dagli 8 campioni usati per il reverse engineering) Form1 ripiega
sull'euristica di AssemblyComponentScanner, che resta quindi come fallback
e come fonte di verita' per gli script BomViaDb.cs/ExtractCreoStrings.cs.

Aggiunti test per AsmRecordReader (fixture binarie costruite a mano: lettura
membri, risoluzione ConfigName, conteggio disallineato, sezione assente).
Aggiornata la documentazione (CLAUDE.md, docs/architettura.md,
docs/formato-asm.md, docs/formato-asm-record.md) di conseguenza.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5 committed 2026-09-21 16:14:00 +02:00
1 parent 60dfd26e8b
commit cce377a091
8 files changed
+363 -163

No files matched your search

+18 -12
View File
@@ -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`.
+123
View File
@@ -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 <istanza>\0 <idMembro> \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<byte>(), "\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<InvalidDataException>(() => 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<InvalidDataException>(() => 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<DirectoryNotFoundException>(() =>
AsmRecordReader.EstraiCodiciReferenziati(@"C:\non\esiste\pippo.asm.1"));
}
+126
View File
@@ -0,0 +1,126 @@
using System.Text;
using System.Text.RegularExpressions;
namespace PrepStep;
/// <summary>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 <see cref="AssemblyComponentScanner"/>
/// (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 <see cref="InvalidDataException"/>
/// invece di restituire un risultato parziale silenzioso - il chiamante (<see
/// cref="Form1"/>) 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.</summary>
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;
}
/// <summary>Nome proprio del modello (quello che Creo stampa in intestazione BOM),
/// dalla prima occorrenza del record "objname".</summary>
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;
}
/// <summary>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 <see cref="InvalidDataException"/> 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).</summary>
public static List<Membro> LeggiMembri(byte[] dati)
{
string testo = Encoding.Latin1.GetString(dati);
var istanze = new Dictionary<int, string>();
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 <conteggio>
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();
}
/// <summary>Codici dei componenti diretti (il generico su disco, non il nome istanza),
/// distinti, nell'ordine in cui compaiono. Equivalente esatto di
/// <see cref="AssemblyComponentScanner.EstraiCodiciReferenziati"/> per i file coperti dal
/// formato: stesso contratto di ritorno (un insieme di codici da cercare nell'indice),
/// lettura strutturale invece di euristica testuale.</summary>
public static HashSet<string> EstraiCodiciReferenziati(string percorsoAsm)
{
var membri = LeggiMembri(File.ReadAllBytes(percorsoAsm));
return new HashSet<string>(membri.Select(m => m.Generico), StringComparer.OrdinalIgnoreCase);
}
}
+22 -2
View File
@@ -743,6 +743,26 @@ public partial class Form1 : Form
return codiciNoti;
}
/// <summary>Codici referenziati direttamente in un assieme: prova prima il parser esatto
/// dei record binari (<see cref="AsmRecordReader"/>, 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 (<see cref="InvalidDataException"/> - capita su
/// .asm di versioni Creo mai riscontrate nei campioni usati per il reverse engineering)
/// ripiega su <see cref="AssemblyComponentScanner"/>, 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.</summary>
private static IEnumerable<string> EstraiCodiciReferenziati(string percorsoAsm, ICollection<string> codiciNoti)
{
try
{
return AsmRecordReader.EstraiCodiciReferenziati(percorsoAsm);
}
catch (InvalidDataException)
{
return AssemblyComponentScanner.EstraiCodiciReferenziati(percorsoAsm, codiciNoti);
}
}
/// <summary>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)
{
+21 -11
View File
@@ -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`.
+13 -9
View File
@@ -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 <int> <int> <stringa>\0 <stringa>\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.
+17 -16
View File
@@ -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
+23 -113
View File
@@ -1,18 +1,17 @@
#:project ../PrepStep/PrepStep.csproj
#:property TargetFramework=net10.0-windows
// dotnet run scripts/CreoBom.cs -- <file.asm.rev> [-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;
/// <summary>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).</summary>
/// <summary>Ricorsione sui sottoassiemi e resa nel formato della BOM esportata da Creo. La
/// lettura del formato binario vive in <see cref="AsmRecordReader"/> (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).</summary>
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<int, string> 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;
}
/// <summary>Nome proprio del modello (quello che Creo stampa in intestazione BOM),
/// dalla prima occorrenza del record "objname".</summary>
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;
}
/// <summary>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).</summary>
public static List<Membro> LeggiMembri(byte[] dati)
{
string testo = Encoding.Latin1.GetString(dati);
var istanze = new Dictionary<int, string>();
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 <conteggio>
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();
}
/// <summary>Voci distinte con quantita', nell'ordine di prima comparsa (come la BOM).</summary>
public static List<((int Tipo, string Nome) Chiave, int Qta)> Rollup(IEnumerable<Membro> membri)
public static List<((int Tipo, string Nome) Chiave, int Qta)> Rollup(IEnumerable<AsmRecordReader.Membro> membri)
{
var ordine = new List<(int, string)>();
var qty = new Dictionary<(int, string), int>();
@@ -163,7 +73,7 @@ public static class CreoAsm
/// <summary>nome modello -> membri, cercando &lt;nome&gt;.asm.&lt;rev&gt; 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.</summary>
private static Func<string, List<Membro>?> CreaFinder(IEnumerable<string> cartelle)
private static Func<string, List<AsmRecordReader.Membro>?> CreaFinder(IEnumerable<string> cartelle)
{
var indice = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var revisioni = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
@@ -184,12 +94,12 @@ public static class CreoAsm
}
}
var cache = new Dictionary<string, List<Membro>?>();
var cache = new Dictionary<string, List<AsmRecordReader.Membro>?>();
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
/// <summary>Blocchi in ordine depth-first pre-order. Ogni modello compare una volta sola
/// (deduplica per nome) anche se montato piu' volte.</summary>
private static List<Blocco> Blocchi(string root, Func<string, List<Membro>?> get)
private static List<Blocco> Blocchi(string root, Func<string, List<AsmRecordReader.Membro>?> get)
{
var risultato = new List<Blocco>();
var visti = new HashSet<string>();
@@ -231,7 +141,7 @@ public static class CreoAsm
/// <summary>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'.</summary>
private static void Sommario(string root, Func<string, List<Membro>?> get, int mult,
private static void Sommario(string root, Func<string, List<AsmRecordReader.Membro>?> get, int mult,
List<string> ordine, Dictionary<string, int> acc)
{
var mem = get(root);
@@ -258,7 +168,7 @@ public static class CreoAsm
/// <summary>Testo della BOM, identico byte per byte a quello esportato da Creo (UTF-8 con
/// BOM, CRLF - vedi GeneraBom per la scrittura su file).</summary>
private static string Render(string root, Func<string, List<Membro>?> get)
private static string Render(string root, Func<string, List<AsmRecordReader.Membro>?> get)
{
var pezzi = new List<string>();
foreach (var blocco in Blocchi(root, get))
@@ -286,6 +196,6 @@ public static class CreoAsm
public static string GeneraBom(string percorsoAsm, IEnumerable<string> cartelle)
{
byte[] dati = File.ReadAllBytes(percorsoAsm);
return Render(NomeProprio(dati), CreaFinder(cartelle));
return Render(AsmRecordReader.NomeProprio(dati), CreaFinder(cartelle));
}
}