Files
prep-step/PrepStep/AsmRecordReader.cs
davideandClaude Opus 5 13f6de6402 Copia in raw anche i modelli citati per riferimento esterno
Terzo caso di "[-4] Dati di input non completi" in Creo Distributed
Batch, dopo i soppressi e le copie ambigue del commit precedente: falliva
9255728001, e solo lui fra i sei assiemi del batch.

Il file in raw/ era identico bit per bit a quello sulla share, quindi non
era il modello: mancava il contorno. Il suo sottoassieme _200 e' legato
ad altri due (9255708017, 9255708018) da assembly feature e datum
copiati, e monta una vite V6-12--_-_U5933_C il cui generico uni5933.prt
non era in raw/. Nessuno dei sei file compariva in "members" ne' fra i
piazzamenti: i due lettori esistenti non potevano vederli.

Quei modelli stanno in una tabella a parte del .asm - array \xf2\xf7\x05
… \xf3 con elementi \xe3\xe2<tipo><nome>\0 - che ora si legge con
EstraiCodiciRiferimentiEsterni, riusando la normalizzazione di #AF<n> e
istanza<generico> gia' fatta per i membri. A differenza dei soppressi si
ricorre dentro: Creo carica il modello citato con tutto il suo albero, e
infatti 9255700182 arriva solo per quella via.

La tabella contiene anche nomi di feature (MIRROR3, CHAMFER1) che dal
record non si distinguono dai modelli. Non e' un problema: come per i
soppressi si cerca ogni nome nell'indice e si ignora quello che non c'e'.
Per lo stesso motivo questi codici restano fuori dai "non trovati", e la
coda di scansione porta ora un flag che tiene fuori anche i rami
raggiunti per riferimento esterno - la lista dei componenti mancanti
continua a parlare solo della distinta.

Documentato in docs/formato-asm-record.md §7.5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:09:48 +02:00

