@helix3/helix-cli 0.1.14-helix3.201 → 0.1.14-helix3.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helix3/helix-cli",
3
- "version": "0.1.14-helix3.201",
3
+ "version": "0.1.14-helix3.202",
4
4
  "description": "helix — the HELIX creator CLI: scaffold, validate, and publish Instant Worlds",
5
5
  "main": "dist/lib.js",
6
6
  "types": "dist/lib.d.ts",
@@ -63,7 +63,7 @@
63
63
  "@gltf-transform/extensions": "^4.4.0",
64
64
  "@gltf-transform/functions": "^4.4.0",
65
65
  "@hypersoniclabs/helix-manifest": "npm:@helix3/helix-manifest@0.3.25-helix3.68",
66
- "@hypersoniclabs/helix-sdk": "npm:@helix3/helix-sdk@0.1.5-helix3.99",
66
+ "@hypersoniclabs/helix-sdk": "npm:@helix3/helix-sdk@0.1.6-helix3.101",
67
67
  "commander": "^13.0.0",
68
68
  "ktx2-encoder": "^0.5.3",
69
69
  "meshoptimizer": "^0.22.0",
@@ -43,8 +43,12 @@ The converter reads all four BeamNG material stages. Later authored stages win,
43
43
 
44
44
  Each successful terminal package emits six assets: the canonical GLB, `<vehicle>.vehicle-source.json`, the versioned `<vehicle>.vehicle-lowering.json` diagnostic aggregate, `<vehicle>.vehicle-package.json`, `<vehicle>.vehicle-addons.json`, and `<vehicle>.vehicle-build.json`. The lowering aggregate makes source semantics, structural exclusions, runtime blockers, functional-domain status, and provenance available to CLI and agent consumers instead of discarding them after packaging. The source manifest remains embedded in GLB extras and records the exact configuration and slot closure, fingerprints, material-resolution provenance, and coverage ledger.
45
45
 
46
- `lower-beamng.py` compiles the exact selected `.pc` and admitted JBeam closure into `helix.vehicle-package/2`, stable add-on records, and a source Build. Source literals cover the torque curve, idle/limiter, engine losses, gearbox and final drive, differentials, selected front/rear `pressureWheels` brake and parking-brake values, lights/electrics, audio event references, seats/cameras, and rigid openables when present. Brake and suspension expressions use a bounded data-only resolver for selected numeric variables, arithmetic, comparisons, boolean conditionals, and `case()`; direct selected-variable references are supported, source code is never evaluated, and unknown variables, calls, or tokens remain explicitly unsupported. HELIX total brake torque is derived from BeamNG's per-wheel axle values and the exact `.pc` variable overrides take precedence over selected JBeam defaults.
46
+ `lower-beamng.py` compiles the exact selected `.pc` and admitted JBeam closure into `helix.vehicle-package/2`, stable add-on records, and a source Build. Source literals cover the torque curve, idle/limiter, engine losses, gearbox and final drive, differentials, selected front/rear `pressureWheels` tire/brake values, lights/electrics, audio event references, seats/cameras, and rigid openables when present. Brake, suspension, tire, and differential expressions use a bounded data-only resolver for selected numeric variables, arithmetic, comparisons, boolean conditionals, and `case()`; direct selected-variable references are supported, source code is never evaluated, and unknown variables, calls, or tokens remain explicitly unsupported. HELIX total brake torque is derived from BeamNG's per-wheel axle values and the exact `.pc` variable overrides take precedence over selected JBeam defaults.
47
47
 
48
- Selected suspension evidence is lowered only from the chosen strut/coilover leaf part on each axle. Matching left/right spring, slow/fast bump, slow/fast rebound, velocity-split, precompression, and bound rows are retained with beam-space frequency and damping-ratio derivations. Structural parent/subframe beams and swaybar construction beams are explicitly excluded by part custody. Selected torsionbar stiffness is retained in `N.m/rad`, but the runtime suspension remains fail-closed: BeamNG element rates need motion-ratio geometry to become HELIX wheel rates, and the current package has one global damping triplet with no axle-specific fast/slow channels. The emitted HELIX suspension therefore remains an explicit fallback and `functionalDomains.suspension` remains false until those exact runtime mappings exist. Tyres, raw Lua/controller, and other unsupported expressions remain explicit provenance instead of being presented as literal runtime parity. Soft-body deformation, fluids, wear, and detailed damage remain out of scope. Rigid panel poses are semantic on every device, physical bodies begin at `medium`, and detachment remains disabled on every production tier while source break groups are retained as future eligibility. Simulation, authority, package identity, and Build selection are quality-invariant.
48
+ Driveline topology is derived from the selected device graph: each connected wheel is traced upstream to the exact selected gearbox, so FWD and RWD packages contain only their driven axle and AWD requires an actual common centre differential. Duplicate devices, cycles, half-connected axles, missing final drives, unknown differential types, invalid fields, and ambiguous output ports retain source receipts but suppress runtime differentials instead of inventing a topology. Open, locked, LSD, and unit-exponent viscous devices lower exactly when every selected behavior field is representable. Nonzero BeamNG torque-loss, virtual-inertia, speed-limit, active-lock/controller, and non-unit viscous-exponent semantics remain explicit parity blockers; a topology-safe approximation stays runnable with `runtimeStatus=approximate` while `functionalDomains.differentials=false`.
49
+
50
+ Selected tires lower one exact common or vehicle-local leaf per axle. Pressure, radius, width, friction, sliding friction, and load-sensitivity values keep literal/derived/unsupported receipts. HELIX's engine-wide exponential load kernel is deterministically sampled at 24 log-spaced points from 100 N to 40 kN for its compact `muLut`; Pacejka-style shape coefficients remain explicitly identified HELIX priors. Complete selected extraction sets `extractionComplete=true` and uses the selected radius/load data, but remains `functionalDomains.wheelsTyres=false` because the current runtime has no pressure or tire-width fields and still uses shape priors. Missing, duplicate, invalid, or unresolved tire semantics instead produce a complete prior runtime fallback, never a parity claim.
51
+
52
+ Selected suspension evidence is lowered only from the chosen strut/coilover leaf part on each axle. Matching left/right spring, slow/fast bump, slow/fast rebound, velocity-split, precompression, and bound rows are retained with beam-space frequency and damping-ratio derivations. Structural parent/subframe beams and swaybar construction beams are explicitly excluded by part custody. Selected torsionbar stiffness is retained in `N.m/rad`, but the runtime suspension remains fail-closed: BeamNG element rates need motion-ratio geometry to become HELIX wheel rates, and the current package has one global damping triplet with no axle-specific fast/slow channels. The emitted HELIX suspension therefore remains an explicit fallback and `functionalDomains.suspension` remains false until those exact runtime mappings exist. Raw Lua/controller and other unsupported expressions remain explicit provenance instead of being presented as literal runtime parity. Soft-body deformation, fluids, wear, and detailed damage remain out of scope. Rigid panel poses are semantic on every device, physical bodies begin at `medium`, and detachment remains disabled on every production tier while source break groups are retained as future eligibility. Simulation, authority, package identity, and Build selection are quality-invariant. Bridge currently seals one canonical source GLB and truthfully maps every tier to it; optimized mesh/texture rendition IDs must not be selected until the optimizer has materialized and sealed those artifacts.
49
53
 
50
54
  The engine consumer seam is `vehiclePackageToTuning(package, { landSolver: 'v2' })`. The source Build intentionally contains stable source identities rather than invented Continuum CIDs; after publication resolves immutable Package references, the existing `createPrivateBuildDraft` → `freezeBuildDraft` workflow is the canonical Build seam.
