abse/gameboy-advance

Validates and exports a four-layer Game Boy–style PCB assembly with RP2350 MCU, 32MB flash, USB-C, LCD and speaker connectors, battery buck-boost power, buttons, and verified routing, connectivity, clearances, CAD models, and physical DRC.

Version
0.0.39
License
unset
Stars
0

checks/build-validated.ts

PCBPCB preview for checks/build-validated.ts
SchematicSchematic preview for checks/build-validated.ts
import {validateSchematic} from "./schematic-validation"
import {auditPhysicalConnectivity} from './physical-connectivity'
import {readFileSync,writeFileSync,mkdirSync,readdirSync} from 'node:fs'
import {resolve,relative,join} from 'node:path'
import {isDeepStrictEqual} from 'node:util'
import {spawnSync} from 'node:child_process'
import {Circuit} from '@tscircuit/core'
import {createElement} from 'react'
import * as checks from '@tscircuit/checks'
import {convertCircuitJsonToPcbSvg,convertCircuitJsonToSchematicSvg} from 'circuit-to-svg'
const root=process.cwd(),batteryCellCount=Number(process.env.GBA_AA_CELLS??4);if(![3,4].includes(batteryCellCount))throw Error('GBA_AA_CELLS must be3or4');const out=join(root,batteryCellCount===4?'dist/index':'dist/index-3aa');mkdirSync(out,{recursive:true})
const baseline=JSON.parse(readFileSync('checks/accepted-baseline.json','utf8'))
const before=JSON.parse(readFileSync('checks/pre-flash.circuit.json','utf8'))
const assert=(ok:boolean,message:string)=>{if(!ok)throw Error(message)}
const oldComp=new Map(before.filter((e:any)=>e.type==='source_component').map((e:any)=>[e.source_component_id,e.name]));const pinMap:any={1:7,2:8,3:9,4:10,5:15,6:16,7:1,8:2}
const oldTraceByName=new Map(before.filter((e:any)=>e.type==='source_trace').map((e:any)=>[e.name??e.display_name,e]));const oldPhysicalPorts=before.filter((e:any)=>e.type==='source_port'&&oldComp.has(e.source_component_id))
const runner=new Circuit();const asyncErrors:any[]=[];let starts=0,ends=0
runner.on('autorouting:start',()=>{starts++;console.log('Native routing phase started',starts)})
runner.on('autorouting:end',()=>{ends++;console.log('Native routing phase completed',ends)})
runner.on('renderable:asyncError',(e:any)=>asyncErrors.push(e))
try{
 const module=batteryCellCount===4?await import('../index.circuit'):await import('../index-3aa.circuit');runner.add(createElement(module.default));await runner.renderUntilSettled()
 const c:any[]=JSON.parse(JSON.stringify(await runner.getCircuitJson()));writeFileSync(join(out,'circuit.json'),JSON.stringify(c));await import('./button-contacts');await import('./pcb-cutout-clearance');await import('./control-model-alignment');await import('./usb-differential-routing');assert(!asyncErrors.length&&starts===ends,'Native asynchronous routing failure')
 const physicalErrors=c.filter(e=>e.type.endsWith('_error')&&e.type!=='pcb_trace_too_long_error');assert(!physicalErrors.length,'Native physical/source DRC errors: '+JSON.stringify(physicalErrors))
 assert(c.filter(e=>e.type==='pcb_via').every(e=>e.hole_diameter>=.3 && e.outer_diameter>=.45),'Via dimensions below requested minima');
 assert(c.filter(e=>e.type==='pcb_via').every(e=>e.layers.length===4 && e.layers.includes('top') && e.layers.includes('bottom')),'Partial-depth via generated');
 const comp=new Map(c.filter(e=>e.type==='source_component').map(e=>[e.source_component_id,e]));const physicalPorts=c.filter(e=>e.type==='source_port'&&comp.has(e.source_component_id));assert(physicalPorts.length===470,'Expected470 real component ports')
 const byKey=new Map(physicalPorts.map(e=>[`${comp.get(e.source_component_id).name}:${e.pin_number}`,e]));const oldGroups=new Map<string,string[]>(),newGroups=new Map<string,string[]>()
 for(const p of oldPhysicalPorts){const name=oldComp.get(p.source_component_id);if(name==='U2'&&p.pin_number===9)continue;if((name==='U_3V3'&&p.pin_number===1)||(name==='U_SPK_AMP'&&p.pin_number===7))continue;const key=`${name}:${p.pin_number}`;const np=byKey.get(`${name}:${name==='U2'?pinMap[p.pin_number]:p.pin_number}`);assert(!!np,'Missing original physical pin '+key);const ok=p.subcircuit_connectivity_map_key??p.source_port_id,nk=np.subcircuit_connectivity_map_key??np.source_port_id;oldGroups.set(ok,[...(oldGroups.get(ok)??[]),key]);newGroups.set(nk,[...(newGroups.get(nk)??[]),key])}
 const partition=(m:Map<string,string[]>)=>[...m.values()].map(v=>v.sort().join('|')).sort();assert(isDeepStrictEqual(partition(oldGroups),partition(newGroups))&&newGroups.size===133,'Original net partitions changed')
 assert(byKey.get('U2:3').subcircuit_connectivity_map_key===byKey.get('U2:2').subcircuit_connectivity_map_key,'Flash dedicated reset must be high');for(const pin of [4,5,6,11,12,13,14])assert(!byKey.get('U2:'+pin).subcircuit_connectivity_map_key,'Connected NC flash pin')
 const enableBottom=[...comp.values()].find(e=>e.name==='R_BAT_EN_BOTTOM');assert(enableBottom.resistance===(batteryCellCount===3?130000:100000),'Wrong battery enable resistor');assert(enableBottom.supplier_part_numbers.jlcpcb[0]===(batteryCellCount===3?'C22795':'C25803'),'Wrong battery enable supplier part');
 const flash=[...comp.values()].find(e=>e.name==='U2');assert(flash.manufacturer_part_number==='W25Q256JVFIQ'&&flash.supplier_part_numbers.jlcpcb[0]==='C779876','Wrong flash');assert([...comp.values()].find(e=>e.name==='U1').manufacturer_part_number==='RP2350A','Wrong MCU')
 const batteryInductor=[...comp.values()].find(e=>e.name==='L_BAT_BUCKBOOST');assert(batteryInductor.manufacturer_part_number==='APH0420T1R5M'&&batteryInductor.supplier_part_numbers.jlcpcb[0]==='C5349662','Wrong stocked battery inductor');
 const speaker=[...comp.values()].find(e=>e.name==='J_SPK');assert(speaker.manufacturer_part_number==='BM02B-SRSS-TB(LF)(SN)'&&speaker.supplier_part_numbers.jlcpcb[0]==='C160388','Wrong JST-SH speaker connector');const speakerCad=c.find(e=>e.type==='cad_component'&&e.source_component_id===speaker.source_component_id);assert(speakerCad?.model_obj_url?.split('?')[0].endsWith('/C160388.obj')&&speakerCad?.model_step_url?.split('?')[0].endsWith('/C160388.step'),'Speaker connector imported CAD missing');
 const usb=[...comp.values()].find(e=>e.name==='J_USB');assert(usb.manufacturer_part_number==='TYPE-C-31-M-12'&&usb.supplier_part_numbers.jlcpcb[0]==='C165948','Wrong replacement USB connector');const usbCad=c.find(e=>e.type==='cad_component'&&e.source_component_id===usb.source_component_id);assert(usbCad?.model_obj_url?.split('?')[0].endsWith('/C165948.obj')&&usbCad?.model_step_url?.split('?')[0].endsWith('/C165948.step'),'Replacement USB imported CAD missing');
 const lcd=[...comp.values()].find(e=>e.name==='J_LCD');assert(lcd.manufacturer_part_number==='AFC07-S50ECC-00'&&lcd.supplier_part_numbers.jlcpcb[0]==='C11098','Wrong top-contact LCD connector');const lcdCad=c.find(e=>e.type==='cad_component'&&e.source_component_id===lcd.source_component_id);assert(lcdCad?.model_obj_url?.split('?')[0].endsWith('/C11098.obj')&&lcdCad?.model_step_url?.split('?')[0].endsWith('/C11098.step'),'LCD supplier CAD missing');
 const controls=JSON.parse(readFileSync('checks/button-positions.json','utf8'));for(const [name,xy] of Object.entries(controls) as any){const part=[...comp.values()].find(e=>e.name===name),pcb=c.find(e=>e.type==='pcb_component'&&e.source_component_id===part.source_component_id);assert(part.supplier_part_numbers.jlcpcb[0]==='C3038675'&&Math.abs(pcb.center.x-xy[0])<1e-6&&Math.abs(pcb.center.y-xy[1])<1e-6,'Restored control changed '+name)}
 const edgeOutline=JSON.parse(readFileSync('checks/edge-outline.json','utf8'));assert(isDeepStrictEqual(c.find(e=>e.type==='pcb_board'),{...baseline.board,...edgeOutline,allow_blind_and_buried_vias:false,min_via_hole_diameter:.3,min_via_pad_diameter:.45}),'Board outline, layer stack, or DRC rules changed')
 const traces=new Map(c.filter(e=>e.type==='source_trace').map(e=>[e.source_trace_id,e]));const lengths=c.filter(e=>e.type==='pcb_trace_too_long_error');const lengthComparison=lengths.map(e=>{const t=traces.get(e.source_trace_id),o=oldTraceByName.get(t.name??t.display_name);return {...e,traceName:t.name??t.display_name,baselineActualLength:o?baseline.lengthFindings[o.source_trace_id]??null:null}});writeFileSync(join(out,'length-comparison.json'),JSON.stringify(lengthComparison,null,2))
 assert(!lengths.length,'Trace-length errors must be zero: '+JSON.stringify(lengthComparison));
 const reviewedLimits=JSON.parse(readFileSync('checks/reviewed-length-limits.json','utf8'));for(const [name,limit] of Object.entries(reviewedLimits)){const t=[...traces.values()].find(t=>t.name===name);assert(t?.max_length===limit,'Reviewed length limit changed '+name)}
 for(const t of c.filter(e=>e.type==='pcb_trace')){const o=oldTraceByName.get(traces.get(t.source_trace_id)?.name??traces.get(t.source_trace_id)?.display_name);const ws=t.route.filter((q:any)=>q.route_type==='wire').map((q:any)=>q.width);assert(Math.min(...ws)+1e-9>=(o?baseline.minimumWidths[o.source_trace_id]??.1:.1),'Trace narrowed '+t.source_trace_id);assert(t.route.every((q:any)=>q.route_type!=='wire'||['top','inner1','inner2','bottom'].includes(q.layer)),'More than four layers')}
 const results:Record<string,any[]>={}
 for(const name of ['checkEachPcbPortConnectedToPcbTraces','checkSourceTracesHavePcbTraces','checkEachPcbTraceNonOverlapping','checkTracesAreContiguous','checkSourceTracesMatchPcbTraceThickness','checkPadPadClearance','checkPadTraceClearance','checkViaPadClearance','checkViaTraceClearance','checkSameNetViaSpacing','checkDifferentNetViaSpacing','checkPcbTracesOutOfBoard','checkCopperToBoardEdgeClearance','checkViasOffBoard','checkCopperPourShorts','checkHoleTraceClearance','checkPcbComponentOverCutout']){
  const findings=await (checks as any)[name](c);results[name]=findings;console.log(name,findings.length)
  if(name==='checkSourceTracesMatchPcbTraceThickness'){const allowed=new Set(baseline.widthWarnings.map((x:any)=>x.source_trace_id));assert(findings.every((x:any)=>x.type.endsWith('_warning')&&allowed.has(oldTraceByName.get(traces.get(x.source_trace_id)?.name??traces.get(x.source_trace_id)?.display_name)?.source_trace_id)),'New width warning or width error')}else assert(!findings.length,'Physical DRC failed '+name+': '+JSON.stringify(findings))
 }
 const copperConnectivity=auditPhysicalConnectivity(c);writeFileSync(join(out,'physical-connectivity.json'),JSON.stringify(copperConnectivity,null,2));console.log('Geometric copper connectivity passed',copperConnectivity.componentPorts);
 writeFileSync(join(out,'physical-checks.json'),JSON.stringify(results,null,2));writeFileSync(join(out,'length-findings.json'),JSON.stringify(lengths,null,2));writeFileSync(join(out,'warnings.json'),JSON.stringify(c.filter(e=>e.type.endsWith('_warning')),null,2))
 const style=spawnSync('tsci',['check','pcb-style',join(out,'circuit.json'),'--json','--svg',join(out,'pcb-style.svg')],{encoding:'utf8',env:process.env});writeFileSync(join(out,'pcb-style.json'),style.stdout);assert(style.status===0,'PCB style check failed: '+style.stderr+' '+style.stdout);const styleResult=JSON.parse(style.stdout);assert(!styleResult.issues.length,'PCB style findings')
 for(const layer of ['top','inner1','inner2','bottom'] as const){const svg=convertCircuitJsonToPcbSvg(c,{layer,width:1445,height:820,matchBoardAspectRatio:true,backgroundColor:'#0f172a',shouldDrawWarnings:false,shouldDrawRatsNest:false,includeVersion:false,showCourtyards:false});writeFileSync(join(out,layer==='top'?'pcb.svg':`pcb-${layer}.svg`),svg)}writeFileSync(join(out,'schematic.svg'),convertCircuitJsonToSchematicSvg(c as any,{width:1445,height:820}))
 await validateSchematic(c,join(out,'circuit.json'));await import('./electrical-prototype');
 const report={accepted:true,batteryCellCount,batteryEnableTopOhm:390000,batteryEnableBottomOhm:enableBottom.resistance,nominalBatteryStartV:.8*(1+390000/enableBottom.resistance),nominalBatteryStopV:.7*(1+390000/enableBottom.resistance),assemblyCellCountSelection:true,tscircuit:'0.0.2827',core:'0.0.2130',routingMode:'authored PCB traces; routeRemaining=false',flash:'W25Q256JVFIQ',flashJlcpcb:'C779876',capacityMB:32,reimportedPartTypes:56,localConnectorReview:{speakerPositionMm:[-2,-20],speakerConnectorPart:"BM02B-SRSS-TB(LF)(SN)",speakerConnectorJlcpcb:"C160388",externalSpeaker:"CMS-23558N-L152B",speakerOhm:8,speakerRatedWatt:1,speakerPowerLimitImplemented:true,shoulderPart:"B3F-3152",shoulderJlcpcb:"C231320",shoulderSidePush:true,usbJlcpcb:"C165948",usbPart:"TYPE-C-31-M-12",usbCadReferenceOnly:false,usbCadSource:"JLCPCB/EasyEDA imported OBJ and STEP",usbStockAtSelection:400610,publicationVersion:"0.0.39"},physicalComponentPorts:470,preservedElectricalGroups:133,newFlashNcPins:7,layers:4,physicalDrcErrors:0,shorts:0,physicalConnectivityGroups:copperConnectivity.electricalGroups,splitPhysicalNets:0,pcbStyleFindings:0,retainedLengthFindings:lengths.length,retainedWidthWarnings:results.checkSourceTracesMatchPcbTraceThickness.length,nativeRoutingPhases:ends,viaHoleMinimumMm:.3,viaPadMinimumMm:.45,throughViaCount:c.filter(e=>e.type==='pcb_via').length,approvedTopologyChanges:["AP63200 feedback divider", "PAM8403 series input attenuation"],logicNominalV:.8*(1+50200/16500),policy:'Zero physical DRC/shorts; original mapped net partitions and remaining board rules preserved; requested via minima0.3/0.45mm; original source-net minimum widths and power-path widths preserved; zero trace-length errors under documented per-trace limits. Additional flash addressing firmware not implemented.'};writeFileSync(join(out,'validation.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report,null,2));console.log('Validated local GBA AA-pack assembly build passed.')
}finally{}