274 lines
16 KiB
C#

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);
// Un componente creato da un pattern/array feature ha un ConfigName con un tag interno
// "<codice>#AF<n>" (Array Feature, n = indice nel pattern), non un'istanza di family
// table vera: la BOM reale di Creo lo ignora e mostra il codice pulito - osservato su
// 9244250136.asm (4 dei suoi 8 membri, due dei quali stesso codice "_C03" con "#AF0" e
// "#AF1" diversi, che la BOM unisce in una sola riga con quantita' 2). Senza questa
// normalizzazione ogni istanza del pattern risulterebbe un componente diverso.
private static readonly Regex SuffissoArrayFeatureRegex = new(@"#AF\d+$", RegexOptions.Compiled);
// record di identita' del modello, seguito da "generic_name" (vuoto): compare su una
// minoranza di assiemi (~7% dei campioni sulla share) per motivi diversi e nella
// maggioranza dei casi "members" resta corretto - la sola presenza di "generic_name" NON
// e' un segnale affidabile (verificato: su 144 file che lo contengono, solo 25 hanno
// anche il sintomo sotto; per gli altri 119 "members" letto normalmente coincide con
// l'euristica testuale, quindi e' un contesto diverso, non lo stesso problema).
private static readonly Regex OwnModelIdentityRegex = new(
@"\xe0\x01model_t_type\x00" + Int + @"\xe0\x0amodel_name\x00([\x20-\x7e]+)\x00\xe0\x0ageneric_name\x00\x00",
RegexOptions.Compiled);
// Alcuni membri di family table non-UNI (es. viti/dadi commerciali) non passano da
// ConfigName: il campo "name" del record members porta gia' la forma "<istanza><generico>"
// (stessa notazione di §4.1, li' sconsigliata per la BOM perche' e' un elenco di
// riferimenti geometrici - qui invece e' il valore vero del componente, va solo separato).
// Osservato su 9244248577a_c01.asm: "V6-12--_-_U5931_GC<UNI5931>" - senza questo split il
// codice cercato nell'indice sarebbe la stringa intera con le parentesi, mai trovata.
private static readonly Regex IstanzaConGenericoRegex = new(@"^([\x20-\x7e]+?)<([\x20-\x7e]+)>$", RegexOptions.Compiled);
// Suffisso "_C0n" delle istanze di family table (stessa convenzione di
// SuffissoArrayFeatureRegex/ConfigName sopra). Usato insieme a OwnModelIdentityRegex per
// riconoscere un file che e' esso stesso un'istanza, il cui "members" descriverebbe solo
// quella rappresentazione invece delle istanze sorelle (docs/formato-asm-record.md §7).
// Da solo NON basta: su file reali quel record nomina spesso un altro modello (un membro
// del file, o un codice imparentato), non il file stesso - per quello serve la conferma
// del nome proprio (objname).
private static readonly Regex SuffissoIstanzaFamilyTableRegex = new(@"_C\d{2,}$", 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 ContaOccorrenze(string testo, string marcatore)
{
int conteggio = 0;
for (int i = testo.IndexOf(marcatore, StringComparison.Ordinal); i >= 0; i = testo.IndexOf(marcatore, i + 1, StringComparison.Ordinal))
conteggio++;
return conteggio;
}
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 configNameMatches = ConfigNameRegex.Matches(testo);
int configNameGrezzi = ContaOccorrenze(testo, (char)0xe3 + "ConfigName\0");
if (configNameMatches.Count != configNameGrezzi)
throw new InvalidDataException(
$"trovati {configNameGrezzi} record 'ConfigName' ma solo {configNameMatches.Count} " +
"interpretati: formato del parametro diverso da quello atteso (vedi §4, costante " +
"\\x82\\x7b), lettura non affidabile");
var istanze = new Dictionary<int, string>();
foreach (Match m in configNameMatches)
istanze[AsInt(m.Groups[2].Value)] = SuffissoArrayFeatureRegex.Replace(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");
if (testo.IndexOf(chiaveMembers, start + chiaveMembers.Length, StringComparison.Ordinal) >= 0)
throw new InvalidDataException(
"trovate piu' occorrenze di 'members': probabile rappresentazione semplificata, " +
"mai verificata (vedi §7) - non e' chiaro quale sezione descriva la BOM");
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");
// Il record "model_name" e' l'identita' del file solo se il nome proprio del modello
// (objname, §5) lo conferma: da solo nomina spesso un altro modello - un membro del
// file, o un modello imparentato - e in quei casi "members" e' gia' corretto. Vedi
// docs/formato-asm-record.md §7.
var identita = OwnModelIdentityRegex.Match(testo);
var nomeProprio = ObjNameRegex.Match(testo);
if (identita.Success && nomeProprio.Success
&& SuffissoIstanzaFamilyTableRegex.IsMatch(identita.Groups[2].Value)
&& string.Equals(SuffissoIstanzaFamilyTableRegex.Replace(identita.Groups[2].Value, ""),
nomeProprio.Groups[1].Value, StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException(
$"assieme con family table: il file e' esso stesso l'istanza '{identita.Groups[2].Value}', " +
"'members' descriverebbe solo questa rappresentazione, non le istanze sorelle - vedi " +
"docs/formato-asm-record.md §7");
return grezzi.Select(g =>
{
if (istanze.TryGetValue(g.Id, out var nomeIstanza))
return new Membro(g.Id, g.Tipo, nomeIstanza, g.Nome);
var bracket = IstanzaConGenericoRegex.Match(g.Nome);
return bracket.Success
? new Membro(g.Id, g.Tipo, bracket.Groups[1].Value, bracket.Groups[2].Value)
: new Membro(g.Id, g.Tipo, 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);
}
/// <summary>Codici che compaiono come piazzamento ma non fra i membri: sono i componenti
/// **soppressi**, che spariscono da "members" senza lasciare traccia (§7.1) ma restano
/// citati dalla feature che li posiziona.
///
/// Non fanno parte della BOM - Creo non li elenca - ma il loro file serve comunque per
/// **aprire** l'assieme: senza, il caricamento fallisce con "dati di input non completi".
/// Sono quindi due domande diverse: cosa compare nella distinta (<see
/// cref="EstraiCodiciReferenziati"/>) e cosa serve avere su disco (questo metodo in
/// aggiunta), e la seconda lista e' piu' lunga della prima.</summary>
public static HashSet<string> EstraiCodiciSoppressi(string percorsoAsm)
{
byte[] dati = File.ReadAllBytes(percorsoAsm);
var membri = LeggiMembri(dati);
var noti = new HashSet<string>(
membri.Select(m => m.Generico).Concat(membri.Select(m => m.Nome)), StringComparer.OrdinalIgnoreCase);
var soppressi = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in PlacementRegex.Matches(Encoding.Latin1.GetString(dati)))
if (!noti.Contains(m.Groups[1].Value))
soppressi.Add(m.Groups[1].Value);
return soppressi;
}
/// <summary>Modelli che l'assieme cita nella tabella dei riferimenti (§7.5): oltre ai propri
/// membri comprende quelli legati da **riferimenti esterni** - assembly feature, datum
/// copiati - che non compaiono né in "members" né come piazzamento, ma che Creo carica
/// comunque per aprire l'assieme.
///
/// Terza lista dopo <see cref="EstraiCodiciReferenziati"/> ("cosa c'è in distinta") e <see
/// cref="EstraiCodiciSoppressi"/>: come quest'ultima risponde a "cosa serve su disco", non a
/// "cosa va nella BOM. L'array contiene anche nomi di feature (MIRROR3, CHAMFER1...) e non
/// solo di modelli: non si riesce a distinguerli dal record, ma non danno fastidio perché il
/// chiamante li cerca nell'indice e ignora quelli che non ci sono.</summary>
public static HashSet<string> EstraiCodiciRiferimentiEsterni(string percorsoAsm)
{
string testo = Encoding.Latin1.GetString(File.ReadAllBytes(percorsoAsm));
var codici = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = testo.IndexOf(InizioRiferimenti, StringComparison.Ordinal); i >= 0;
i = testo.IndexOf(InizioRiferimenti, i + 1, StringComparison.Ordinal))
{
int max = Math.Min(LunghezzaMassimaRiferimenti, testo.Length - i);
int fine = testo.IndexOf(FineRiferimenti, i, max);
foreach (Match m in RiferimentoRegex.Matches(testo.Substring(i, fine >= 0 ? fine - i : max)))
{
var bracket = IstanzaConGenericoRegex.Match(m.Groups[1].Value);
codici.Add(bracket.Success
? bracket.Groups[2].Value
: SuffissoArrayFeatureRegex.Replace(m.Groups[1].Value, ""));
}
}
return codici;
}
// Array dei modelli referenziati: "\xf2\xf7\x05" lo apre, "\xf3" lo chiude, ogni elemento e'
// "\xe3\xe2<tipo><nome>\x00" (§7.5). Il tetto di lunghezza limita il danno se il terminatore
// non si trova dove atteso: si perde qualche riferimento, non si raccoglie mezzo file.
private const string InizioRiferimenti = "ò÷\u0005ãâ";
private const char FineRiferimenti = 'ó';
private const int LunghezzaMassimaRiferimenti = 4096;
private static readonly Regex RiferimentoRegex = new(
@"\xe3\xe2[\x01\x02]([\x20-\x7e]{3,})\x00", RegexOptions.Compiled);
// "Componente ID 52 (9230100043.PRT)" - la stringa cambia con la localizzazione Creo del
// file, come in AssemblyComponentScanner.HaPlacement. Minimo 3 caratteri per lo stesso
// motivo per cui li' i token generici partono da 3: sotto quella soglia si raccoglie
// rumore (sulla share esistono davvero modelli chiamati "1.prt", "2.prt"...).
private static readonly Regex PlacementRegex = new(
@"Component(?:e)? [Ii][Dd] \d+ \(([A-Za-z0-9_\-]{3,})\.(?:PRT|ASM)\)", RegexOptions.Compiled);
}