@@ -529,7 +529,7 @@ function assertValidFunctionalLowering(lowering, boundary) {
529
529
  const nonempty = (value) => typeof value === 'string' && value.length > 0;
530
530
  const explicitUnsupported = (value) => record(value) && value.status === 'unsupported' && nonempty(value.reason);
531
531
  const semanticReceipt = (value) => record(value)
532
- && ['literal', 'derived', 'unsupported'].includes(value.status) && nonempty(value.unit)
532
+ && ['literal', 'derived', 'defaulted', 'unsupported'].includes(value.status) && nonempty(value.unit)
533
533
  && Array.isArray(value.variables)
534
534
  && (value.status === 'unsupported' ? nonempty(value.reason) : Number.isFinite(value.value));
535
535
  const selectedReceipt = (value) => semanticReceipt(value) && (value.status === 'unsupported'
@@ -559,6 +559,198 @@ function assertValidFunctionalLowering(lowering, boundary) {
559
559
  const antiRollAxle = (value) => explicitUnsupported(value) || (record(value)
560
560
  && nonempty(value.slotType) && nonempty(value.partName) && nonempty(value.source)
561
561
  && torsionReceipt(value.torsionSpring) && value.runtimeStatus === 'unsupported' && nonempty(value.reason));
562
+ const tyreFields = ['pressurePSI', 'radius', 'tireWidth', 'frictionCoef', 'slidingFrictionCoef',
563
+ 'noLoadCoef', 'fullLoadCoef', 'loadSensitivitySlope'];
564
+ const tyreAxle = (value) => {
565
+ if (explicitUnsupported(value) && Array.isArray(value.candidates)) return true;
566
+ if (!record(value) || !nonempty(value.partName) || !nonempty(value.source)
567
+ || !tyreFields.every((name) => semanticReceipt(value[name]) && Object.hasOwn(value[name], 'raw'))) return false;
568
+ const expectedStatus = tyreFields.every((name) => value[name].status !== 'unsupported')
569
+ ? 'functional' : 'unsupported';
570
+ return value.status === expectedStatus;
571
+ };
572
+ const tyres = semantics?.wheelsTyres;
573
+ const tyreFormula = 'frictionCoef * (fullLoadCoef + (noLoadCoef - fullLoadCoef) * exp(-loadSensitivitySlope * fz))';
574
+ const expectedTyreLoads = Array.from({ length: 24 }, (_, index) => Math.round(Math.exp(
575
+ Math.log(100) + ((Math.log(40000) - Math.log(100)) * index) / 23,
576
+ ) * 1e9) / 1e9);
577
+ const selectedTyresComplete = ['front', 'rear'].every((axle) => record(tyres?.axles?.[axle])
578
+ && tyres.axles[axle].status === 'functional'
579
+ && tyreFields.every((name) => tyres.axles[axle][name]?.status !== 'unsupported'));
580
+ const runtimeTyres = lowering?.vehiclePackage?.simulation?.land?.tyreByAxle;
581
+ const approximately = (left, right, epsilon = 1e-8) => Number.isFinite(left) && Number.isFinite(right)
582
+ && Math.abs(left - right) <= epsilon;
583
+ const tyreShapePrior = { alphaPeakRad: 0.139626, cxMult: 1.05, sigmaX: 0.048,
584
+ sigmaY: 0.08, camberGain: 0.15, dxOverDy: 1, sFall: 0.3,
585
+ rollingResistance: 0.013, angularInertia: 1.3 };
586
+ const validRuntimeTyre = (runtime, axle) => {
587
+ const source = tyres?.axles?.[axle];
588
+ if (!record(runtime) || !record(source) || !record(runtime.loadSensitivity)
589
+ || !record(runtime.muLut) || runtime.calibration !== 'prior'
590
+ || !approximately(runtime.radius, source.radius?.value)
591
+ || !Array.isArray(runtime.muLut.fz) || !equalJson(runtime.muLut.fz, tyres?.lut?.fz)
592
+ || !Array.isArray(runtime.muLut.mu) || runtime.muLut.mu.length !== runtime.muLut.fz.length
593
+ || !Object.entries(tyreShapePrior).every(([name, value]) => approximately(runtime[name], value))) return false;
594
+ for (const field of ['frictionCoef', 'slidingFrictionCoef', 'noLoadCoef', 'fullLoadCoef', 'loadSensitivitySlope']) {
595
+ if (!approximately(runtime.loadSensitivity[field], source[field]?.value)) return false;
596
+ }
597
+ const slideRatio = source.slidingFrictionCoef.value / source.frictionCoef.value;
598
+ if (!approximately(runtime.muLut.slideRatio, Math.round(slideRatio * 1e6) / 1e6)) return false;
599
+ return runtime.muLut.fz.every((load, index) => approximately(runtime.muLut.mu[index],
600
+ Math.round((source.frictionCoef.value * (source.fullLoadCoef.value
601
+ + (source.noLoadCoef.value - source.fullLoadCoef.value)
602
+ * Math.exp(-source.loadSensitivitySlope.value * load))) * 1e9) / 1e9));
603
+ };
604
+ const validFallbackTyre = (runtime) => record(runtime) && record(runtime.loadSensitivity)
605
+ && record(runtime.muLut) && runtime.calibration === 'prior'
606
+ && approximately(runtime.radius, 0.3466)
607
+ && Object.entries(tyreShapePrior).every(([name, value]) => approximately(runtime[name], value))
608
+ && equalJson(runtime.muLut.fz, tyres?.lut?.fz)
609
+ && Array.isArray(runtime.muLut.mu) && Array.isArray(runtime.muLut.fz)
610
+ && runtime.muLut.mu.length === runtime.muLut.fz.length
611
+ && runtime.muLut.mu.every((value) => approximately(value, 1.1))
612
+ && approximately(runtime.muLut.slideRatio, 0.85)
613
+ && equalJson(runtime.loadSensitivity, { frictionCoef: 1, slidingFrictionCoef: 0.85,
614
+ noLoadCoef: 1.1, fullLoadCoef: 1.1, loadSensitivitySlope: 0 });
615
+ const validTyres = record(tyres) && tyres.status === 'unsupported'
616
+ && record(tyres.axles) && tyreAxle(tyres.axles.front) && tyreAxle(tyres.axles.rear)
617
+ && record(tyres.variables) && record(tyres.lut) && Array.isArray(tyres.lut.fz)
618
+ && equalJson(tyres.lut.fz, expectedTyreLoads) && tyres.lut.formula === tyreFormula
619
+ && Array.isArray(tyres.runtimeBlockers) && tyres.runtimeBlockers.length > 0
620
+ && typeof tyres.extractionComplete === 'boolean'
621
+ && tyres.extractionComplete === selectedTyresComplete
622
+ && Array.isArray(runtimeTyres) && runtimeTyres.length === 2
623
+ && (tyres.extractionComplete
624
+ ? validRuntimeTyre(runtimeTyres[0], 'front') && validRuntimeTyre(runtimeTyres[1], 'rear')
625
+ : runtimeTyres.every(validFallbackTyre));
626
+ const driveline = semantics?.driveline;
627
+ const differentialFields = ['lockTorque', 'activeLockTorque', 'viscousCoef', 'viscousTorque', 'lsdPreload',
628
+ 'lsdLockCoef', 'lsdRevLockCoef', 'friction', 'dynamicFriction', 'torqueLossCoef',
629
+ 'defaultVirtualInertia', 'speedLimitCoef', 'diffTorqueSplit'];
630
+ const differential = (value) => record(value) && nonempty(value.deviceName)
631
+ && nonempty(value.partName) && nonempty(value.source) && nonempty(value.type)
632
+ && differentialFields.every((name) => semanticReceipt(value.fields?.[name])
633
+ && Object.hasOwn(value.fields[name], 'raw'))
634
+ && Array.isArray(value.controllers) && value.controllers.every(nonempty)
635
+ && Array.isArray(value.overlaySources) && value.overlaySources.every(nonempty)
636
+ && record(value.overlayFieldSources)
637
+ && Object.values(value.overlayFieldSources).every((sources) => Array.isArray(sources)
638
+ && sources.length > 0 && sources.every(nonempty))
639
+ && Array.isArray(value.overlayConflicts)
640
+ && value.overlayConflicts.every((conflict) => record(conflict) && nonempty(conflict.field)
641
+ && Array.isArray(conflict.sources) && conflict.sources.length > 1 && conflict.sources.every(nonempty))
642
+ && (value.type !== 'viscous' || (semanticReceipt(value.fields?.viscousExponent)
643
+ && Object.hasOwn(value.fields.viscousExponent, 'raw')))
644
+ && semanticReceipt(value.gearRatio) && record(value.outputPorts)
645
+ && [value.outputPorts.a, value.outputPorts.b].every((port) => port === 1 || port === 2)
646
+ && value.outputPorts.a !== value.outputPorts.b;
647
+ const drivenAxles = Array.isArray(driveline?.drivenAxles) ? driveline.drivenAxles : [];
648
+ const differentialEntries = record(driveline?.differentials)
649
+ ? Object.entries(driveline.differentials) : [];
650
+ const runtimeDriveline = lowering?.vehiclePackage?.simulation?.land?.driveline;
651
+ const hasRuntimeDiff = (name) => record(runtimeDriveline) && record(runtimeDriveline[name]);
652
+ const expectedDiffCount = drivenAxles.length + (drivenAxles.length === 2 ? 1 : 0);
653
+ const expectedLocations = [...drivenAxles, ...(drivenAxles.length === 2 ? ['centre'] : [])].sort();
654
+ const semanticLocations = differentialEntries.map(([location]) => location).sort();
655
+ const runtimeLocations = ['front', 'rear', 'centre'].filter(hasRuntimeDiff).sort();
656
+ const runtimeFields = {
657
+ lockTorque: 'lockTorque', viscousCoef: 'viscousCoef', viscousTorque: 'viscousTorque',
658
+ lsdPreload: 'lsdPreload', lsdLockCoef: 'lsdLockCoef', lsdRevLockCoef: 'lsdRevLockCoef',
659
+ friction: 'friction', dynamicFriction: 'dynamicFriction',
660
+ };
661
+ const runtimeCriticalDifferentialFields = new Set([
662
+ 'type', 'name', 'inputName', 'inputIndex', 'diffType', 'gearRatio', 'viscousExponent',
663
+ ...differentialFields,
664
+ ]);
665
+ const axleWheels = { front: ['FL', 'FR'], rear: ['RL', 'RR'] };
666
+ const expectedWheels = drivenAxles.flatMap((axle) => axleWheels[axle]).sort();
667
+ const wheelPathEntries = record(driveline?.wheelPaths) ? Object.entries(driveline.wheelPaths) : [];
668
+ const validWheelPaths = nonempty(driveline?.transmissionDevice)
669
+ && equalJson(wheelPathEntries.map(([wheel]) => wheel).sort(), expectedWheels)
670
+ && wheelPathEntries.every(([, path]) => Array.isArray(path) && path.length > 0
671
+ && path.every(nonempty) && new Set(path).size === path.length
672
+ && path.at(-1) === driveline.transmissionDevice)
673
+ && drivenAxles.every((axle) => axleWheels[axle].every((wheel) =>
674
+ driveline.wheelPaths[wheel].includes(driveline.differentials[axle]?.deviceName)))
675
+ && (drivenAxles.length !== 2 || expectedWheels.every((wheel) =>
676
+ driveline.wheelPaths[wheel].includes(driveline.differentials.centre?.deviceName)))
677
+ && expectedWheels.every((wheel) => {
678
+ const path = driveline.wheelPaths[wheel];
679
+ const axle = wheel.startsWith('F') ? 'front' : 'rear';
680
+ const axleIndex = path.indexOf(driveline.differentials[axle].deviceName);
681
+ const gearboxIndex = path.indexOf(driveline.transmissionDevice);
682
+ if (drivenAxles.length !== 2) return axleIndex >= 0 && axleIndex < gearboxIndex;
683
+ const centreIndex = path.indexOf(driveline.differentials.centre.deviceName);
684
+ return axleIndex >= 0 && axleIndex < centreIndex && centreIndex < gearboxIndex;
685
+ });
686
+ const gearboxKinds = { manualGearbox: 'manual', sequentialGearbox: 'sequential',
687
+ dctGearbox: 'dct', cvtGearbox: 'cvt', automaticGearbox: 'automatic' };
688
+ const validRuntimeGearbox = record(driveline?.gearbox) && driveline.gearbox.status === 'derived'
689
+ && nonempty(driveline.gearbox.deviceName) && nonempty(driveline.gearbox.deviceType)
690
+ && nonempty(driveline.gearbox.partName) && nonempty(driveline.gearbox.source)
691
+ && gearboxKinds[driveline.gearbox.deviceType] === driveline.gearbox.kind
692
+ && runtimeDriveline?.gearbox?.kind === driveline.gearbox.kind
693
+ && driveline.transmissionDevice === driveline.gearbox.deviceName;
694
+ const expectedDriveBias = driveline?.runtimeStatus === 'unsupported' ? 0.5
695
+ : drivenAxles.length === 1 ? (drivenAxles[0] === 'front' ? 1 : 0)
696
+ : runtimeDriveline?.centre?.torqueSplit;
697
+ const validLegacyDriveline = semanticReceipt(driveline?.legacyFinalDrive)
698
+ && approximately(lowering?.vehiclePackage?.physics?.driveBiasFront?.value, expectedDriveBias)
699
+ && (driveline.legacyFinalDrive.status === 'unsupported'
700
+ || approximately(lowering?.vehiclePackage?.physics?.gearbox?.finalDrive?.value,
701
+ driveline.legacyFinalDrive.value));
702
+ const validRuntimeDiff = ([location, source], requireParity) => {
703
+ const runtime = runtimeDriveline?.[location];
704
+ const fields = source.fields;
705
+ const supportedType = ['open', 'locked', 'viscous', 'lsd'].includes(source.type);
706
+ const resolved = [...differentialFields.map((name) => fields[name]), source.gearRatio]
707
+ .every((receipt) => receipt.status !== 'unsupported');
708
+ const activeController = source.controllers.some((name) => name.includes('activeCenterDiff'));
709
+ const runtimeConflict = source.overlayConflicts.some((conflict) =>
710
+ runtimeCriticalDifferentialFields.has(conflict.field));
711
+ const representableLosses = ['torqueLossCoef', 'defaultVirtualInertia', 'speedLimitCoef']
712
+ .every((name) => fields[name].value === 0);
713
+ const typeParametersValid = source.type !== 'locked' || fields.lockTorque.value > 0;
714
+ const viscousRuntimeSafe = source.type !== 'viscous'
715
+ || (fields.viscousCoef.value > 0 && fields.viscousTorque.value > 0
716
+ && Number.isFinite(fields.viscousExponent.value));
717
+ const viscousParity = viscousRuntimeSafe
718
+ && (source.type !== 'viscous'
719
+ || (fields.viscousExponent.status !== 'unsupported' && fields.viscousExponent.value === 1));
720
+ if (!record(runtime) || !supportedType || !resolved || !typeParametersValid || !viscousRuntimeSafe
721
+ || runtimeConflict
722
+ || !(source.gearRatio.value > 0)
723
+ || !(fields.diffTorqueSplit.value >= 0 && fields.diffTorqueSplit.value <= 1)
724
+ || runtime.type !== source.type
725
+ || !approximately(runtime.gearRatio, source.gearRatio.value)) return false;
726
+ if (requireParity && (activeController || !representableLosses
727
+ || fields.activeLockTorque.value !== 0 || !viscousParity)) return false;
728
+ const sourceSplit = source.fields.diffTorqueSplit.value;
729
+ const expectedSplit = source.outputPorts.a === 1 ? sourceSplit : Math.round((1 - sourceSplit) * 1e8) / 1e8;
730
+ if (!approximately(runtime.torqueSplit, expectedSplit)) return false;
731
+ return Object.entries(runtimeFields).every(([runtimeField, receiptField]) =>
732
+ approximately(runtime[runtimeField], source.fields[receiptField].value));
733
+ };
734
+ const validDriveline = record(driveline) && ['functional', 'unsupported'].includes(driveline.status)
735
+ && ['functional', 'approximate', 'unsupported'].includes(driveline.runtimeStatus)
736
+ && drivenAxles.every((name) => ['front', 'rear'].includes(name))
737
+ && new Set(drivenAxles).size === drivenAxles.length && record(driveline.wheelPaths)
738
+ && differentialEntries.every(([location, value]) => ['front', 'rear', 'centre'].includes(location)
739
+ && differential(value))
740
+ && record(driveline.variables) && Array.isArray(driveline.runtimeBlockers)
741
+ && (explicitUnsupported(driveline.gearbox) || validRuntimeGearbox)
742
+ && validLegacyDriveline
743
+ && (driveline.status === 'functional') === (driveline.runtimeBlockers.length === 0)
744
+ && (driveline.status === 'functional' ? driveline.runtimeStatus === 'functional'
745
+ : driveline.runtimeStatus !== 'functional')
746
+ && (driveline.runtimeStatus !== 'unsupported'
747
+ ? differentialEntries.length === expectedDiffCount
748
+ && equalJson(semanticLocations, expectedLocations)
749
+ && equalJson(runtimeLocations, expectedLocations)
750
+ && validRuntimeGearbox
751
+ && validWheelPaths
752
+ && differentialEntries.every((entry) => validRuntimeDiff(entry, driveline.status === 'functional'))
753
+ : !hasRuntimeDiff('front') && !hasRuntimeDiff('rear') && !hasRuntimeDiff('centre'));
562
754
  const sourceExtractionComplete = suspension?.axles?.front?.status === 'resolved'
563
755
  && suspension?.axles?.rear?.status === 'resolved'
564
756
  && suspension?.antiRoll?.front?.torsionSpring?.status !== 'unsupported'
@@ -601,7 +793,32 @@ function assertValidFunctionalLowering(lowering, boundary) {
601
793
  requiredSemanticTargets.set(`sourceSemantics.suspension.antiRoll.${axle}.torsionSpring`,
602
794
  expectedEvidence(suspension.antiRoll[axle].torsionSpring, suspension.antiRoll[axle].source));
603
795
  }
796
+ const tyre = tyres?.axles?.[axle];
797
+ if (record(tyre) && nonempty(tyre.partName)) {
798
+ for (const field of tyreFields) {
799
+ requiredSemanticTargets.set(`sourceSemantics.wheelsTyres.axles.${axle}.${field}`,
800
+ expectedEvidence(tyre[field], tyre.source));
801
+ }
802
+ }
604
803
  }
804
+ if (record(driveline?.gearbox) && driveline.gearbox.status === 'derived') {
805
+ requiredSemanticTargets.set('simulation.land.driveline.gearbox.kind',
806
+ { disposition: 'source-literal', source: driveline.gearbox.source });
807
+ }
808
+ for (const [location, row] of differentialEntries) {
809
+ requiredSemanticTargets.set(`sourceSemantics.driveline.differentials.${location}.type`, {
810
+ disposition: ['open', 'locked', 'viscous', 'lsd'].includes(row.type) ? 'source-literal' : 'unsupported',
811
+ source: row.source,
812
+ });
813
+ }
814
+ requiredSemanticTargets.set('simulation.land.driveline.differentials', {
815
+ disposition: driveline?.status === 'functional' ? 'derived' : 'unsupported',
816
+ source: 'selected JBeam powertrain graph',
817
+ });
818
+ requiredSemanticTargets.set('simulation.land.tyreByAxle', {
819
+ disposition: 'unsupported',
820
+ source: 'selected JBeam pressureWheels',
821
+ });
605
822
  const provenanceByTarget = new Map();
606
823
  if (Array.isArray(lowering?.provenance)) {
607
824
  for (const row of lowering.provenance) {
@@ -643,14 +860,16 @@ function assertValidFunctionalLowering(lowering, boundary) {
643
860
  && record(suspensionCounts)
644
861
  && requiredSuspensionCounts.every((name) => Number.isInteger(suspensionCounts[name])
645
862
  && suspensionCounts[name] >= 0 && suspensionCounts[name] === expectedSuspensionCounts[name])
646
- && record(semantics.wheelsTyres) && Array.isArray(semantics.wheelsTyres.selectedParts)
647
- && Array.isArray(semantics.wheelsTyres.literalRadii)
863
+ && validTyres && Array.isArray(semantics.wheelsTyres.selectedParts)
864
+ && validDriveline
648
865
  && Array.isArray(semantics.electrics)
649
866
  && Array.isArray(semantics.audioEvents)
650
867
  && Array.isArray(semantics.openableControllers);
651
868
  const validCoverage = record(coverage)
652
869
  && record(coverage.functionalDomains)
653
870
  && coverage.functionalDomains.suspension === false
871
+ && coverage.functionalDomains.wheelsTyres === false
872
+ && coverage.functionalDomains.differentials === (driveline?.status === 'functional')
654
873
  && record(suspensionProvenance)
655
874
  && requiredProvenanceCounts.every((name) => Number.isInteger(suspensionProvenance[name])
656
875
  && suspensionProvenance[name] >= 0
@@ -412,6 +412,7 @@ LAMP_NODE_SEMANTICS = {
412
412
 
413
413
  TRANSMISSION_DEVICE_KINDS = {
414
414
  "manualgearbox": "manual",
415
+ "sequentialgearbox": "sequential",
415
416
  "dctgearbox": "dct",
416
417
  "cvtgearbox": "cvt",
417
418
  "automaticgearbox": "automatic",
@@ -419,6 +420,7 @@ TRANSMISSION_DEVICE_KINDS = {
419
420
 
420
421
  TRANSMISSION_INFO_KINDS = {
421
422
  "manual": "manual",
423
+ "sequential": "sequential",
422
424
  "dct": "dct",
423
425
  "cvt": "cvt",
424
426
  "automatic": "automatic",
@@ -433,17 +435,68 @@ def lamp_beam(light: str) -> dict[str, float] | None:
433
435
  "penumbra": .3, "intensity": 1}
434
436
 
435
437
 
436
- def selected_transmission_kind(selected: Iterable[tuple[str, str, dict[str, Any], Path]],
437
- config_info: dict[str, Any],
438
- label_source: Callable[[Path], str]) -> tuple[str | None, str, str]:
439
- """Return a kind only when the selected, fingerprinted source is unambiguous."""
440
- evidence: list[tuple[str, str, Path, str]] = []
441
- for _, part_name, body, path in selected:
438
+ def selected_powertrain_devices(selected: Iterable[tuple[str, str, dict[str, Any], Path]],
439
+ label_source: Callable[[Path], str]) -> list[dict[str, Any]]:
440
+ selected_parts = list(selected)
441
+ base_devices: list[tuple[str, str, Path, list[str], dict[str, Any]]] = []
442
+ for slot_name, part_name, body, path in selected_parts:
443
+ controllers = sorted(str(row["fileName"]) for row in decode_jbeam_table(body.get("controller"))
444
+ if isinstance(row.get("fileName"), str))
442
445
  for row in decode_jbeam_table(body.get("powertrain")):
443
- device_type = str(row.get("type", ""))
444
- kind = TRANSMISSION_DEVICE_KINDS.get(device_type.lower())
445
- if kind is not None:
446
- evidence.append((kind, part_name, path, device_type))
446
+ name = row.get("name")
447
+ if not isinstance(name, str) or not name:
448
+ continue
449
+ base_devices.append((slot_name, part_name, path, controllers, row))
450
+ device_names = {row[4]["name"] for row in base_devices}
451
+ overlay_index: dict[str, list[tuple[dict[str, Any], str]]] = {}
452
+ overlay_count = 0
453
+ for _, part_name, body, path in selected_parts:
454
+ for name, options in body.items():
455
+ if name not in device_names or not isinstance(options, dict):
456
+ continue
457
+ overlay_count += 1
458
+ if overlay_count > MAX_INDEX_PARTS:
459
+ fail(f"selected powertrain device overlays exceed {MAX_INDEX_PARTS}")
460
+ overlay_index.setdefault(name, []).append(
461
+ (options, f"{label_source(path)}#{part_name}.{name}"))
462
+
463
+ devices: list[dict[str, Any]] = []
464
+ for slot_name, part_name, path, controllers, row in base_devices:
465
+ name = row["name"]
466
+ merged = dict(row)
467
+ overlay_sources: list[str] = []
468
+ overlay_fields: dict[str, list[tuple[Any, str]]] = {}
469
+ for options, overlay_source in overlay_index.get(name, []):
470
+ overlay_sources.append(overlay_source)
471
+ for field, value in options.items():
472
+ overlay_fields.setdefault(field, []).append((value, overlay_source))
473
+ overlay_conflicts: list[dict[str, Any]] = []
474
+ for field, values in overlay_fields.items():
475
+ unique_values = {json.dumps(value, sort_keys=True, separators=(",", ":")) for value, _ in values}
476
+ if len(unique_values) == 1:
477
+ merged[field] = values[0][0]
478
+ else:
479
+ sources = [source for _, source in values]
480
+ merged[field] = {"ambiguousOverlaySources": sources}
481
+ overlay_conflicts.append({"field": field, "sources": sources})
482
+ devices.append({**merged, "partName": part_name, "slotType": slot_name,
483
+ "source": f"{label_source(path)}#{part_name}.powertrain",
484
+ "overlaySources": sorted(set(overlay_sources)),
485
+ "overlayFieldSources": {field: sorted({source for _, source in values})
486
+ for field, values in sorted(overlay_fields.items())},
487
+ "overlayConflicts": overlay_conflicts, "controllers": controllers})
488
+ return devices
489
+
490
+
491
+ def selected_transmission_kind(devices: Iterable[dict[str, Any]],
492
+ config_info: dict[str, Any]) -> tuple[str | None, str, str]:
493
+ """Return a kind only when the selected, fingerprinted source is unambiguous."""
494
+ evidence: list[tuple[str, str, str, str]] = []
495
+ for row in devices:
496
+ device_type = str(row.get("type", ""))
497
+ kind = TRANSMISSION_DEVICE_KINDS.get(device_type.lower())
498
+ if kind is not None:
499
+ evidence.append((kind, row["partName"], row["source"], device_type))
447
500
  kinds = {row[0] for row in evidence}
448
501
  info_value = str(config_info.get("Transmission", "")).strip()
449
502
  info_kind = TRANSMISSION_INFO_KINDS.get(info_value.lower())
@@ -451,16 +504,16 @@ def selected_transmission_kind(selected: Iterable[tuple[str, str, dict[str, Any]
451
504
  detail = ", ".join(sorted(f"{part}:{device}" for _, part, _, device in evidence))
452
505
  return None, "selected JBeam#powertrain.type", f"Ambiguous selected transmission devices: {detail}."
453
506
  if evidence:
454
- kind, part_name, path, device_type = evidence[0]
507
+ kind, part_name, source, device_type = evidence[0]
455
508
  if info_kind is not None and info_kind != kind:
456
- return None, label_source(path), \
509
+ return None, source, \
457
510
  f"Selected {part_name} declares {device_type}, but configuration info declares {info_value}."
458
- return kind, label_source(path), f"Selected {part_name} powertrain device type is {device_type}."
511
+ return kind, source, f"Selected {part_name} powertrain device type is {device_type}."
459
512
  if info_kind is not None:
460
513
  return info_kind, "selected configuration info#Transmission", \
461
514
  f"No selected JBeam transmission device row was available; exact configuration info declares {info_value}."
462
515
  return None, "selected JBeam#powertrain.type", \
463
- "No recognized manualGearbox, dctGearbox, cvtGearbox, or automaticGearbox device was selected."
516
+ "No recognized manualGearbox, sequentialGearbox, dctGearbox, cvtGearbox, or automaticGearbox device was selected."
464
517
 
465
518
 
466
519
  def key_multimap(parts: Iterable[tuple[str, Any]], max_entries: int = 1_000_000,
@@ -531,6 +584,7 @@ def sourced(value: Any, unit: str, source: str | None = None, note: str | None =
531
584
  BRAKE_AXLE_PATTERN = re.compile(r"(?:^|_)brakes?_?([FR])(?:_|$)", re.I)
532
585
  SUSPENSION_AXLE_PATTERN = re.compile(r"(?:^|_)(?:strut|coilover)_?([FR])(?:_|$)", re.I)
533
586
  SWAYBAR_AXLE_PATTERN = re.compile(r"(?:^|_)(?:rally_)?swaybar_?([FR])(?:_|$)", re.I)
587
+ TIRE_AXLE_PATTERN = re.compile(r"(?:^|_)tire_?([FR])(?:_|$)", re.I)
534
588
 
535
589
 
536
590
  def selected_variable_environment(selected: Iterable[tuple[str, str, dict[str, Any], Path]],
@@ -751,17 +805,11 @@ def suspension_row_side(row: dict[str, Any]) -> str | None:
751
805
 
752
806
  def resolution_receipt(raw: Any, variables: dict[str, Any], unit: str,
753
807
  source: str, provenance: list[dict[str, Any]], target: str) -> dict[str, Any]:
754
- result = numeric_resolution(raw, variables)
755
- row: dict[str, Any] = {"raw": raw, "status": result.status, "unit": unit,
756
- "variables": list(result.variables)}
757
- disposition = "source-literal" if result.status == "literal" else result.status
758
- if result.status == "unsupported":
759
- row["reason"] = result.reason
760
- detail = result.reason or "Selected value did not resolve."
761
- else:
762
- row["value"] = result.value
763
- detail = (f"Selected literal {result.value:g} {unit}." if result.status == "literal" else
764
- f"Resolved {raw!r} to {result.value:g} {unit} from selected variables.")
808
+ row, _ = numeric_receipt(raw, variables, unit)
809
+ disposition = "source-literal" if row["status"] == "literal" else row["status"]
810
+ detail = row.get("reason") or (f"Selected literal {row['value']:g} {unit}."
811
+ if row["status"] == "literal" else
812
+ f"Resolved {raw!r} to {row['value']:g} {unit} from selected variables.")
765
813
  provenance.append({"target": target, "source": source,
766
814
  "disposition": disposition, "detail": detail})
767
815
  return row
@@ -1037,14 +1085,458 @@ def lower_selected_suspension(selected: list[tuple[str, str, dict[str, Any], Pat
1037
1085
  return suspension, semantics
1038
1086
 
1039
1087
 
1040
- def diff(kind: str, ratio: float, preload: float, lock: float, reverse: float, torque_split: float = .5,
1041
- friction: float = 0, dynamic: float = 0, lock_torque: float = 0,
1042
- viscous_coef: float = 0, viscous_torque: float = 0) -> dict[str, Any]:
1088
+ def diff(kind: str, ratio: float, *, preload: float = 0, lock: float = 0, reverse: float = 0,
1089
+ torque_split: float = .5, friction: float = 0, dynamic: float = 0,
1090
+ lock_torque: float = 0, viscous_coef: float = 0,
1091
+ viscous_torque: float = 0) -> dict[str, Any]:
1043
1092
  return {"type": kind, "torqueSplit": torque_split, "gearRatio": ratio, "lockTorque": lock_torque,
1044
1093
  "viscousCoef": viscous_coef, "viscousTorque": viscous_torque, "lsdPreload": preload,
1045
1094
  "lsdLockCoef": lock, "lsdRevLockCoef": reverse, "friction": friction, "dynamicFriction": dynamic}
1046
1095
 
1047
1096
 
1097
+ def numeric_receipt(raw: Any, variables: dict[str, Any], unit: str,
1098
+ minimum: float | None = None, positive: bool = False) -> tuple[dict[str, Any], bool]:
1099
+ result = numeric_resolution(raw, variables)
1100
+ row: dict[str, Any] = {"raw": raw, "status": result.status, "unit": unit,
1101
+ "variables": list(result.variables)}
1102
+ valid = result.status != "unsupported"
1103
+ if valid:
1104
+ value = float(result.value)
1105
+ row["value"] = value
1106
+ if (positive and value <= 0) or (minimum is not None and value < minimum):
1107
+ row["status"] = "unsupported"
1108
+ row["reason"] = "resolved value is outside the admitted runtime range"
1109
+ valid = False
1110
+ else:
1111
+ row["reason"] = result.reason
1112
+ return row, valid
1113
+
1114
+
1115
+ MU_LUT_POINTS = 24
1116
+ MU_LUT_FZ = [round(math.exp(math.log(100) + ((math.log(40000) - math.log(100)) * index)
1117
+ / (MU_LUT_POINTS - 1)), 9) for index in range(MU_LUT_POINTS)]
1118
+ TYRE_SHAPE_PRIOR = {"alphaPeakRad": .139626, "cxMult": 1.05, "sigmaX": .048,
1119
+ "sigmaY": .08, "camberGain": .15, "dxOverDy": 1, "sFall": .3,
1120
+ "rollingResistance": .013, "angularInertia": 1.3}
1121
+
1122
+
1123
+ def lower_selected_tyres(selected: list[tuple[str, str, dict[str, Any], Path]],
1124
+ variables: dict[str, Any], variable_receipts: dict[str, dict[str, Any]],
1125
+ label_source: Callable[[Path], str],
1126
+ provenance: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any], bool]:
1127
+ """Lower one exact selected pressure-wheel tire Package per axle.
1128
+
1129
+ BeamNG supplies load-sensitivity coefficients while HELIX consumes its
1130
+ engine-wide exponential kernel as a compact LUT. Sampling uses the same
1131
+ fixed 24-knot logarithmic grid as the engine; it is not a per-vehicle fit.
1132
+ """
1133
+ candidates: dict[str, list[dict[str, Any]]] = {"front": [], "rear": []}
1134
+ for slot_name, part_name, body, path in selected:
1135
+ options = pressure_wheel_options(body)
1136
+ if not any(key in options for key in ("pressurePSI", "frictionCoef", "noLoadCoef")):
1137
+ continue
1138
+ axle = selected_part_axle(TIRE_AXLE_PATTERN, slot_name, part_name, body)
1139
+ if axle is not None:
1140
+ candidates[axle].append({"slotType": slot_name, "partName": part_name,
1141
+ "source": f"{label_source(path)}#{part_name}.pressureWheels",
1142
+ "options": options})
1143
+
1144
+ field_specs = {
1145
+ "pressurePSI": ("psi", 0.0, False), "radius": ("m", None, True),
1146
+ "tireWidth": ("m", None, True), "frictionCoef": ("ratio", None, True),
1147
+ "slidingFrictionCoef": ("ratio", None, True), "noLoadCoef": ("ratio", None, True),
1148
+ "fullLoadCoef": ("ratio", None, True), "loadSensitivitySlope": ("1/N", 0.0, False),
1149
+ }
1150
+ fz = MU_LUT_FZ
1151
+ axles: dict[str, Any] = {}
1152
+ runtime: list[dict[str, Any]] = []
1153
+ blockers: list[str] = []
1154
+ used_variables: set[str] = set()
1155
+ for axle in ("front", "rear"):
1156
+ rows = candidates[axle]
1157
+ if len(rows) != 1:
1158
+ reason = f"Expected one selected {axle} tire part; found {len(rows)}."
1159
+ blockers.append(reason)
1160
+ axles[axle] = {"status": "unsupported", "reason": reason,
1161
+ "candidates": [{key: row[key] for key in ("slotType", "partName", "source")}
1162
+ for row in rows]}
1163
+ continue
1164
+ candidate = rows[0]
1165
+ receipts: dict[str, Any] = {}
1166
+ complete = True
1167
+ for field, (unit, minimum, positive) in field_specs.items():
1168
+ receipt, valid = numeric_receipt(candidate["options"].get(field), variables, unit,
1169
+ minimum=minimum, positive=positive)
1170
+ receipts[field] = receipt
1171
+ complete = complete and valid
1172
+ used_variables.update(receipt["variables"])
1173
+ disposition = "source-literal" if receipt["status"] == "literal" else receipt["status"]
1174
+ provenance.append({"target": f"sourceSemantics.wheelsTyres.axles.{axle}.{field}",
1175
+ "source": candidate["source"], "disposition": disposition,
1176
+ "detail": receipt.get("reason") or
1177
+ f"Selected {field} resolved to {receipt.get('value'):g} {unit}."})
1178
+ axle_row = {key: candidate[key] for key in ("slotType", "partName", "source")}
1179
+ axle_row.update(receipts)
1180
+ axle_row["status"] = "functional" if complete else "unsupported"
1181
+ axles[axle] = axle_row
1182
+ if not complete:
1183
+ blockers.append(f"Selected {axle} tire contains unresolved or invalid numeric semantics.")
1184
+ continue
1185
+ values = {field: float(receipt["value"]) for field, receipt in receipts.items()}
1186
+ slide_ratio = values["slidingFrictionCoef"] / values["frictionCoef"]
1187
+ if not 0 < slide_ratio <= 1:
1188
+ receipts["slidingFrictionCoef"]["status"] = "unsupported"
1189
+ receipts["slidingFrictionCoef"]["reason"] = "HELIX muLut.slideRatio must be in (0, 1]."
1190
+ axle_row["status"] = "unsupported"
1191
+ for receipt_provenance in reversed(provenance):
1192
+ if receipt_provenance["target"] == \
1193
+ f"sourceSemantics.wheelsTyres.axles.{axle}.slidingFrictionCoef":
1194
+ receipt_provenance["disposition"] = "unsupported"
1195
+ receipt_provenance["detail"] = receipts["slidingFrictionCoef"]["reason"]
1196
+ break
1197
+ blockers.append(f"Selected {axle} tire sliding/friction ratio is outside (0, 1].")
1198
+ continue
1199
+ mu = [round(values["frictionCoef"] * (values["fullLoadCoef"]
1200
+ + (values["noLoadCoef"] - values["fullLoadCoef"])
1201
+ * math.exp(-values["loadSensitivitySlope"] * load)), 9) for load in fz]
1202
+ runtime.append({
1203
+ **TYRE_SHAPE_PRIOR,
1204
+ "loadSensitivity": {key: values[key] for key in ("frictionCoef", "slidingFrictionCoef",
1205
+ "noLoadCoef", "fullLoadCoef", "loadSensitivitySlope")},
1206
+ "muLut": {"fz": fz, "mu": mu,
1207
+ "slideRatio": round(slide_ratio, 6)},
1208
+ "radius": values["radius"],
1209
+ "calibration": "prior",
1210
+ })
1211
+
1212
+ extraction_complete = not blockers and len(runtime) == 2
1213
+ if extraction_complete:
1214
+ blockers.extend([
1215
+ "Selected pressurePSI is retained but the HELIX tire runtime has no pressure field.",
1216
+ "Selected tireWidth is retained but the HELIX tire runtime has no width field.",
1217
+ ])
1218
+ provenance.append({"target": "simulation.land.tyreByAxle",
1219
+ "source": "selected JBeam pressureWheels", "disposition": "unsupported",
1220
+ "detail": "Radius and load sensitivity use the selected source with the engine-wide exponential 24-knot LUT, but pressure and width have no runtime field; shape coefficients remain explicit priors."})
1221
+ else:
1222
+ provenance.append({"target": "simulation.land.tyreByAxle",
1223
+ "source": "selected JBeam pressureWheels", "disposition": "unsupported",
1224
+ "detail": " ".join(blockers)})
1225
+ fallback = {**TYRE_SHAPE_PRIOR,
1226
+ "loadSensitivity": {"frictionCoef": 1, "slidingFrictionCoef": .85,
1227
+ "noLoadCoef": 1.1, "fullLoadCoef": 1.1, "loadSensitivitySlope": 0},
1228
+ "muLut": {"fz": fz, "mu": [1.1] * MU_LUT_POINTS, "slideRatio": .85},
1229
+ "radius": .3466, "calibration": "prior"}
1230
+ runtime = [fallback, dict(fallback)]
1231
+ functional = False
1232
+ semantics = {"status": "unsupported", "extractionComplete": extraction_complete, "axles": axles,
1233
+ "variables": {name: variable_receipts[name] for name in sorted(used_variables)
1234
+ if name in variable_receipts},
1235
+ "lut": {"fz": fz,
1236
+ "formula": "frictionCoef * (fullLoadCoef + (noLoadCoef - fullLoadCoef) * exp(-loadSensitivitySlope * fz))"},
1237
+ "runtimeBlockers": blockers}
1238
+ return runtime, semantics, functional
1239
+
1240
+
1241
+ def lower_selected_driveline(devices: list[dict[str, Any]],
1242
+ variables: dict[str, Any], variable_receipts: dict[str, dict[str, Any]],
1243
+ provenance: list[dict[str, Any]]) -> tuple[dict[str, dict[str, Any]], dict[str, Any], bool, float]:
1244
+ """Trace selected devices from connected wheels to the gearbox.
1245
+
1246
+ Device names are BeamNG graph identities. Duplicate names, cycles,
1247
+ disconnected halfshafts, unknown differential kinds, and runtime-unsafe
1248
+ parameters keep receipts and suppress every runtime differential. Safe but
1249
+ inexact behavior fields retain an explicitly approximate runtime.
1250
+ """
1251
+ by_name: dict[str, list[dict[str, Any]]] = {}
1252
+ for row in devices:
1253
+ by_name.setdefault(row["name"], []).append(row)
1254
+ blockers = [f"Duplicate powertrain device name {name}."
1255
+ for name, rows in sorted(by_name.items()) if len(rows) != 1]
1256
+ runtime_safe = not blockers
1257
+ unique = {name: rows[0] for name, rows in by_name.items() if len(rows) == 1}
1258
+ transmission_devices = [row for row in devices
1259
+ if str(row.get("type", "")).lower() in TRANSMISSION_DEVICE_KINDS]
1260
+ gearbox_name = transmission_devices[0]["name"] if len(transmission_devices) == 1 else None
1261
+ gearbox_semantics = ({"status": "derived", "deviceName": transmission_devices[0]["name"],
1262
+ "deviceType": transmission_devices[0]["type"],
1263
+ "kind": TRANSMISSION_DEVICE_KINDS[str(transmission_devices[0]["type"]).lower()],
1264
+ "partName": transmission_devices[0]["partName"],
1265
+ "source": transmission_devices[0]["source"]}
1266
+ if len(transmission_devices) == 1 else
1267
+ {"status": "unsupported",
1268
+ "reason": f"Expected one selected recognized transmission device; found {len(transmission_devices)}."})
1269
+ if len(transmission_devices) != 1:
1270
+ blockers.append(f"Expected one selected recognized transmission device; found {len(transmission_devices)}.")
1271
+ runtime_safe = False
1272
+
1273
+ def trace(start: str) -> list[str] | None:
1274
+ path: list[str] = []
1275
+ seen: set[str] = set()
1276
+ current: str | None = start
1277
+ for _ in range(128):
1278
+ if current is None or current in seen:
1279
+ return None
1280
+ seen.add(current)
1281
+ path.append(current)
1282
+ if current == gearbox_name:
1283
+ return path
1284
+ row = unique.get(current)
1285
+ if row is None:
1286
+ return path
1287
+ upstream = row.get("inputName")
1288
+ current = upstream if isinstance(upstream, str) and upstream else None
1289
+ return None
1290
+
1291
+ wheel_starts: dict[str, list[str]] = {}
1292
+ for row in devices:
1293
+ wheel = row.get("connectedWheel")
1294
+ if isinstance(wheel, str) and wheel in ("FL", "FR", "RL", "RR"):
1295
+ wheel_starts.setdefault(wheel, []).append(row["name"])
1296
+ wheel_paths: dict[str, list[str]] = {}
1297
+ for wheel in ("FL", "FR", "RL", "RR"):
1298
+ starts = wheel_starts.get(wheel, [])
1299
+ traced = trace(starts[0]) if len(starts) == 1 else None
1300
+ if traced is not None and gearbox_name is not None and gearbox_name in traced:
1301
+ wheel_paths[wheel] = traced
1302
+ elif len(starts) > 1:
1303
+ blockers.append(f"Ambiguous connected-wheel endpoint {wheel}; found {len(starts)} devices.")
1304
+ runtime_safe = False
1305
+
1306
+ driven_axles: list[str] = []
1307
+ axle_wheels = {"front": ("FL", "FR"), "rear": ("RL", "RR")}
1308
+ for axle, wheels in axle_wheels.items():
1309
+ present = [wheel in wheel_paths for wheel in wheels]
1310
+ if all(present):
1311
+ driven_axles.append(axle)
1312
+ elif any(present):
1313
+ blockers.append(f"Only one selected {axle} wheel has a complete path to gearbox.")
1314
+ runtime_safe = False
1315
+ if not driven_axles:
1316
+ blockers.append("No selected axle has two complete wheel-to-gearbox paths.")
1317
+ runtime_safe = False
1318
+
1319
+ differential_names = {name for name, row in unique.items() if row.get("type") == "differential"}
1320
+ common_all: set[str] = set()
1321
+ if driven_axles:
1322
+ driven_paths = [set(wheel_paths[wheel]) for axle in driven_axles for wheel in axle_wheels[axle]]
1323
+ common_all = set.intersection(*driven_paths) & differential_names
1324
+ axle_diff_names: dict[str, str] = {}
1325
+ for axle in driven_axles:
1326
+ left, right = axle_wheels[axle]
1327
+ candidates = (set(wheel_paths[left]) & set(wheel_paths[right]) & differential_names)
1328
+ if len(driven_axles) == 2:
1329
+ candidates -= common_all
1330
+ if len(candidates) != 1:
1331
+ blockers.append(f"Expected one selected {axle} axle differential; found {len(candidates)}.")
1332
+ runtime_safe = False
1333
+ else:
1334
+ axle_diff_names[axle] = next(iter(candidates))
1335
+ centre_name: str | None = None
1336
+ if len(driven_axles) == 2:
1337
+ candidates = common_all - set(axle_diff_names.values())
1338
+ if len(candidates) != 1:
1339
+ blockers.append(f"Expected one selected AWD centre differential; found {len(candidates)}.")
1340
+ runtime_safe = False
1341
+ else:
1342
+ centre_name = next(iter(candidates))
1343
+
1344
+ selected_diffs = {axle: unique[name] for axle, name in axle_diff_names.items()}
1345
+ if centre_name is not None:
1346
+ selected_diffs["centre"] = unique[centre_name]
1347
+
1348
+ def branch_port(diff_name: str, wheels: tuple[str, ...]) -> int | None:
1349
+ children: set[str] = set()
1350
+ ports: set[int] = set()
1351
+ for wheel in wheels:
1352
+ path = wheel_paths[wheel]
1353
+ index = path.index(diff_name)
1354
+ if index == 0:
1355
+ return None
1356
+ child_name = path[index - 1]
1357
+ child = unique.get(child_name)
1358
+ port = child.get("inputIndex") if child else None
1359
+ if not isinstance(port, int):
1360
+ return None
1361
+ children.add(child_name)
1362
+ ports.add(port)
1363
+ return next(iter(ports)) if len(children) == 1 and len(ports) == 1 else None
1364
+
1365
+ runtime: dict[str, dict[str, Any]] = {}
1366
+ semantics_diffs: dict[str, Any] = {}
1367
+ used_variables: set[str] = set()
1368
+ field_specs = {
1369
+ "lockTorque": "N.m", "activeLockTorque": "N.m",
1370
+ "viscousCoef": "N.m/(rad/s)", "viscousTorque": "N.m", "lsdPreload": "N.m",
1371
+ "lsdLockCoef": "ratio", "lsdRevLockCoef": "ratio", "friction": "N.m",
1372
+ "dynamicFriction": "N.m/(rad/s)", "torqueLossCoef": "ratio",
1373
+ "defaultVirtualInertia": "kg.m^2", "speedLimitCoef": "ratio",
1374
+ }
1375
+ for location, row in selected_diffs.items():
1376
+ dtype = str(row.get("diffType", ""))
1377
+ kind = dtype.lower()
1378
+ if kind not in ("open", "locked", "viscous", "lsd"):
1379
+ blockers.append(f"Selected {location} differential type {dtype or '<missing>'} is unsupported.")
1380
+ runtime_safe = False
1381
+ fields: dict[str, Any] = {}
1382
+ values: dict[str, float] = {}
1383
+ for field, unit in field_specs.items():
1384
+ raw = row.get(field, 0)
1385
+ receipt, valid = numeric_receipt(raw, variables, unit, minimum=0)
1386
+ if field not in row and valid:
1387
+ receipt["status"] = "defaulted"
1388
+ receipt["reason"] = "BeamNG field absent; HELIX neutral value 0."
1389
+ fields[field] = receipt
1390
+ if valid:
1391
+ values[field] = float(receipt["value"])
1392
+ else:
1393
+ blockers.append(f"Selected {location} differential field {field} is unresolved.")
1394
+ runtime_safe = False
1395
+ used_variables.update(receipt["variables"])
1396
+ split_raw = row.get("diffTorqueSplit", .5)
1397
+ split, split_valid = numeric_receipt(split_raw, variables, "ratio", minimum=0)
1398
+ if "diffTorqueSplit" not in row and split_valid:
1399
+ split["status"] = "defaulted"
1400
+ split["reason"] = "BeamNG field absent; equal HELIX split 0.5."
1401
+ if split_valid and float(split["value"]) > 1:
1402
+ split["status"] = "unsupported"
1403
+ split["reason"] = "torque split must be within [0, 1]"
1404
+ split_valid = False
1405
+ fields["diffTorqueSplit"] = split
1406
+ used_variables.update(split["variables"])
1407
+ if not split_valid:
1408
+ blockers.append(f"Selected {location} differential torque split is unresolved or invalid.")
1409
+ runtime_safe = False
1410
+ if kind == "locked" and values.get("lockTorque", 0) <= 0:
1411
+ blockers.append(f"Selected {location} locked differential has no positive lockTorque.")
1412
+ runtime_safe = False
1413
+ active_controllers = [name for name in row.get("controllers", []) if "activeCenterDiff" in name]
1414
+ if values.get("activeLockTorque", 0) > 0 or active_controllers:
1415
+ blockers.append(f"Selected {location} activeLockTorque/controller cannot be represented by the HELIX differential runtime.")
1416
+ for field in ("torqueLossCoef", "defaultVirtualInertia", "speedLimitCoef"):
1417
+ if values.get(field, 0) > 0:
1418
+ blockers.append(
1419
+ f"Selected {location} differential {field} cannot be represented by the HELIX differential runtime."
1420
+ )
1421
+ if kind == "viscous":
1422
+ exponent_raw = row.get("viscousExponent")
1423
+ exponent, exponent_valid = numeric_receipt(exponent_raw, variables, "ratio", positive=True)
1424
+ if exponent_valid and exponent.get("value") != 1:
1425
+ exponent["status"] = "unsupported"
1426
+ exponent["reason"] = "HELIX differential runtime has no viscousExponent field."
1427
+ blockers.append(f"Selected {location} viscousExponent cannot be represented exactly.")
1428
+ elif not exponent_valid:
1429
+ blockers.append(f"Selected {location} viscousExponent is unresolved.")
1430
+ runtime_safe = False
1431
+ fields["viscousExponent"] = exponent
1432
+ used_variables.update(exponent["variables"])
1433
+ if values.get("viscousCoef", 0) <= 0 or values.get("viscousTorque", 0) <= 0:
1434
+ blockers.append(f"Selected {location} viscous differential parameters must be positive.")
1435
+ runtime_safe = False
1436
+ if kind == "lsd" and any(values.get(field, -1) < 0
1437
+ for field in ("lsdPreload", "lsdLockCoef", "lsdRevLockCoef")):
1438
+ blockers.append(f"Selected {location} LSD parameters are invalid.")
1439
+ runtime_safe = False
1440
+ source = row["source"]
1441
+ semantics_diffs[location] = {"deviceName": row["name"], "partName": row["partName"],
1442
+ "source": source, "type": kind, "fields": fields,
1443
+ "controllers": row.get("controllers", []),
1444
+ "overlaySources": row.get("overlaySources", []),
1445
+ "overlayFieldSources": row.get("overlayFieldSources", {}),
1446
+ "overlayConflicts": row.get("overlayConflicts", [])}
1447
+ provenance.append({"target": f"sourceSemantics.driveline.differentials.{location}.type",
1448
+ "source": source,
1449
+ "disposition": "source-literal" if kind in ("open", "locked", "viscous", "lsd") else "unsupported",
1450
+ "detail": f"Selected connected differential declares diffType {dtype or '<missing>'}."})
1451
+
1452
+ ratio_raw = row.get("gearRatio", 1 if location == "centre" else None)
1453
+ ratio_receipt, ratio_valid = numeric_receipt(ratio_raw, variables, "ratio", positive=True)
1454
+ if location == "centre" and "gearRatio" not in row and ratio_valid:
1455
+ ratio_receipt["status"] = "defaulted"
1456
+ ratio_receipt["reason"] = "BeamNG centre gearRatio absent; neutral ratio 1."
1457
+ semantics_diffs[location]["gearRatio"] = ratio_receipt
1458
+ used_variables.update(ratio_receipt["variables"])
1459
+ ratio = float(ratio_receipt.get("value", 1))
1460
+ if not ratio_valid:
1461
+ blockers.append(f"Selected {location} differential gearRatio is absent or invalid.")
1462
+ runtime_safe = False
1463
+
1464
+ if location == "centre":
1465
+ a_port = branch_port(row["name"], ("FL", "FR"))
1466
+ b_port = branch_port(row["name"], ("RL", "RR"))
1467
+ elif location == "front":
1468
+ a_port = branch_port(row["name"], ("FL",))
1469
+ b_port = branch_port(row["name"], ("FR",))
1470
+ else:
1471
+ a_port = branch_port(row["name"], ("RL",))
1472
+ b_port = branch_port(row["name"], ("RR",))
1473
+ semantics_diffs[location]["outputPorts"] = {"a": a_port, "b": b_port}
1474
+ if {a_port, b_port} != {1, 2}:
1475
+ blockers.append(f"Selected {location} differential output ports are missing, duplicate, or ambiguous.")
1476
+ runtime_safe = False
1477
+ if kind in ("open", "locked", "viscous", "lsd"):
1478
+ source_split = float(split.get("value", .5))
1479
+ runtime_split = source_split if a_port == 1 else round(1 - source_split, 8)
1480
+ runtime[location] = diff(kind, ratio, preload=values.get("lsdPreload", 0),
1481
+ lock=values.get("lsdLockCoef", 0),
1482
+ reverse=values.get("lsdRevLockCoef", 0), torque_split=runtime_split,
1483
+ friction=values.get("friction", 0),
1484
+ dynamic=values.get("dynamicFriction", 0),
1485
+ lock_torque=values.get("lockTorque", 0),
1486
+ viscous_coef=values.get("viscousCoef", 0),
1487
+ viscous_torque=values.get("viscousTorque", 0))
1488
+
1489
+ functional = not blockers and len(selected_diffs) == len(driven_axles) + (1 if len(driven_axles) == 2 else 0)
1490
+ if len(selected_diffs) != len(driven_axles) + (1 if len(driven_axles) == 2 else 0):
1491
+ runtime_safe = False
1492
+ if not runtime_safe:
1493
+ runtime = {}
1494
+ if driven_axles == ["front"]:
1495
+ bias_front = 1.0
1496
+ elif driven_axles == ["rear"]:
1497
+ bias_front = 0.0
1498
+ elif centre_name and isinstance(selected_diffs.get("centre"), dict):
1499
+ centre = semantics_diffs["centre"]
1500
+ centre_split = centre["fields"]["diffTorqueSplit"]
1501
+ if centre_split["status"] != "unsupported":
1502
+ source_split = float(centre_split["value"])
1503
+ bias_front = source_split if centre["outputPorts"]["a"] == 1 else round(1 - source_split, 8)
1504
+ else:
1505
+ bias_front = .5
1506
+ else:
1507
+ bias_front = .5
1508
+ if not runtime:
1509
+ bias_front = .5
1510
+ axle_ratios = [row["gearRatio"] for location, row in semantics_diffs.items()
1511
+ if location in ("front", "rear") and row["gearRatio"]["status"] != "unsupported"]
1512
+ unique_axle_ratios = {float(row["value"]) for row in axle_ratios}
1513
+ if len(axle_ratios) == len(driven_axles) and len(unique_axle_ratios) == 1:
1514
+ legacy_final_drive = {"raw": [row["raw"] for row in axle_ratios], "status": "derived",
1515
+ "unit": "ratio", "variables": sorted({variable for row in axle_ratios
1516
+ for variable in row["variables"]}),
1517
+ "value": next(iter(unique_axle_ratios))}
1518
+ else:
1519
+ legacy_final_drive = {"raw": [row.get("raw") for row in axle_ratios], "status": "unsupported",
1520
+ "unit": "ratio", "variables": sorted({variable for row in axle_ratios
1521
+ for variable in row["variables"]}),
1522
+ "reason": "Driven axle ratios are missing or unequal; one legacy finalDrive cannot represent them."}
1523
+ provenance.append({"target": "simulation.land.driveline.differentials",
1524
+ "source": "selected JBeam powertrain graph",
1525
+ "disposition": "derived" if functional else "unsupported",
1526
+ "detail": "Wheel endpoints traced to gearbox without synthesized axles or centre differential."
1527
+ if functional else " ".join(blockers)})
1528
+ semantics = {"status": "functional" if functional else "unsupported",
1529
+ "runtimeStatus": "functional" if functional else "approximate" if runtime else "unsupported",
1530
+ "gearbox": gearbox_semantics, "transmissionDevice": gearbox_name,
1531
+ "legacyFinalDrive": legacy_final_drive,
1532
+ "drivenAxles": driven_axles, "wheelPaths": wheel_paths,
1533
+ "differentials": semantics_diffs,
1534
+ "variables": {name: variable_receipts[name] for name in sorted(used_variables)
1535
+ if name in variable_receipts},
1536
+ "runtimeBlockers": blockers}
1537
+ return runtime, semantics, functional, bias_front
1538
+
1539
+
1048
1540
  def main() -> None:
1049
1541
  parser = argparse.ArgumentParser()
1050
1542
  parser.add_argument("--source-root", required=True)
@@ -1357,12 +1849,10 @@ def main() -> None:
1357
1849
  published_power_rpm = range_midpoint(config_info.get("PowerPeakRPM"), peak_power_rpm)
1358
1850
  published_torque_rpm = range_midpoint(config_info.get("TorquePeakRPM"), peak_rpm)
1359
1851
 
1360
- power_rows: list[dict[str, Any]] = []
1361
- for _, _, body, _ in selected:
1362
- power_rows.extend(decode_jbeam_table(body.get("powertrain")))
1852
+ power_rows = selected_powertrain_devices(selected, source_label)
1363
1853
  engine_device = next((r for r in power_rows if r.get("type") == "combustionEngine"), {})
1364
1854
  transmission_kind, transmission_source, transmission_detail = selected_transmission_kind(
1365
- selected, config_info, source_label)
1855
+ power_rows, config_info)
1366
1856
  transmission_kind_sourced = transmission_kind is not None
1367
1857
  if transmission_kind_sourced:
1368
1858
  prov("simulation.land.driveline.gearbox.kind", transmission_source, "source-literal", transmission_detail)
@@ -1430,6 +1920,14 @@ def main() -> None:
1430
1920
  suspension, suspension_semantics = lower_selected_suspension(
1431
1921
  selected, selected_variables, selected_variable_receipts, source_label,
1432
1922
  chassis_mass, weight_distribution_front, provenance)
1923
+ differential_runtime, driveline_semantics, differentials_functional, drive_bias_front = lower_selected_driveline(
1924
+ power_rows, selected_variables, selected_variable_receipts, provenance)
1925
+ legacy_final_drive = driveline_semantics["legacyFinalDrive"]
1926
+ if legacy_final_drive["status"] != "unsupported":
1927
+ final_drive = float(legacy_final_drive["value"])
1928
+ fd_key = "selected-driveline-final-drive"
1929
+ tyre_by_axle, tyre_semantics, tyres_functional = lower_selected_tyres(
1930
+ selected, selected_variables, selected_variable_receipts, source_label, provenance)
1433
1931
  turbo_parts = [(name, body, path) for _, name, body, path in selected if isinstance(body.get("turbocharger"), dict)]
1434
1932
  wastegate_psi = None
1435
1933
  for _, name, body, _ in selected:
@@ -1464,36 +1962,14 @@ def main() -> None:
1464
1962
  prov("physics.induction", f"info_{config_path.stem}.json#Induction Type", "discovered-not-lowered",
1465
1963
  "Selected configuration declares forced induction but exact turbo and ECU literals were incomplete.")
1466
1964
 
1467
- diffs: dict[str, dict[str, Any]] = {}
1468
- for row in power_rows:
1469
- if row.get("type") not in ("differential", "shaft"):
1470
- continue
1471
- name = str(row.get("name", "")).lower()
1472
- key = "centre" if "transfer" in name or "center" in name or "centre" in name else "front" if "_f" in name else "rear" if "_r" in name else ""
1473
- if not key: continue
1474
- dtype = str(row.get("diffType", "open")).lower()
1475
- if dtype not in ("open", "locked", "viscous", "lsd"): dtype = "open"
1476
- diffs[key] = diff(dtype, final_drive if key != "centre" else float(row.get("gearRatio", 1) or 1),
1477
- float(row.get("lsdPreload", 0) or 0), float(row.get("lsdLockCoef", 0) or 0),
1478
- float(row.get("lsdRevLockCoef", 0) or 0),
1479
- float(row.get("diffTorqueSplit", .5) or .5), float(row.get("friction", 0) or 0),
1480
- float(row.get("dynamicFriction", 0) or 0), float(row.get("lockTorque", 0) or 0),
1481
- float(row.get("viscousCoef", 0) or 0), float(row.get("viscousTorque", 0) or 0))
1482
- # Selected Sunburst transfer case expresses the centre diff as a shaft row.
1483
- for split in indexed_values(value_index, "diffTorqueSplit"):
1484
- if isinstance(split, (int, float)):
1485
- diffs["centre"] = diff("lsd", 1, 2.5, .175, .175, float(split))
1486
- front = diffs.get("front", diff("lsd", final_drive, 45, .175, .01))
1487
- rear = diffs.get("rear", diff("lsd", final_drive, 65, .175, .175))
1488
- centre = diffs.get("centre", diff("lsd", 1, 2.5, .175, .175, .65))
1489
-
1490
1965
  # Geometry can be supplied from Blender's authoritative receipt.
1491
1966
  geometry: dict[str, Any] = {}
1492
1967
  if args.geometry:
1493
1968
  document = json.loads(Path(args.geometry).read_text())
1494
1969
  geometry = document.get("HELIX_vehicle", document)
1495
1970
  sockets = geometry.get("sockets") if isinstance(geometry.get("sockets"), dict) else {}
1496
- wheel_radius = float(geometry.get("wheelRadius", .3466))
1971
+ selected_tyre_radius = sum(float(row["radius"]) for row in tyre_by_axle) / len(tyre_by_axle)
1972
+ wheel_radius = float(geometry.get("wheelRadius", selected_tyre_radius))
1497
1973
  bounds = geometry.get("bounds", {})
1498
1974
  extents = bounds.get("size") if isinstance(bounds, dict) else None
1499
1975
  if (not isinstance(extents, list) or len(extents) != 3) and isinstance(bounds, dict):
@@ -1594,12 +2070,6 @@ def main() -> None:
1594
2070
  audio_slots.append({"slot": slot_name, "buffers": [sample] if sample else [], "consumed": bool(sample),
1595
2071
  **({} if sample else {"reason": "No selected source event was mapped to this HELIX slot."})})
1596
2072
 
1597
- tyre = {"loadSensitivity": {"frictionCoef": 1, "slidingFrictionCoef": .85, "noLoadCoef": 1.1,
1598
- "fullLoadCoef": 1.1, "loadSensitivitySlope": 0},
1599
- "muLut": {"fz": [1000, 3000, 6000, 9000], "mu": [1.1, 1.1, 1.1, 1.1], "slideRatio": .85},
1600
- "alphaPeakRad": .139626, "cxMult": 1.05, "sigmaX": .048, "sigmaY": .08,
1601
- "camberGain": .15, "dxOverDy": 1, "sFall": .3, "rollingResistance": .013,
1602
- "radius": wheel_radius, "angularInertia": 1.3, "calibration": "prior"}
1603
2073
  engine_spec = {"torqueCurve": {"points": torque_curve}, "idleRpm": idle, "limiterRpm": limiter,
1604
2074
  "revLimiterType": "soft", "inertia": inertia, "friction": friction,
1605
2075
  "dynamicFriction": dyn_friction, "engineBrakeTorque": coast,
@@ -1611,9 +2081,9 @@ def main() -> None:
1611
2081
  "upRpm": max(idle + 1, limiter - 200), "downRpm": 2800}}
1612
2082
  land = {"version": 1, "driveline": {"engine": engine_spec,
1613
2083
  "clutch": {"inertia": .1, "capacity": peak_torque * 1.6, "freePlay": .2, "launchStartRpm": 2000, "launchTargetRpm": 2500},
1614
- "gearbox": gearbox_spec, "front": front, "rear": rear, "centre": centre},
2084
+ "gearbox": gearbox_spec, **differential_runtime},
1615
2085
  "abs": {"targetSlip": .12, "releaseRate": 12, "applyRate": 5, "minSpeed": 2.5},
1616
- "tyreByAxle": [tyre, dict(tyre)]}
2086
+ "tyreByAxle": tyre_by_axle}
1617
2087
 
1618
2088
  physics = {
1619
2089
  "chassis": {"mass": sourced(float(config_info["Weight"]), "kg", source_ref,
@@ -1640,7 +2110,10 @@ def main() -> None:
1640
2110
  "gearbox": {"ratios": sourced(ratios, "ratio", source_ref if ratios_sourced else None), "reverseRatio": sourced(reverse, "ratio", source_ref if reverse_key != "HELIX-default" else None),
1641
2111
  "finalDrive": sourced(final_drive, "ratio", source_ref if fd_key != "HELIX-default" else None), "shiftTime": sourced(shift_delay, "s", source_ref if shift_key != "HELIX-default" else None),
1642
2112
  "autoUpRpm": sourced(max(idle + 1, limiter - 200), "rpm", note="HELIX automatic shift prior; ignored when the selected transmission is manual."), "autoDownRpm": sourced(2800, "rpm", note="HELIX automatic shift prior; ignored when the selected transmission is manual.")},
1643
- "driveBiasFront": sourced(.35, "ratio", note="1 - selected centre differential rear torque split."),
2113
+ "driveBiasFront": sourced(drive_bias_front, "ratio", source_ref if differentials_functional else None,
2114
+ "Derived from selected driven-axle topology and centre rear torque split."
2115
+ if differentials_functional else
2116
+ "Neutral fallback because selected driveline topology is unsupported."),
1644
2117
  "brakes": brakes,
1645
2118
  "aero": {"dragCoefficient": sourced(.32, "ratio", note="Explicit sports-sedan prior."), "frontalArea": sourced(2.2, "m2", note="width*height*shape factor prior."),
1646
2119
  "airDensity": sourced(1.225, "kg/m3", note="ISA sea-level standard."), "downforceCoefficient": sourced(0, "ratio", note="No selected literal aerodynamic coefficient.")},
@@ -1652,14 +2125,15 @@ def main() -> None:
1652
2125
  "rackTime": sourced(.8, "s", note="Derived from selected steering in/out rate."), "speedSensitivity": sourced([[0, 1], [50, .45]], "m/s,ratio", note="HELIX steering assist prior.")},
1653
2126
  "linearDamping": sourced(.02, "ratio", note="HELIX solver stability prior."), "angularDamping": sourced(.08, "ratio", note="HELIX solver stability prior.")}
1654
2127
 
2128
+ # Bridge materializes one canonical GLB today. Do not advertise planned
2129
+ # optimization targets as selectable artifacts until the optimizer has
2130
+ # actually sealed them into the package.
1655
2131
  renditions = [{"id": "source", "kind": "source", "triangleBudget": 500000, "textureMaxDimension": 4096}]
1656
- for rid, budget, texture in [("mobile-low", 35000, 512), ("balanced", 90000, 1024), ("desktop-high", 220000, 2048)]:
1657
- renditions.append({"id": rid, "kind": "derived", "sourceRenditionId": "source", "triangleBudget": budget, "textureMaxDimension": texture})
1658
2132
  tiers = {
1659
- "minimal": {"renditionId":"mobile-low","interior":"hidden","materialLayers":"base","lightBeams":False,"physicalPanels":False,"detachableParts":False},
1660
- "low": {"renditionId":"mobile-low","interior":"simplified","materialLayers":"base","lightBeams":False,"physicalPanels":False,"detachableParts":False},
1661
- "medium": {"renditionId":"balanced","interior":"simplified","materialLayers":"dynamic","lightBeams":True,"physicalPanels":True,"detachableParts":False},
1662
- "high": {"renditionId":"desktop-high","interior":"full","materialLayers":"full","lightBeams":True,"physicalPanels":True,"detachableParts":False},
2133
+ "minimal": {"renditionId":"source","interior":"hidden","materialLayers":"base","lightBeams":False,"physicalPanels":False,"detachableParts":False},
2134
+ "low": {"renditionId":"source","interior":"simplified","materialLayers":"base","lightBeams":False,"physicalPanels":False,"detachableParts":False},
2135
+ "medium": {"renditionId":"source","interior":"simplified","materialLayers":"dynamic","lightBeams":True,"physicalPanels":True,"detachableParts":False},
2136
+ "high": {"renditionId":"source","interior":"full","materialLayers":"full","lightBeams":True,"physicalPanels":True,"detachableParts":False},
1663
2137
  "ultra": {"renditionId":"source","interior":"full","materialLayers":"full","lightBeams":True,"physicalPanels":True,"detachableParts":False},
1664
2138
  }
1665
2139
  package_id = f"beamng-{slug(candidate)}-{slug(config_path.stem)}"
@@ -1741,8 +2215,9 @@ def main() -> None:
1741
2215
  and row["brakeTorque"].get("raw") is not None}),
1742
2216
  **brake_semantics},
1743
2217
  "suspension": suspension_semantics,
2218
+ "driveline": driveline_semantics,
1744
2219
  "wheelsTyres": {"selectedParts": sorted(name for name in selected_names if any(token in name.lower() for token in ("wheel", "rim", "tire", "tyre"))),
1745
- "literalRadii": sorted({float(value) for value in indexed_values(value_index, "radius") if isinstance(value, (int, float))})},
2220
+ **tyre_semantics},
1746
2221
  "electrics": sorted({str(value) for value in indexed_values(value_index, "electricsName") + indexed_values(value_index, "$electric")}),
1747
2222
  "audioEvents": sorted({str(value) for value in indexed_values(value_index, "sampleName") + indexed_values(value_index, "deformSound")}),
1748
2223
  "openableControllers": [name for name in controllers if name == "advancedCouplerControl"],
@@ -1762,7 +2237,6 @@ def main() -> None:
1762
2237
  prov("runtime.controllers", "selected JBeam controller tables", "unsupported", f"{len(controllers)} controller types retained in capability ledger.")
1763
2238
  prov("runtime.softbody", "selected JBeam nodes/beams/triangles", "out-of-scope", "Not emitted into runtime contract.")
1764
2239
  prov("runtime.fluids", "selected JBeam energy/thermal sections", "out-of-scope", "Not emitted into runtime contract.")
1765
- prov("simulation.land.tyreByAxle", "selected BeamNG wheel/tyre identities", "defaulted", "Compact common corpus lacks literal tyre model rows.")
1766
2240
  prov("mesh.visual.tiers", "HELIX engine fidelity contract", "defaulted", "Presentation-only policy; simulation and identity are invariant.")
1767
2241
 
1768
2242
  provenance.sort(key=lambda row: (row["target"], row["source"], row["disposition"], row["detail"]))
@@ -1775,11 +2249,10 @@ def main() -> None:
1775
2249
  "geometrySockets": bool(sockets),
1776
2250
  "engine": bool(engine_parts and len(torque) >= 2 and (not turbo_declared or induction is not None)),
1777
2251
  "gearbox": ratios_sourced and transmission_kind_sourced,
1778
- "differentials": bool(diffs),
2252
+ "differentials": differentials_functional,
1779
2253
  "brakes": brakes_functional,
1780
2254
  "suspension": False,
1781
- "wheelsTyres": any(value for value in indexed_values(value_index, "radius")
1782
- if isinstance(value, (int, float))),
2255
+ "wheelsTyres": tyres_functional,
1783
2256
  "seatsCameras": any(str(name).startswith("seat") for name in sockets),
1784
2257
  "lightsElectrics": light_evidence,
1785
2258
  "audio": bool(engine_sample or exhaust_sample),