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

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.200",
3
+ "version": "0.1.14-helix3.201",
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",
@@ -41,8 +41,10 @@ External-plugin execution still requires the Bridge plan key and all permissions
41
41
 
42
42
  The converter reads all four BeamNG material stages. Later authored stages win, while conflicts, declaration provenance, null sentinels, unknown maps, and dynamic `@...` render targets remain explicit diagnostics. Texture resolution is deterministic: selected vehicle files, then mod files, then the reviewed base-game common snapshot; same-tier ambiguity refuses the binding. This parser is packaged with the plugin and never imports engine-repository code at runtime.
43
43
 
44
- Each successful terminal package emits five assets: the canonical GLB, `<vehicle>.vehicle-source.json`, `<vehicle>.vehicle-package.json`, `<vehicle>.vehicle-addons.json`, and `<vehicle>.vehicle-build.json`. The source manifest remains embedded in GLB extras and records the exact configuration and slot closure, fingerprints, material-resolution provenance, and coverage ledger.
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, lights/electrics, audio event references, seats/cameras, and rigid openables when present. Spring, damper, brake, tyre, raw Lua/controller, and 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.
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.
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.
47
49
 
48
50
  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.
@@ -514,6 +514,167 @@ function assertValidVehiclePackage(pkg, boundary) {
514
514
  }
515
515
  }
516
516
 
517
+ function record(value) {
518
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
519
+ }
520
+
521
+ function assertValidFunctionalLowering(lowering, boundary) {
522
+ const semantics = lowering?.sourceSemantics;
523
+ const suspension = semantics?.suspension;
524
+ const coverage = lowering?.coverage;
525
+ const suspensionCounts = suspension?.counts;
526
+ const suspensionProvenance = coverage?.suspensionProvenance;
527
+ const requiredSuspensionCounts = ['selectedLeafParts', 'excludedStructuralParts', 'resolvedAxles', 'sourceRows'];
528
+ const requiredProvenanceCounts = ['source-literal', 'derived', 'defaulted', 'unsupported'];
529
+ const nonempty = (value) => typeof value === 'string' && value.length > 0;
530
+ const explicitUnsupported = (value) => record(value) && value.status === 'unsupported' && nonempty(value.reason);
531
+ const semanticReceipt = (value) => record(value)
532
+ && ['literal', 'derived', 'unsupported'].includes(value.status) && nonempty(value.unit)
533
+ && Array.isArray(value.variables)
534
+ && (value.status === 'unsupported' ? nonempty(value.reason) : Number.isFinite(value.value));
535
+ const selectedReceipt = (value) => semanticReceipt(value) && (value.status === 'unsupported'
536
+ || (Object.hasOwn(value, 'raw') && value.symmetricRows === 2));
537
+ const dampingReceipt = (value) => semanticReceipt(value) && (value.status === 'unsupported'
538
+ || (Object.hasOwn(value, 'raw') && value.symmetricRows === 2
539
+ && semanticReceipt(value.velocitySplit) && Object.hasOwn(value.velocitySplit, 'raw')
540
+ && value.velocitySplit.symmetricRows === 2 && value.velocitySplit.unit === 'm/s'
541
+ && value.velocitySplit.status !== 'unsupported' && value.velocitySplit.value >= 0));
542
+ const torsionReceipt = (value) => semanticReceipt(value)
543
+ && (value.status === 'unsupported' || Object.hasOwn(value, 'raw'));
544
+ const suspensionAxle = (value) => {
545
+ if (explicitUnsupported(value) && Array.isArray(value.candidates)) return true;
546
+ if (!record(value) || !record(value.damping)) return false;
547
+ const receipts = [value.springRate, value.precompressionRange, value.bumpLimit,
548
+ ...['slowBump', 'fastBump', 'slowRebound', 'fastRebound'].map((name) => value.damping[name])];
549
+ const receiptShapeValid = receipts.slice(0, 3).every(selectedReceipt)
550
+ && receipts.slice(3).every(dampingReceipt);
551
+ const expectedStatus = receipts.every((receipt) => receipt?.status !== 'unsupported')
552
+ ? 'resolved' : 'unsupported';
553
+ return value.status === expectedStatus
554
+ && nonempty(value.slotType) && nonempty(value.partName) && nonempty(value.source)
555
+ && receiptShapeValid && record(value.derived) && record(value.rowCounts)
556
+ && ['spring', 'damper', 'travelLimit'].every((name) => Number.isInteger(value.rowCounts[name])
557
+ && value.rowCounts[name] >= 0);
558
+ };
559
+ const antiRollAxle = (value) => explicitUnsupported(value) || (record(value)
560
+ && nonempty(value.slotType) && nonempty(value.partName) && nonempty(value.source)
561
+ && torsionReceipt(value.torsionSpring) && value.runtimeStatus === 'unsupported' && nonempty(value.reason));
562
+ const sourceExtractionComplete = suspension?.axles?.front?.status === 'resolved'
563
+ && suspension?.axles?.rear?.status === 'resolved'
564
+ && suspension?.antiRoll?.front?.torsionSpring?.status !== 'unsupported'
565
+ && suspension?.antiRoll?.front?.torsionSpring !== undefined
566
+ && suspension?.antiRoll?.rear?.torsionSpring?.status !== 'unsupported'
567
+ && suspension?.antiRoll?.rear?.torsionSpring !== undefined;
568
+ const countedSuspensionProvenance = Object.fromEntries(requiredProvenanceCounts.map((disposition) => [
569
+ disposition,
570
+ Array.isArray(lowering?.provenance) ? lowering.provenance.filter((row) => record(row)
571
+ && row.disposition === disposition
572
+ && typeof row.target === 'string'
573
+ && (row.target.startsWith('physics.suspension')
574
+ || row.target.startsWith('sourceSemantics.suspension'))).length : -1,
575
+ ]));
576
+ const requiredSemanticTargets = new Map();
577
+ const receiptDisposition = (receipt) => receipt?.status === 'literal' ? 'source-literal' : receipt?.status;
578
+ const expectedEvidence = (receipt, source) => ({ disposition: receiptDisposition(receipt), source });
579
+ for (const axle of ['front', 'rear']) {
580
+ const row = suspension?.axles?.[axle];
581
+ if (record(row) && nonempty(row.partName)) {
582
+ requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}.springRate`,
583
+ expectedEvidence(row.springRate, row.source));
584
+ requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}.precompressionRange`,
585
+ expectedEvidence(row.precompressionRange, row.source));
586
+ requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}.bumpLimit`,
587
+ expectedEvidence(row.bumpLimit, row.source));
588
+ for (const field of ['slowBump', 'fastBump', 'slowRebound', 'fastRebound']) {
589
+ requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}.damping.${field}`,
590
+ expectedEvidence(row.damping?.[field], row.source));
591
+ }
592
+ if (Object.keys(row.derived ?? {}).length > 0) {
593
+ requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}.derived`,
594
+ { disposition: 'derived', source: row.source });
595
+ }
596
+ } else if (explicitUnsupported(row)) {
597
+ requiredSemanticTargets.set(`sourceSemantics.suspension.axles.${axle}`,
598
+ { disposition: 'unsupported', source: 'selected JBeam part custody' });
599
+ }
600
+ if (record(suspension?.antiRoll?.[axle]) && nonempty(suspension.antiRoll[axle].partName)) {
601
+ requiredSemanticTargets.set(`sourceSemantics.suspension.antiRoll.${axle}.torsionSpring`,
602
+ expectedEvidence(suspension.antiRoll[axle].torsionSpring, suspension.antiRoll[axle].source));
603
+ }
604
+ }
605
+ const provenanceByTarget = new Map();
606
+ if (Array.isArray(lowering?.provenance)) {
607
+ for (const row of lowering.provenance) {
608
+ if (record(row) && typeof row.target === 'string') {
609
+ const rows = provenanceByTarget.get(row.target) ?? [];
610
+ rows.push(row);
611
+ provenanceByTarget.set(row.target, rows);
612
+ }
613
+ }
614
+ }
615
+ const validSemanticTargets = [...requiredSemanticTargets].every(([target, expected]) => {
616
+ const rows = provenanceByTarget.get(target);
617
+ return rows?.length === 1 && rows[0].disposition === expected.disposition
618
+ && rows[0].source === expected.source;
619
+ });
620
+ const axleRows = [suspension?.axles?.front, suspension?.axles?.rear];
621
+ const expectedSuspensionCounts = {
622
+ selectedLeafParts: axleRows.reduce((count, row) => count + (Array.isArray(row?.candidates)
623
+ ? row.candidates.length : record(row) && nonempty(row.partName) ? 1 : 0), 0),
624
+ excludedStructuralParts: Array.isArray(suspension?.excludedStructuralParts)
625
+ ? suspension.excludedStructuralParts.length : -1,
626
+ resolvedAxles: axleRows.filter((row) => row?.status === 'resolved').length,
627
+ sourceRows: axleRows.reduce((count, row) => count + (record(row?.rowCounts)
628
+ ? Object.values(row.rowCounts).reduce((sum, value) => sum + (Number.isInteger(value) ? value : 0), 0) : 0), 0),
629
+ };
630
+ const validSemantics = record(semantics)
631
+ && record(semantics.mass) && Number.isInteger(semantics.mass.uniqueNodes)
632
+ && Number.isFinite(semantics.mass.selectedNodeMassKg)
633
+ && Array.isArray(semantics.seats)
634
+ && Array.isArray(semantics.cameras)
635
+ && record(semantics.brakes) && record(semantics.brakes.axles)
636
+ && record(suspension)
637
+ && record(suspension.axles) && suspensionAxle(suspension.axles.front) && suspensionAxle(suspension.axles.rear)
638
+ && record(suspension.antiRoll) && antiRollAxle(suspension.antiRoll.front) && antiRollAxle(suspension.antiRoll.rear)
639
+ && Array.isArray(suspension.excludedStructuralParts)
640
+ && Array.isArray(suspension.runtimeBlockers) && suspension.runtimeBlockers.length > 0
641
+ && typeof suspension.extractionComplete === 'boolean'
642
+ && suspension.extractionComplete === sourceExtractionComplete
643
+ && record(suspensionCounts)
644
+ && requiredSuspensionCounts.every((name) => Number.isInteger(suspensionCounts[name])
645
+ && suspensionCounts[name] >= 0 && suspensionCounts[name] === expectedSuspensionCounts[name])
646
+ && record(semantics.wheelsTyres) && Array.isArray(semantics.wheelsTyres.selectedParts)
647
+ && Array.isArray(semantics.wheelsTyres.literalRadii)
648
+ && Array.isArray(semantics.electrics)
649
+ && Array.isArray(semantics.audioEvents)
650
+ && Array.isArray(semantics.openableControllers);
651
+ const validCoverage = record(coverage)
652
+ && record(coverage.functionalDomains)
653
+ && coverage.functionalDomains.suspension === false
654
+ && record(suspensionProvenance)
655
+ && requiredProvenanceCounts.every((name) => Number.isInteger(suspensionProvenance[name])
656
+ && suspensionProvenance[name] >= 0
657
+ && suspensionProvenance[name] === countedSuspensionProvenance[name]);
658
+ const validCapabilities = Array.isArray(lowering?.capabilities) && lowering.capabilities.length > 0
659
+ && lowering.capabilities.every((row) => record(row) && typeof row.capability === 'string'
660
+ && typeof row.disposition === 'string' && Array.isArray(row.source) && typeof row.note === 'string');
661
+ const validProvenance = Array.isArray(lowering?.provenance) && lowering.provenance.length > 0
662
+ && lowering.provenance.every((row) => record(row) && typeof row.target === 'string'
663
+ && typeof row.source === 'string'
664
+ && ['source-literal', 'derived', 'defaulted', 'unsupported', 'out-of-scope',
665
+ 'missing-source', 'discovered-not-lowered'].includes(row.disposition)
666
+ && typeof row.detail === 'string');
667
+ const invalid = [
668
+ [lowering?.schemaVersion === 'helix.beamng-functional-lowering/2', 'schema'],
669
+ [validSemantics, 'sourceSemantics'], [validCapabilities, 'capabilities'],
670
+ [validProvenance, 'provenance'], [validSemanticTargets, 'semanticProvenance'],
671
+ [validCoverage, 'coverage'],
672
+ ].filter(([valid]) => !valid).map(([, name]) => name);
673
+ if (invalid.length > 0) {
674
+ throw new Error(`${boundary} has an incompatible schema or incomplete helix.beamng-functional-lowering/2 diagnostics: ${invalid.join(', ')}`);
675
+ }
676
+ }
677
+
517
678
  function lowerFunctionalVehicle({ source, candidate, sourceManifest, conversion, outputDir, commonRoot,
518
679
  selectedPartIndexArtifact }) {
519
680
  const configurationFile = conversion.importQuality?.configuration?.file;
@@ -543,8 +704,8 @@ function lowerFunctionalVehicle({ source, candidate, sourceManifest, conversion,
543
704
  rmSync(sourcePath, { force: true });
544
705
  rmSync(geometryPath, { force: true });
545
706
  }
546
- if (bundle?.schemaVersion !== 'helix.beamng-functional-lowering/1'
547
- || bundle?.vehiclePackage?.schemaVersion !== 'helix.vehicle-package/2'
707
+ assertValidFunctionalLowering(bundle, 'functional BeamNG lowerer');
708
+ if (bundle?.vehiclePackage?.schemaVersion !== 'helix.vehicle-package/2'
548
709
  || bundle?.addOnPackages?.schemaVersion !== 'helix.vehicle-addon-packages/1'
549
710
  || bundle?.build?.schemaVersion !== 'helix.vehicle-build-source/1'
550
711
  || bundle?.identity?.candidateId !== candidate.id
@@ -563,8 +724,8 @@ function packageVehicleSource(request) {
563
724
  }
564
725
  const functional = input?.metadata?.functionalVehicle;
565
726
  const candidateId = portableCandidateId(manifest?.vehicle?.candidateId);
566
- if (!functional || functional.schemaVersion !== 'helix.beamng-functional-lowering/1'
567
- || functional.vehiclePackage?.schemaVersion !== 'helix.vehicle-package/2'
727
+ assertValidFunctionalLowering(functional, 'BeamNG functional package metadata');
728
+ if (functional.vehiclePackage?.schemaVersion !== 'helix.vehicle-package/2'
568
729
  || functional.addOnPackages?.schemaVersion !== 'helix.vehicle-addon-packages/1'
569
730
  || functional.build?.schemaVersion !== 'helix.vehicle-build-source/1'
570
731
  || functional.identity?.candidateId !== manifest.vehicle.candidateId
@@ -617,6 +778,7 @@ function packageVehicleSource(request) {
617
778
  vehicle: candidateId } },
618
779
  ];
619
780
  for (const [suffix, value, role] of [
781
+ ['vehicle-lowering', functional, 'vehicle-functional-lowering'],
620
782
  ['vehicle-package', functional.vehiclePackage, 'vehicle-package'],
621
783
  ['vehicle-addons', functional.addOnPackages, 'vehicle-add-on-packages'],
622
784
  ['vehicle-build', functional.build, 'vehicle-build-source'],
@@ -0,0 +1,373 @@
1
+ """Bounded evaluator for the portable subset of BeamNG value expressions.
2
+
3
+ BeamNG JBeam values commonly use ``$=`` expressions backed by configuration
4
+ variables. This module deliberately implements only the data-only subset the
5
+ Bridge can prove equivalent. It never calls Python ``eval`` and it refuses
6
+ unknown variables, functions, tokens, non-finite arithmetic, and excessive
7
+ input before returning a value.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ import math
13
+ import re
14
+ from typing import Any, Mapping
15
+
16
+
17
+ MAX_EXPRESSION_CHARS = 4096
18
+ MAX_TOKENS = 512
19
+ MAX_PARSE_DEPTH = 64
20
+ MAX_EVALUATION_STEPS = 2048
21
+
22
+
23
+ class BeamNGExpressionUnsupported(ValueError):
24
+ """The expression is outside the explicitly supported portable subset."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ExpressionResolution:
29
+ status: str
30
+ value: Any
31
+ variables: tuple[str, ...]
32
+ reason: str | None = None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class _Token:
37
+ kind: str
38
+ value: str
39
+ offset: int
40
+
41
+
42
+ _NUMBER = re.compile(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?")
43
+ _VARIABLE = re.compile(r"\$[A-Za-z_][A-Za-z0-9_]*")
44
+ _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
45
+ _TWO_CHAR_OPERATORS = {"==", "~=", "!=", "<=", ">="}
46
+ _ONE_CHAR_OPERATORS = set("()+-*/%^,<>")
47
+ _KEYWORDS = {"and", "or", "not", "nil", "true", "false"}
48
+ _ALLOWED_CALLS = {"case"}
49
+
50
+
51
+ def _tokenize(expression: str) -> list[_Token]:
52
+ if not isinstance(expression, str) or not expression.startswith("$="):
53
+ raise BeamNGExpressionUnsupported("value is not a BeamNG $= expression")
54
+ source = expression[2:]
55
+ if not source.strip():
56
+ raise BeamNGExpressionUnsupported("expression is empty")
57
+ if len(source) > MAX_EXPRESSION_CHARS:
58
+ raise BeamNGExpressionUnsupported(
59
+ f"expression exceeds {MAX_EXPRESSION_CHARS} characters")
60
+ tokens: list[_Token] = []
61
+ offset = 0
62
+ while offset < len(source):
63
+ if source[offset].isspace():
64
+ offset += 1
65
+ continue
66
+ match = _NUMBER.match(source, offset)
67
+ if match:
68
+ tokens.append(_Token("number", match.group(0), offset))
69
+ offset = match.end()
70
+ else:
71
+ match = _VARIABLE.match(source, offset)
72
+ if match:
73
+ tokens.append(_Token("variable", match.group(0), offset))
74
+ offset = match.end()
75
+ else:
76
+ match = _IDENTIFIER.match(source, offset)
77
+ if match:
78
+ value = match.group(0)
79
+ tokens.append(_Token("keyword" if value in _KEYWORDS else "identifier",
80
+ value, offset))
81
+ offset = match.end()
82
+ else:
83
+ pair = source[offset:offset + 2]
84
+ if pair in _TWO_CHAR_OPERATORS:
85
+ tokens.append(_Token("operator", pair, offset))
86
+ offset += 2
87
+ elif source[offset] in _ONE_CHAR_OPERATORS:
88
+ tokens.append(_Token("operator", source[offset], offset))
89
+ offset += 1
90
+ else:
91
+ raise BeamNGExpressionUnsupported(
92
+ f"unsupported token at offset {offset}: {source[offset:offset + 12]!r}")
93
+ if len(tokens) > MAX_TOKENS:
94
+ raise BeamNGExpressionUnsupported(f"expression exceeds {MAX_TOKENS} tokens")
95
+ tokens.append(_Token("eof", "", len(source)))
96
+ return tokens
97
+
98
+
99
+ class _Parser:
100
+ def __init__(self, tokens: list[_Token]):
101
+ self.tokens = tokens
102
+ self.index = 0
103
+ self.depth = 0
104
+
105
+ @property
106
+ def current(self) -> _Token:
107
+ return self.tokens[self.index]
108
+
109
+ def accept(self, value: str) -> bool:
110
+ if self.current.value == value:
111
+ self.index += 1
112
+ return True
113
+ return False
114
+
115
+ def expect(self, value: str) -> None:
116
+ if not self.accept(value):
117
+ raise BeamNGExpressionUnsupported(
118
+ f"expected {value!r} at offset {self.current.offset}")
119
+
120
+ def parse(self) -> tuple[Any, ...]:
121
+ node = self.parse_or()
122
+ if self.current.kind != "eof":
123
+ raise BeamNGExpressionUnsupported(
124
+ f"unexpected token {self.current.value!r} at offset {self.current.offset}")
125
+ return node
126
+
127
+ def descend(self) -> None:
128
+ self.depth += 1
129
+ if self.depth > MAX_PARSE_DEPTH:
130
+ raise BeamNGExpressionUnsupported(
131
+ f"expression exceeds parse depth {MAX_PARSE_DEPTH}")
132
+
133
+ def ascend(self) -> None:
134
+ self.depth -= 1
135
+
136
+ def parse_or(self) -> tuple[Any, ...]:
137
+ node = self.parse_and()
138
+ while self.accept("or"):
139
+ node = ("binary", "or", node, self.parse_and())
140
+ return node
141
+
142
+ def parse_and(self) -> tuple[Any, ...]:
143
+ node = self.parse_compare()
144
+ while self.accept("and"):
145
+ node = ("binary", "and", node, self.parse_compare())
146
+ return node
147
+
148
+ def parse_compare(self) -> tuple[Any, ...]:
149
+ node = self.parse_add()
150
+ if self.current.value in ("==", "~=", "!=", "<", "<=", ">", ">="):
151
+ operator = self.current.value
152
+ self.index += 1
153
+ node = ("binary", operator, node, self.parse_add())
154
+ if self.current.value in ("==", "~=", "!=", "<", "<=", ">", ">="):
155
+ raise BeamNGExpressionUnsupported("chained comparisons are not supported")
156
+ return node
157
+
158
+ def parse_add(self) -> tuple[Any, ...]:
159
+ node = self.parse_multiply()
160
+ while self.current.value in ("+", "-"):
161
+ operator = self.current.value
162
+ self.index += 1
163
+ node = ("binary", operator, node, self.parse_multiply())
164
+ return node
165
+
166
+ def parse_multiply(self) -> tuple[Any, ...]:
167
+ node = self.parse_unary()
168
+ while self.current.value in ("*", "/", "%"):
169
+ operator = self.current.value
170
+ self.index += 1
171
+ node = ("binary", operator, node, self.parse_unary())
172
+ return node
173
+
174
+ def parse_power(self) -> tuple[Any, ...]:
175
+ node = self.parse_primary()
176
+ if self.accept("^"):
177
+ node = ("binary", "^", node, self.parse_unary())
178
+ return node
179
+
180
+ def parse_unary(self) -> tuple[Any, ...]:
181
+ if self.current.value in ("+", "-", "not"):
182
+ operator = self.current.value
183
+ self.index += 1
184
+ return ("unary", operator, self.parse_unary())
185
+ return self.parse_power()
186
+
187
+ def parse_primary(self) -> tuple[Any, ...]:
188
+ token = self.current
189
+ if token.kind == "number":
190
+ self.index += 1
191
+ value = float(token.value)
192
+ if not math.isfinite(value):
193
+ raise BeamNGExpressionUnsupported("numeric literal is not finite")
194
+ return ("literal", value)
195
+ if token.kind == "variable":
196
+ self.index += 1
197
+ return ("variable", token.value)
198
+ if token.kind == "keyword" and token.value in ("nil", "true", "false"):
199
+ self.index += 1
200
+ return ("literal", {"nil": None, "true": True, "false": False}[token.value])
201
+ if token.kind == "identifier":
202
+ self.index += 1
203
+ name = token.value
204
+ if not self.accept("("):
205
+ raise BeamNGExpressionUnsupported(f"unknown symbol {name!r}")
206
+ if name not in _ALLOWED_CALLS:
207
+ raise BeamNGExpressionUnsupported(f"unsupported call {name!r}")
208
+ self.descend()
209
+ try:
210
+ arguments = [self.parse_or()]
211
+ while self.accept(","):
212
+ arguments.append(self.parse_or())
213
+ self.expect(")")
214
+ finally:
215
+ self.ascend()
216
+ if name == "case" and len(arguments) != 3:
217
+ raise BeamNGExpressionUnsupported("case() requires exactly three arguments")
218
+ return ("call", name, tuple(arguments))
219
+ if self.accept("("):
220
+ self.descend()
221
+ try:
222
+ node = self.parse_or()
223
+ self.expect(")")
224
+ finally:
225
+ self.ascend()
226
+ return node
227
+ raise BeamNGExpressionUnsupported(
228
+ f"expected a value at offset {token.offset}, found {token.value!r}")
229
+
230
+
231
+ def _variables(node: tuple[Any, ...], output: set[str]) -> None:
232
+ if node[0] == "variable":
233
+ output.add(node[1])
234
+ elif node[0] == "unary":
235
+ _variables(node[2], output)
236
+ elif node[0] == "binary":
237
+ _variables(node[2], output)
238
+ _variables(node[3], output)
239
+ elif node[0] == "call":
240
+ for argument in node[2]:
241
+ _variables(argument, output)
242
+
243
+
244
+ def _truthy(value: Any) -> bool:
245
+ return value is not None and value is not False
246
+
247
+
248
+ class _Evaluator:
249
+ def __init__(self, variables: Mapping[str, Any]):
250
+ self.variables = variables
251
+ self.steps = 0
252
+
253
+ def evaluate(self, node: tuple[Any, ...]) -> Any:
254
+ self.steps += 1
255
+ if self.steps > MAX_EVALUATION_STEPS:
256
+ raise BeamNGExpressionUnsupported(
257
+ f"expression exceeds {MAX_EVALUATION_STEPS} evaluation steps")
258
+ kind = node[0]
259
+ if kind == "literal":
260
+ return node[1]
261
+ if kind == "variable":
262
+ return self.variables[node[1]]
263
+ if kind == "unary":
264
+ operator, value = node[1], self.evaluate(node[2])
265
+ if operator == "not":
266
+ return not _truthy(value)
267
+ number = self.number(value, operator)
268
+ return number if operator == "+" else -number
269
+ if kind == "call":
270
+ condition = self.evaluate(node[2][0])
271
+ return self.evaluate(node[2][1] if _truthy(condition) else node[2][2])
272
+ operator = node[1]
273
+ left = self.evaluate(node[2])
274
+ if operator == "and":
275
+ return self.evaluate(node[3]) if _truthy(left) else left
276
+ if operator == "or":
277
+ return left if _truthy(left) else self.evaluate(node[3])
278
+ right = self.evaluate(node[3])
279
+ if operator in ("==", "~=", "!="):
280
+ equal = left == right
281
+ return equal if operator == "==" else not equal
282
+ if operator in ("<", "<=", ">", ">="):
283
+ first, second = self.number(left, operator), self.number(right, operator)
284
+ return {"<": first < second, "<=": first <= second,
285
+ ">": first > second, ">=": first >= second}[operator]
286
+ first, second = self.number(left, operator), self.number(right, operator)
287
+ try:
288
+ if operator == "+":
289
+ result = first + second
290
+ elif operator == "-":
291
+ result = first - second
292
+ elif operator == "*":
293
+ result = first * second
294
+ elif operator == "/":
295
+ if second == 0:
296
+ raise BeamNGExpressionUnsupported("division by zero")
297
+ result = first / second
298
+ elif operator == "%":
299
+ if second == 0:
300
+ raise BeamNGExpressionUnsupported("modulo by zero")
301
+ result = first % second
302
+ elif operator == "^":
303
+ if abs(second) > 1000:
304
+ raise BeamNGExpressionUnsupported("exponent exceeds the portable bound")
305
+ # Keep the evaluator in the real-number domain. Python's
306
+ # ``**`` can yield complex values for fractional powers of a
307
+ # negative base, which are not portable BeamNG numerics.
308
+ result = math.pow(first, second)
309
+ else: # parser admits no other binary operator
310
+ raise BeamNGExpressionUnsupported(f"unsupported operator {operator!r}")
311
+ except (OverflowError, ValueError, ZeroDivisionError, TypeError) as error:
312
+ raise BeamNGExpressionUnsupported(f"invalid arithmetic: {error}") from error
313
+ if not math.isfinite(result):
314
+ raise BeamNGExpressionUnsupported("arithmetic result is not finite")
315
+ return result
316
+
317
+ @staticmethod
318
+ def number(value: Any, operator: str) -> float:
319
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
320
+ raise BeamNGExpressionUnsupported(
321
+ f"operator {operator!r} requires finite numeric operands")
322
+ return float(value)
323
+
324
+
325
+ def resolve_beamng_expression(expression: Any,
326
+ selected_variables: Mapping[str, Any]) -> ExpressionResolution:
327
+ """Resolve one literal/expression without executing source code.
328
+
329
+ Numeric literals are returned as ``literal``. Direct ``$variable``
330
+ references and ``$=`` expressions become ``derived`` only when every
331
+ referenced variable is present in the supplied selected configuration
332
+ environment and evaluation stays inside the allowlist. All other values
333
+ return ``unsupported`` with a stable reason.
334
+ """
335
+ if isinstance(expression, (int, float)) and not isinstance(expression, bool):
336
+ if math.isfinite(expression):
337
+ return ExpressionResolution("literal", float(expression), ())
338
+ return ExpressionResolution("unsupported", None, (), "numeric literal is not finite")
339
+ # BeamNG also permits a value to be a direct selected-variable reference,
340
+ # without the ``$=`` expression marker. Route it through the exact same
341
+ # parser/evaluator boundary so missing, non-scalar, and non-finite values
342
+ # fail closed instead of falling back to a JBeam default.
343
+ if isinstance(expression, str) and _VARIABLE.fullmatch(expression):
344
+ expression = f"$={expression}"
345
+ used: set[str] = set()
346
+ try:
347
+ tokens = _tokenize(expression)
348
+ tree = _Parser(tokens).parse()
349
+ _variables(tree, used)
350
+ unknown = sorted(name for name in used if name not in selected_variables)
351
+ if unknown:
352
+ raise BeamNGExpressionUnsupported(
353
+ f"unknown selected variable(s): {', '.join(unknown)}")
354
+ non_scalar = sorted(name for name in used
355
+ if selected_variables[name] is not None
356
+ and not isinstance(selected_variables[name], (int, float, bool)))
357
+ if non_scalar:
358
+ raise BeamNGExpressionUnsupported(
359
+ f"non-scalar selected variable(s): {', '.join(non_scalar)}")
360
+ non_finite = sorted(name for name in used
361
+ if isinstance(selected_variables[name], float)
362
+ and not math.isfinite(selected_variables[name]))
363
+ if non_finite:
364
+ raise BeamNGExpressionUnsupported(
365
+ f"non-finite selected variable(s): {', '.join(non_finite)}")
366
+ value = _Evaluator(selected_variables).evaluate(tree)
367
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
368
+ value = float(value)
369
+ if not math.isfinite(value):
370
+ raise BeamNGExpressionUnsupported("expression result is not finite")
371
+ return ExpressionResolution("derived", value, tuple(sorted(used)))
372
+ except BeamNGExpressionUnsupported as error:
373
+ return ExpressionResolution("unsupported", None, tuple(sorted(used)), str(error))
@@ -22,8 +22,9 @@ from beamng_jbeam import (JBeamDocumentCache, MAX_SELECTED_PART_INDEX_BYTES,
22
22
  SELECTED_PART_INDEX_SCHEMA, build_part_catalog,
23
23
  bounded_asset_paths, decode_jbeam_table,
24
24
  resolve_part_closure)
25
+ from beamng_expression import ExpressionResolution, resolve_beamng_expression
25
26
 
26
- SCHEMA = "helix.beamng-functional-lowering/1"
27
+ SCHEMA = "helix.beamng-functional-lowering/2"
27
28
  SOURCE_SCHEMA = "helix.beamng-vehicle-source/1"
28
29
  AUDIO_SLOTS = [
29
30
  "engineLadder", "exhaustLadder", "idle", "startup", "shutdown", "shiftUp", "shiftDown", "clutch",
@@ -527,6 +528,515 @@ def sourced(value: Any, unit: str, source: str | None = None, note: str | None =
527
528
  return row
528
529
 
529
530
 
531
+ BRAKE_AXLE_PATTERN = re.compile(r"(?:^|_)brakes?_?([FR])(?:_|$)", re.I)
532
+ SUSPENSION_AXLE_PATTERN = re.compile(r"(?:^|_)(?:strut|coilover)_?([FR])(?:_|$)", re.I)
533
+ SWAYBAR_AXLE_PATTERN = re.compile(r"(?:^|_)(?:rally_)?swaybar_?([FR])(?:_|$)", re.I)
534
+
535
+
536
+ def selected_variable_environment(selected: Iterable[tuple[str, str, dict[str, Any], Path]],
537
+ config: dict[str, Any], config_path: Path,
538
+ label_source: Callable[[Path], str]) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
539
+ """Resolve the selected JBeam defaults, then exact `.pc` variable overrides."""
540
+ values: dict[str, Any] = {}
541
+ receipts: dict[str, dict[str, Any]] = {}
542
+ for _, part_name, body, path in selected:
543
+ for row in decode_jbeam_table(body.get("variables")):
544
+ name, default = row.get("name"), row.get("default")
545
+ if not isinstance(name, str) or not re.fullmatch(r"\$[A-Za-z_][A-Za-z0-9_]*", name):
546
+ continue
547
+ if isinstance(default, float) and not math.isfinite(default):
548
+ continue
549
+ values[name] = default
550
+ receipts[name] = {"value": default, "selection": "selected-default",
551
+ "source": f"{label_source(path)}#{part_name}.variables.{name}.default"}
552
+ overrides = config.get("vars")
553
+ if isinstance(overrides, dict):
554
+ for name in sorted(overrides):
555
+ value = overrides[name]
556
+ if not isinstance(name, str) or not re.fullmatch(r"\$[A-Za-z_][A-Za-z0-9_]*", name):
557
+ continue
558
+ # Keep unsupported scalar/object overrides in the environment so
559
+ # they fail the exact expression rather than silently falling back
560
+ # to the JBeam default they override.
561
+ values[name] = value
562
+ receipt = {"value": value, "selection": "configuration-override",
563
+ "source": f"{config_path.name}#vars.{name}"}
564
+ if isinstance(value, float) and not math.isfinite(value):
565
+ # JSON has no portable non-finite number representation. Keep
566
+ # the evaluator's exact value, but serialize an explicit safe
567
+ # receipt instead of emitting invalid terminal JSON.
568
+ receipt.update({"value": None, "raw": repr(value), "status": "unsupported"})
569
+ receipts[name] = receipt
570
+ return values, receipts
571
+
572
+
573
+ def pressure_wheel_options(body: dict[str, Any]) -> dict[str, Any]:
574
+ """Merge only section-level option dictionaries, never anonymous wheel rows."""
575
+ section = body.get("pressureWheels")
576
+ if not isinstance(section, list):
577
+ return {}
578
+ options: dict[str, Any] = {}
579
+ for row in section[1:]:
580
+ if isinstance(row, dict):
581
+ options.update(row)
582
+ return options
583
+
584
+
585
+ def selected_part_axle(pattern: re.Pattern[str], slot_name: str, part_name: str,
586
+ body: dict[str, Any]) -> str | None:
587
+ matches: set[str] = set()
588
+ for label in (slot_name, body.get("slotType"), part_name):
589
+ if not isinstance(label, str):
590
+ continue
591
+ match = pattern.search(label)
592
+ if match:
593
+ matches.add("front" if match.group(1).lower() == "f" else "rear")
594
+ return next(iter(matches)) if len(matches) == 1 else None
595
+
596
+
597
+ def numeric_resolution(value: Any, variables: dict[str, Any]) -> ExpressionResolution:
598
+ result = resolve_beamng_expression(value, variables)
599
+ if result.status != "unsupported" and (isinstance(result.value, bool)
600
+ or not isinstance(result.value, (int, float))
601
+ or not math.isfinite(result.value)):
602
+ return ExpressionResolution("unsupported", None, result.variables,
603
+ "expression did not resolve to a finite number")
604
+ if result.status != "unsupported":
605
+ return ExpressionResolution(result.status, round(float(result.value), 12),
606
+ result.variables, result.reason)
607
+ return result
608
+
609
+
610
+ def lower_selected_brakes(selected: list[tuple[str, str, dict[str, Any], Path]],
611
+ variables: dict[str, Any], variable_receipts: dict[str, dict[str, Any]],
612
+ label_source: Callable[[Path], str], citation_id: str,
613
+ provenance: list[dict[str, Any]]) -> tuple[dict[str, Any], dict[str, Any], bool]:
614
+ """Lower the two selected brake-part option blocks into HELIX totals.
615
+
616
+ BeamNG's torque values apply to each wheel in the part-owned axle section;
617
+ HELIX's values are totals across the car. Front bias is therefore derived
618
+ from the per-wheel front/rear values before both axles are doubled.
619
+ """
620
+ candidates: dict[str, list[dict[str, Any]]] = {"front": [], "rear": []}
621
+ unowned: list[dict[str, Any]] = []
622
+ for slot_name, part_name, body, path in selected:
623
+ options = pressure_wheel_options(body)
624
+ if "brakeTorque" not in options and "parkingTorque" not in options:
625
+ continue
626
+ axle = selected_part_axle(BRAKE_AXLE_PATTERN, slot_name, part_name, body)
627
+ receipt = {"slotType": slot_name, "partName": part_name,
628
+ "source": f"{label_source(path)}#{part_name}.pressureWheels",
629
+ "options": {key: options[key] for key in ("brakeTorque", "brakeInputSplit",
630
+ "brakeSplitCoef", "parkingTorque")
631
+ if key in options}}
632
+ if axle is None:
633
+ # Suspension defaults deliberately clear inherited brake options;
634
+ # they do not own an axle brake Package and cannot override the
635
+ # exact selected brake part.
636
+ if "brake" in slot_name.lower() or "brake" in part_name.lower():
637
+ unowned.append(receipt)
638
+ continue
639
+ candidates[axle].append(receipt)
640
+
641
+ axles: dict[str, dict[str, Any]] = {}
642
+ complete = True
643
+ for axle in ("front", "rear"):
644
+ rows = candidates[axle]
645
+ if len(rows) != 1:
646
+ complete = False
647
+ detail = f"Expected one selected {axle} brake part; found {len(rows)}."
648
+ provenance.append({"target": f"physics.brakes.{axle}",
649
+ "source": "selected JBeam pressureWheels",
650
+ "disposition": "unsupported", "detail": detail})
651
+ axles[axle] = {"status": "unsupported", "reason": detail,
652
+ "candidates": rows}
653
+ continue
654
+ receipt = rows[0]
655
+ options = receipt["options"]
656
+ fields: dict[str, Any] = {}
657
+ for field in ("brakeTorque", "parkingTorque"):
658
+ result = numeric_resolution(options.get(field), variables)
659
+ disposition = "source-literal" if result.status == "literal" else result.status
660
+ row: dict[str, Any] = {"raw": options.get(field), "status": result.status,
661
+ "variables": list(result.variables)}
662
+ if result.status != "unsupported":
663
+ row["value"] = result.value
664
+ else:
665
+ row["reason"] = result.reason
666
+ complete = False
667
+ fields[field] = row
668
+ provenance.append({"target": f"sourceSemantics.brakes.axles.{axle}.{field}",
669
+ "source": receipt["source"], "disposition": disposition,
670
+ "detail": result.reason or
671
+ (f"Selected literal {result.value:g}." if result.status == "literal" else
672
+ f"Resolved {options.get(field)!r} to {result.value:g} from selected variables.")})
673
+ for field in ("brakeInputSplit", "brakeSplitCoef"):
674
+ if field in options:
675
+ fields[field] = {"raw": options[field], "status": "preserved-source"}
676
+ axle_row = {key: value for key, value in receipt.items() if key != "options"}
677
+ axle_row.update(fields)
678
+ axles[axle] = axle_row
679
+
680
+ front_torque = axles.get("front", {}).get("brakeTorque", {}).get("value")
681
+ rear_torque = axles.get("rear", {}).get("brakeTorque", {}).get("value")
682
+ front_parking = axles.get("front", {}).get("parkingTorque", {}).get("value")
683
+ rear_parking = axles.get("rear", {}).get("parkingTorque", {}).get("value")
684
+ complete = complete and all(isinstance(value, (int, float)) and not isinstance(value, bool)
685
+ for value in (front_torque, rear_torque, front_parking, rear_parking))
686
+ if complete:
687
+ complete = bool(front_torque > 0 and rear_torque > 0
688
+ and front_parking >= 0 and rear_parking >= 0
689
+ and front_parking + rear_parking > 0)
690
+ if not complete:
691
+ provenance.append({"target": "physics.brakes", "source": "selected JBeam pressureWheels",
692
+ "disposition": "unsupported",
693
+ "detail": "Selected brake torque must be positive on both axles and parking torque must be non-negative and present on at least one axle."})
694
+
695
+ if complete:
696
+ per_wheel_total = float(front_torque + rear_torque)
697
+ max_torque = round(per_wheel_total * 2, 6)
698
+ bias_front = round(float(front_torque) / per_wheel_total, 8)
699
+ parking_by_axle = {"front": float(front_parking), "rear": float(rear_parking)}
700
+ active_parking = [axle for axle, torque in parking_by_axle.items() if torque > 0]
701
+ handbrake_axle = active_parking[0] if len(active_parking) == 1 else "all"
702
+ handbrake_torque = round(sum(parking_by_axle.values()) * 2, 6)
703
+ brakes = {
704
+ "maxTorque": sourced(max_torque, "N.m", citation_id,
705
+ "Twice the sum of selected per-wheel front and rear brake torque."),
706
+ "biasFront": sourced(bias_front, "ratio", citation_id,
707
+ "Selected front per-wheel torque divided by front plus rear per-wheel torque."),
708
+ "handbrakeTorque": sourced(handbrake_torque, "N.m", citation_id,
709
+ "Twice the selected per-wheel parking torque across its active axle(s)."),
710
+ "handbrakeAxle": handbrake_axle,
711
+ }
712
+ for target, detail in (
713
+ ("physics.brakes.maxTorque", f"2 * ({front_torque:g} + {rear_torque:g}) = {max_torque:g} N.m."),
714
+ ("physics.brakes.biasFront", f"{front_torque:g} / ({front_torque:g} + {rear_torque:g}) = {bias_front:g}."),
715
+ ("physics.brakes.handbrakeTorque", f"2 * ({front_parking:g} + {rear_parking:g}) = {handbrake_torque:g} N.m on {handbrake_axle}."),
716
+ ):
717
+ provenance.append({"target": target, "source": "selected JBeam brake-part pressureWheels rows",
718
+ "disposition": "derived", "detail": detail})
719
+ else:
720
+ brakes = {
721
+ "maxTorque": sourced(3900, "N.m", note="HELIX functional fallback; selected brake rows were incomplete or unsupported."),
722
+ "biasFront": sourced(.6923, "ratio", note="HELIX functional fallback; selected axle torque was incomplete or unsupported."),
723
+ "handbrakeTorque": sourced(1800, "N.m", note="HELIX functional fallback; selected parking torque was incomplete or unsupported."),
724
+ "handbrakeAxle": "rear",
725
+ }
726
+ provenance.append({"target": "physics.brakes", "source": "HELIX functional fallback",
727
+ "disposition": "defaulted",
728
+ "detail": "Selected front/rear brake and parking torque rows did not all resolve; brake domain remains non-functional."})
729
+
730
+ used_variables = sorted({name for row in axles.values() if isinstance(row, dict)
731
+ for field in ("brakeTorque", "parkingTorque")
732
+ for name in row.get(field, {}).get("variables", [])})
733
+ semantics = {"variables": {name: variable_receipts[name] for name in used_variables
734
+ if name in variable_receipts},
735
+ "axles": axles, "unownedPressureWheelOptions": sorted(
736
+ unowned, key=lambda row: (row["slotType"], row["partName"], row["source"]))}
737
+ return brakes, semantics, complete
738
+
739
+
740
+ def suspension_row_side(row: dict[str, Any]) -> str | None:
741
+ labels = [row.get("name"), row.get("id1"), row.get("id2")]
742
+ sides: set[str] = set()
743
+ for label in labels:
744
+ if not isinstance(label, str):
745
+ continue
746
+ match = re.search(r"(?:_|[0-9])([LR])$", label, re.I)
747
+ if match:
748
+ sides.add("left" if match.group(1).lower() == "l" else "right")
749
+ return next(iter(sides)) if len(sides) == 1 else None
750
+
751
+
752
+ def resolution_receipt(raw: Any, variables: dict[str, Any], unit: str,
753
+ 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.")
765
+ provenance.append({"target": target, "source": source,
766
+ "disposition": disposition, "detail": detail})
767
+ return row
768
+
769
+
770
+ def symmetric_suspension_field(rows: list[dict[str, Any]], field: str,
771
+ variables: dict[str, Any], unit: str, source: str,
772
+ provenance: list[dict[str, Any]], target: str,
773
+ positive: bool = False,
774
+ require_velocity_split: bool = False) -> dict[str, Any]:
775
+ """Collapse only a matching left/right pair from one selected leaf part."""
776
+ by_side: dict[str, list[tuple[Any, ExpressionResolution, dict[str, Any]]]] = {
777
+ "left": [], "right": []}
778
+ unsupported: list[ExpressionResolution] = []
779
+ unsupported_variables: set[str] = set()
780
+ for row in rows:
781
+ if field not in row:
782
+ continue
783
+ raw = row[field]
784
+ result = numeric_resolution(raw, variables)
785
+ if result.status == "unsupported":
786
+ unsupported.append(result)
787
+ unsupported_variables.update(result.variables)
788
+ continue
789
+ if positive and not (result.value > 0):
790
+ continue
791
+ side = suspension_row_side(row)
792
+ if side:
793
+ by_side[side].append((raw, result, row))
794
+ reason = None
795
+ chosen: list[tuple[Any, ExpressionResolution, dict[str, Any]]] = []
796
+ if unsupported:
797
+ reason = unsupported[0].reason
798
+ else:
799
+ for side in ("left", "right"):
800
+ values = by_side[side]
801
+ if len(values) != 1:
802
+ reason = f"expected one selected {side} {field} row; found {len(values)}"
803
+ break
804
+ chosen.append(values[0])
805
+ if reason is None and chosen[0][1].value != chosen[1][1].value:
806
+ reason = f"selected left/right {field} values do not match"
807
+ velocity_split = None
808
+ if reason is None and require_velocity_split:
809
+ split_rows = []
810
+ for _, _, row in chosen:
811
+ if "beamDampVelocitySplit" not in row:
812
+ reason = f"selected {field} rows are missing beamDampVelocitySplit"
813
+ break
814
+ split_rows.append((row["beamDampVelocitySplit"],
815
+ numeric_resolution(row["beamDampVelocitySplit"], variables)))
816
+ if reason is None:
817
+ unsupported_split = next((result for _, result in split_rows
818
+ if result.status == "unsupported"), None)
819
+ if unsupported_split:
820
+ unsupported_variables.update(unsupported_split.variables)
821
+ reason = unsupported_split.reason
822
+ elif split_rows[0][1].value != split_rows[1][1].value:
823
+ reason = f"selected left/right {field} beamDampVelocitySplit values do not match"
824
+ elif not isinstance(split_rows[0][1].value, (int, float)):
825
+ reason = f"selected {field} beamDampVelocitySplit is not numeric"
826
+ elif split_rows[0][1].value < 0:
827
+ reason = f"selected {field} beamDampVelocitySplit must be non-negative"
828
+ else:
829
+ velocity_split = split_rows[0]
830
+ if reason is not None:
831
+ receipt = {"status": "unsupported", "unit": unit, "reason": reason,
832
+ "variables": sorted(unsupported_variables)}
833
+ provenance.append({"target": target, "source": source,
834
+ "disposition": "unsupported", "detail": reason})
835
+ return receipt
836
+ raw = chosen[0][0]
837
+ result = chosen[0][1]
838
+ receipt = {"raw": raw, "status": result.status, "value": result.value,
839
+ "unit": unit, "variables": list(result.variables), "symmetricRows": 2}
840
+ if velocity_split is not None:
841
+ split_raw, split_result = velocity_split
842
+ receipt["velocitySplit"] = {
843
+ "raw": split_raw, "status": split_result.status, "value": split_result.value,
844
+ "unit": "m/s", "variables": list(split_result.variables), "symmetricRows": 2,
845
+ }
846
+ receipt["variables"] = sorted(set(receipt["variables"]) | set(split_result.variables))
847
+ disposition = "source-literal" if result.status == "literal" else "derived"
848
+ provenance.append({"target": target, "source": source, "disposition": disposition,
849
+ "detail": (f"Matching selected left/right rows resolve to {result.value:g} {unit}.")})
850
+ return receipt
851
+
852
+
853
+ def lower_selected_suspension(selected: list[tuple[str, str, dict[str, Any], Path]],
854
+ variables: dict[str, Any], variable_receipts: dict[str, dict[str, Any]],
855
+ label_source: Callable[[Path], str],
856
+ chassis_mass: float, weight_front: float,
857
+ provenance: list[dict[str, Any]]) -> tuple[dict[str, Any], dict[str, Any]]:
858
+ """Extract selected strut/coilover semantics without treating structure as suspension.
859
+
860
+ BeamNG authors spring and velocity-sensitive damper beams at the selected
861
+ strut. HELIX currently authors wheel rate plus one global slow damper
862
+ triplet. The exact source values and their beam-space derivations are
863
+ retained here, while the runtime projection remains fail-closed until a
864
+ motion ratio and axle-specific fast/slow damping contract are available.
865
+ """
866
+ leaves: dict[str, list[tuple[str, str, dict[str, Any], Path]]] = {"front": [], "rear": []}
867
+ excluded: list[dict[str, Any]] = []
868
+ anti_roll_candidates: dict[str, list[tuple[str, str, dict[str, Any], Path]]] = {
869
+ "front": [], "rear": []}
870
+ for slot_name, part_name, body, path in selected:
871
+ axle = selected_part_axle(SUSPENSION_AXLE_PATTERN, slot_name, part_name, body)
872
+ if axle:
873
+ leaves[axle].append((slot_name, part_name, body, path))
874
+ continue
875
+ sway_axle = selected_part_axle(SWAYBAR_AXLE_PATTERN, slot_name, part_name, body)
876
+ if sway_axle:
877
+ anti_roll_candidates[sway_axle].append((slot_name, part_name, body, path))
878
+ label = " ".join(str(value) for value in (slot_name, body.get("slotType"), part_name)).lower()
879
+ if not any(token in label for token in ("suspension", "strut", "swaybar")):
880
+ continue
881
+ structural_rows = [row for row in decode_jbeam_table(body.get("beams"))
882
+ if "beamSpring" in row or "beamDamp" in row]
883
+ if structural_rows:
884
+ reason = ("swaybar construction beams do not own anti-roll stiffness" if sway_axle else
885
+ "not selected through a strut/coilover leaf slot")
886
+ excluded.append({"slotType": slot_name, "partName": part_name,
887
+ "source": f"{label_source(path)}#{part_name}.beams",
888
+ "beamRows": len(structural_rows), "reason": reason})
889
+
890
+ axles: dict[str, dict[str, Any]] = {}
891
+ used_variables: set[str] = set()
892
+ extraction_complete = True
893
+ for axle in ("front", "rear"):
894
+ candidates = leaves[axle]
895
+ if len(candidates) != 1:
896
+ reason = f"Expected one selected {axle} strut/coilover leaf part; found {len(candidates)}."
897
+ provenance.append({"target": f"sourceSemantics.suspension.axles.{axle}",
898
+ "source": "selected JBeam part custody",
899
+ "disposition": "unsupported", "detail": reason})
900
+ axles[axle] = {"status": "unsupported", "reason": reason,
901
+ "candidates": [part for _, part, _, _ in candidates]}
902
+ extraction_complete = False
903
+ continue
904
+ slot_name, part_name, body, path = candidates[0]
905
+ source = f"{label_source(path)}#{part_name}.beams"
906
+ beams = decode_jbeam_table(body.get("beams"))
907
+ variable_springs = [row for row in beams if isinstance(row.get("beamSpring"), str)
908
+ and "precompressionRange" in row
909
+ and row["beamSpring"].startswith("$")]
910
+ literal_springs = [] if variable_springs else [
911
+ row for row in beams if isinstance(row.get("beamSpring"), (int, float))
912
+ and not isinstance(row.get("beamSpring"), bool)
913
+ and row["beamSpring"] > 0 and row.get("beamDamp") == 0
914
+ and "precompressionRange" in row]
915
+ spring_rows = variable_springs or literal_springs
916
+ spring = symmetric_suspension_field(
917
+ spring_rows, "beamSpring", variables, "N/m", source, provenance,
918
+ f"sourceSemantics.suspension.axles.{axle}.springRate", positive=True)
919
+ precompression = symmetric_suspension_field(
920
+ spring_rows, "precompressionRange", variables, "m", source, provenance,
921
+ f"sourceSemantics.suspension.axles.{axle}.precompressionRange")
922
+ damper_rows = [row for row in beams if row.get("beamSpring") == 0
923
+ and ("beamDampVelocitySplit" in row
924
+ or re.match(r"^(?:shock|damper)_", str(row.get("name", "")), re.I))]
925
+ damping = {}
926
+ for output_name, field in (("slowBump", "beamDamp"), ("fastBump", "beamDampFast"),
927
+ ("slowRebound", "beamDampRebound"),
928
+ ("fastRebound", "beamDampReboundFast")):
929
+ damping[output_name] = symmetric_suspension_field(
930
+ damper_rows, field, variables, "N.s/m", source, provenance,
931
+ f"sourceSemantics.suspension.axles.{axle}.damping.{output_name}",
932
+ positive=True, require_velocity_split=True)
933
+ travel_rows = [row for row in beams if row.get("beamSpring") == 0 and row.get("beamDamp") == 0
934
+ and not row.get("name") and "beamDampVelocitySplit" not in row
935
+ and isinstance(row.get("shortBoundRange"), (int, float))
936
+ and 0 < row["shortBoundRange"] <= .5]
937
+ bump_limit = symmetric_suspension_field(
938
+ travel_rows, "shortBoundRange", variables, "m", source, provenance,
939
+ f"sourceSemantics.suspension.axles.{axle}.bumpLimit", positive=True)
940
+ required = [spring, precompression, bump_limit, *damping.values()]
941
+ axle_complete = not any(row.get("status") == "unsupported" for row in required)
942
+ if not axle_complete:
943
+ extraction_complete = False
944
+ for row in required:
945
+ used_variables.update(row.get("variables", []))
946
+ derived: dict[str, Any] = {}
947
+ if spring.get("status") != "unsupported":
948
+ corner_mass = chassis_mass * (weight_front if axle == "front" else 1 - weight_front) / 2
949
+ critical = 2 * math.sqrt(float(spring["value"]) * corner_mass)
950
+ derived = {
951
+ "cornerMassKg": round(corner_mass, 6),
952
+ "beamNaturalHz": round(math.sqrt(float(spring["value"]) / corner_mass) / (2 * math.pi), 8),
953
+ "criticalDampingNsPerM": round(critical, 6),
954
+ }
955
+ for output_name, ratio_name in (("slowBump", "slowBumpRatio"),
956
+ ("fastBump", "fastBumpRatio"),
957
+ ("slowRebound", "slowReboundRatio"),
958
+ ("fastRebound", "fastReboundRatio")):
959
+ value = damping[output_name].get("value")
960
+ if isinstance(value, (int, float)):
961
+ derived[ratio_name] = round(float(value) / critical, 8)
962
+ provenance.append({"target": f"sourceSemantics.suspension.axles.{axle}.derived",
963
+ "source": source, "disposition": "derived",
964
+ "detail": "Beam natural frequency and damping ratios derived from selected beam rates and the emitted HELIX corner mass; not claimed as wheel-space ride values without motion ratio."})
965
+ axles[axle] = {"status": "resolved" if axle_complete else "unsupported",
966
+ "slotType": slot_name, "partName": part_name, "source": source,
967
+ "springRate": spring, "precompressionRange": precompression,
968
+ "damping": damping, "bumpLimit": bump_limit, "derived": derived,
969
+ "rowCounts": {"spring": len(spring_rows), "damper": len(damper_rows),
970
+ "travelLimit": len(travel_rows)}}
971
+
972
+ anti_roll: dict[str, Any] = {}
973
+ for axle in ("front", "rear"):
974
+ candidates = anti_roll_candidates[axle]
975
+ if len(candidates) != 1:
976
+ anti_roll[axle] = {"status": "unsupported",
977
+ "reason": f"expected one selected {axle} swaybar part; found {len(candidates)}"}
978
+ extraction_complete = False
979
+ continue
980
+ slot_name, part_name, body, path = candidates[0]
981
+ source = f"{label_source(path)}#{part_name}.torsionbars"
982
+ torsion_rows = [row for row in decode_jbeam_table(body.get("torsionbars")) if "spring" in row]
983
+ if len(torsion_rows) == 1:
984
+ torsion = resolution_receipt(torsion_rows[0]["spring"], variables, "N.m/rad", source,
985
+ provenance, f"sourceSemantics.suspension.antiRoll.{axle}.torsionSpring")
986
+ used_variables.update(torsion.get("variables", []))
987
+ else:
988
+ reason = f"expected one selected torsionbar spring row; found {len(torsion_rows)}"
989
+ torsion = {"status": "unsupported", "unit": "N.m/rad", "reason": reason,
990
+ "variables": []}
991
+ provenance.append({"target": f"sourceSemantics.suspension.antiRoll.{axle}.torsionSpring",
992
+ "source": source, "disposition": "unsupported", "detail": reason})
993
+ if torsion.get("status") == "unsupported":
994
+ extraction_complete = False
995
+ anti_roll[axle] = {"slotType": slot_name, "partName": part_name, "source": source,
996
+ "torsionSpring": torsion, "runtimeStatus": "unsupported",
997
+ "reason": "BeamNG torsional stiffness requires selected lever-arm and wheel motion-ratio geometry before HELIX N/m conversion."}
998
+
999
+ runtime_blockers = [
1000
+ "Selected beam spring rate requires wheel motion ratio before it is a HELIX wheel-space ride frequency.",
1001
+ "HELIX PackageSuspension has one global damping triplet and no axle-specific fast/slow velocity split.",
1002
+ "BeamNG strut bounds and precompression require suspension motion geometry before HELIX static ride height/travel conversion.",
1003
+ "BeamNG torsionbar stiffness requires lever-arm and wheel motion-ratio geometry before HELIX anti-roll N/m conversion.",
1004
+ ]
1005
+ for target, detail in (("rideHzFront", runtime_blockers[0]), ("rideHzRear", runtime_blockers[0]),
1006
+ ("dampingRatio", runtime_blockers[1]), ("bumpScale", runtime_blockers[1]),
1007
+ ("reboundScale", runtime_blockers[1]), ("staticRideHeight", runtime_blockers[2]),
1008
+ ("bumpTravel", runtime_blockers[2]), ("antiRollBarFront", runtime_blockers[3]),
1009
+ ("antiRollBarRear", runtime_blockers[3])):
1010
+ provenance.append({"target": f"physics.suspension.{target}",
1011
+ "source": "selected JBeam suspension semantics",
1012
+ "disposition": "unsupported", "detail": detail})
1013
+ suspension = {
1014
+ "rideHzFront": sourced(1.8, "Hz", note="HELIX sports prior; selected beam rate lacks wheel motion ratio."),
1015
+ "rideHzRear": sourced(1.8, "Hz", note="HELIX sports prior; selected beam rate lacks wheel motion ratio."),
1016
+ "dampingRatio": sourced(.45, "ratio", note="HELIX sports prior; selected axle fast/slow damping cannot fit the global runtime field exactly."),
1017
+ "bumpScale": sourced(1, "ratio", note="HELIX solver prior; selected axle damping is retained in source semantics."),
1018
+ "reboundScale": sourced(1, "ratio", note="HELIX solver prior; selected axle damping is retained in source semantics."),
1019
+ "staticRideHeight": sourced(.34, "m", note="Wheel-radius-derived prior; selected strut geometry does not yet provide wheel-space ride height."),
1020
+ "bumpTravel": sourced(.1, "m", note="HELIX sports prior; selected strut bound is retained in beam space."),
1021
+ "antiRollBarFront": sourced(0, "N/m", note="HELIX off fallback; selected torsionbar lacks exact wheel-space conversion."),
1022
+ "antiRollBarRear": sourced(0, "N/m", note="HELIX off fallback; selected torsionbar lacks exact wheel-space conversion."),
1023
+ }
1024
+ provenance.append({"target": "physics.suspension", "source": "HELIX functional fallback",
1025
+ "disposition": "defaulted",
1026
+ "detail": "Exact selected leaf semantics were retained, but runtime suspension remains non-functional until every wheel-space blocker is resolved."})
1027
+ counts = {"selectedLeafParts": sum(len(rows) for rows in leaves.values()),
1028
+ "excludedStructuralParts": len(excluded),
1029
+ "resolvedAxles": sum(row.get("status") != "unsupported" for row in axles.values()),
1030
+ "sourceRows": sum(sum(row.get("rowCounts", {}).values()) for row in axles.values())}
1031
+ semantics = {"variables": {name: variable_receipts[name] for name in sorted(used_variables)
1032
+ if name in variable_receipts},
1033
+ "axles": axles, "antiRoll": anti_roll,
1034
+ "excludedStructuralParts": sorted(excluded, key=lambda row: (row["slotType"], row["partName"])),
1035
+ "runtimeBlockers": runtime_blockers, "extractionComplete": extraction_complete,
1036
+ "counts": counts}
1037
+ return suspension, semantics
1038
+
1039
+
530
1040
  def diff(kind: str, ratio: float, preload: float, lock: float, reverse: float, torque_split: float = .5,
531
1041
  friction: float = 0, dynamic: float = 0, lock_torque: float = 0,
532
1042
  viscous_coef: float = 0, viscous_torque: float = 0) -> dict[str, Any]:
@@ -573,6 +1083,8 @@ def main() -> None:
573
1083
  config = json.loads(config_path.read_text())
574
1084
  if config.get("format") != 2 or not isinstance(config.get("parts"), dict):
575
1085
  fail("configuration must be BeamNG .pc format 2 with a parts object")
1086
+ if "vars" in config and not isinstance(config["vars"], dict):
1087
+ fail("configuration vars must be an object when present")
576
1088
  config_model = config.get("model") or config.get("mainPartName") or candidate
577
1089
  if config_model != candidate:
578
1090
  fail("configuration model/mainPartName does not match vehicle-source candidate")
@@ -906,6 +1418,18 @@ def main() -> None:
906
1418
  shift_delay, shift_key = number(value_index, ["transmissionShiftDelay", "shiftDelay"], .25)
907
1419
 
908
1420
  source_ref = citation_id
1421
+ selected_variables, selected_variable_receipts = selected_variable_environment(
1422
+ selected, config, config_path, source_label)
1423
+ brakes, brake_semantics, brakes_functional = lower_selected_brakes(
1424
+ selected, selected_variables, selected_variable_receipts,
1425
+ source_label, citation_id, provenance)
1426
+ chassis_mass = (float(config_info["Weight"])
1427
+ if isinstance(config_info.get("Weight"), (int, float)) else
1428
+ source_mass if source_mass > 100 else 1365.0)
1429
+ weight_distribution_front = .58
1430
+ suspension, suspension_semantics = lower_selected_suspension(
1431
+ selected, selected_variables, selected_variable_receipts, source_label,
1432
+ chassis_mass, weight_distribution_front, provenance)
909
1433
  turbo_parts = [(name, body, path) for _, name, body, path in selected if isinstance(body.get("turbocharger"), dict)]
910
1434
  wastegate_psi = None
911
1435
  for _, name, body, _ in selected:
@@ -1096,7 +1620,7 @@ def main() -> None:
1096
1620
  "Selected configuration info curb mass." if isinstance(config_info.get("Weight"), (int, float)) else None)
1097
1621
  if isinstance(config_info.get("Weight"), (int, float)) else sourced(source_mass, "kg", source_ref if source_mass > 100 else None,
1098
1622
  "Sum of unique selected literal JBeam nodeWeight values; missing common selections remain visible in coverage." if source_mass > 100 else "Explicit fallback pending BeamNG node-mass accumulation."),
1099
- "weightDistributionFront": sourced(.58, "ratio", note="Explicit sports-sedan prior."), "centreOfMassHeight": sourced(.52, "m", note="Explicit sports-sedan prior."),
1623
+ "weightDistributionFront": sourced(weight_distribution_front, "ratio", note="Explicit sports-sedan prior."), "centreOfMassHeight": sourced(.52, "m", note="Explicit sports-sedan prior."),
1100
1624
  "wheelbase": sourced(wheelbase, "m", source_ref if sockets else None, "Measured from Blender wheel sockets." if sockets else "Geometry socket derivation unavailable in compact fixture."),
1101
1625
  "trackFront": sourced(track_front, "m", source_ref if sockets else None, "Measured from Blender wheel sockets." if sockets else "Geometry socket derivation unavailable in compact fixture."),
1102
1626
  "trackRear": sourced(track_rear, "m", source_ref if sockets else None, "Measured from Blender wheel sockets." if sockets else "Geometry socket derivation unavailable in compact fixture."), "length": sourced(length, "m", None if not args.geometry else source_ref),
@@ -1117,18 +1641,13 @@ def main() -> None:
1117
1641
  "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),
1118
1642
  "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.")},
1119
1643
  "driveBiasFront": sourced(.35, "ratio", note="1 - selected centre differential rear torque split."),
1120
- "brakes": {"maxTorque": sourced(3900, "N.m", note="BeamNG brake expressions are retained as unsupported rather than silently evaluated."),
1121
- "biasFront": sourced(.6923, "ratio", note="2700/(2700+1200) from selected BeamNG brake expressions."), "handbrakeTorque": sourced(1800, "N.m", note="HELIX fallback."), "handbrakeAxle": "rear"},
1644
+ "brakes": brakes,
1122
1645
  "aero": {"dragCoefficient": sourced(.32, "ratio", note="Explicit sports-sedan prior."), "frontalArea": sourced(2.2, "m2", note="width*height*shape factor prior."),
1123
1646
  "airDensity": sourced(1.225, "kg/m3", note="ISA sea-level standard."), "downforceCoefficient": sourced(0, "ratio", note="No selected literal aerodynamic coefficient.")},
1124
1647
  "grip": {"peak": sourced(1.1, "ratio", note="HELIX tyre-model prior."), "sliding": sourced(.85, "ratio", note="HELIX tyre-model prior."),
1125
1648
  "lateralMultiplier": sourced(1, "ratio", note="HELIX tyre-model prior."), "biasFront": sourced(.5, "ratio", note="Neutral prior."),
1126
1649
  "loadSensitivityExponent": sourced(.85, "ratio", note="HELIX tyre-model prior.")},
1127
- "suspension": {"rideHzFront": sourced(1.8, "Hz", note="Selected spring literals require corner mass to convert; sports prior used."),
1128
- "rideHzRear": sourced(1.8, "Hz", note="Selected spring literals require corner mass to convert; sports prior used."), "dampingRatio": sourced(.45, "ratio", note="HELIX sports prior."),
1129
- "bumpScale": sourced(1, "ratio", note="HELIX solver prior."), "reboundScale": sourced(1, "ratio", note="HELIX solver prior."),
1130
- "staticRideHeight": sourced(.34, "m", note="Wheel-radius-derived prior."), "bumpTravel": sourced(.1, "m", note="HELIX sports prior."),
1131
- "antiRollBarFront": sourced(1, "ratio", note="Source torsion bars require model conversion."), "antiRollBarRear": sourced(1, "ratio", note="Source torsion bars require model conversion.")},
1650
+ "suspension": suspension,
1132
1651
  "steering": {"roadWheelLockDeg": sourced(34, "deg", note="Steering-wheel 510 degree source value needs rack ratio; road-wheel prior retained."),
1133
1652
  "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.")},
1134
1653
  "linearDamping": sourced(.02, "ratio", note="HELIX solver stability prior."), "angularDamping": sourced(.08, "ratio", note="HELIX solver stability prior.")}
@@ -1216,10 +1735,12 @@ def main() -> None:
1216
1735
  for _, name, body, _ in selected for section in ("cameraChase", "cameraExternal", "camerasInternal")
1217
1736
  if isinstance(body.get(section), list)],
1218
1737
  "brakes": {"selectedParts": sorted(name for name in selected_names if "brake" in name.lower()),
1219
- "sourceExpressions": sorted({str(value) for value in indexed_values(value_index, "brakeTorque")})},
1220
- "suspension": {"selectedParts": sorted(name for name in selected_names if "strut" in name.lower() or "suspension" in name.lower()),
1221
- "springLiterals": sorted({float(value) for value in indexed_values(value_index, "beamSpring") if isinstance(value, (int, float))}),
1222
- "damperLiterals": sorted({float(value) for value in indexed_values(value_index, "beamDamp") if isinstance(value, (int, float))})},
1738
+ "sourceExpressions": sorted({str(row.get("brakeTorque", {}).get("raw"))
1739
+ for row in brake_semantics["axles"].values()
1740
+ if isinstance(row, dict) and isinstance(row.get("brakeTorque"), dict)
1741
+ and row["brakeTorque"].get("raw") is not None}),
1742
+ **brake_semantics},
1743
+ "suspension": suspension_semantics,
1223
1744
  "wheelsTyres": {"selectedParts": sorted(name for name in selected_names if any(token in name.lower() for token in ("wheel", "rim", "tire", "tyre"))),
1224
1745
  "literalRadii": sorted({float(value) for value in indexed_values(value_index, "radius") if isinstance(value, (int, float))})},
1225
1746
  "electrics": sorted({str(value) for value in indexed_values(value_index, "electricsName") + indexed_values(value_index, "$electric")}),
@@ -1241,8 +1762,6 @@ def main() -> None:
1241
1762
  prov("runtime.controllers", "selected JBeam controller tables", "unsupported", f"{len(controllers)} controller types retained in capability ledger.")
1242
1763
  prov("runtime.softbody", "selected JBeam nodes/beams/triangles", "out-of-scope", "Not emitted into runtime contract.")
1243
1764
  prov("runtime.fluids", "selected JBeam energy/thermal sections", "out-of-scope", "Not emitted into runtime contract.")
1244
- prov("physics.brakes.maxTorque", "selected JBeam brakeTorque expressions", "defaulted", "Expressions retained; functional HELIX fallback emitted.")
1245
- prov("physics.suspension", "selected JBeam spring/damper rows", "defaulted", "Literal spring/damper rows are retained for later conversion; explicit HELIX priors emitted.")
1246
1765
  prov("simulation.land.tyreByAxle", "selected BeamNG wheel/tyre identities", "defaulted", "Compact common corpus lacks literal tyre model rows.")
1247
1766
  prov("mesh.visual.tiers", "HELIX engine fidelity contract", "defaulted", "Presentation-only policy; simulation and identity are invariant.")
1248
1767
 
@@ -1257,7 +1776,7 @@ def main() -> None:
1257
1776
  "engine": bool(engine_parts and len(torque) >= 2 and (not turbo_declared or induction is not None)),
1258
1777
  "gearbox": ratios_sourced and transmission_kind_sourced,
1259
1778
  "differentials": bool(diffs),
1260
- "brakes": False,
1779
+ "brakes": brakes_functional,
1261
1780
  "suspension": False,
1262
1781
  "wheelsTyres": any(value for value in indexed_values(value_index, "radius")
1263
1782
  if isinstance(value, (int, float))),
@@ -1279,6 +1798,13 @@ def main() -> None:
1279
1798
  "resolvedSelectionPercent": round(100 * resolved_count / max(1, len(explicit)), 2),
1280
1799
  "resolvedClosurePercent": round(100 * len(selected) / max(1, len(selected) + len(missing)), 2),
1281
1800
  "sourceLiteralPercent": round(100 * literal_count / max(1, len(provenance)), 2),
1801
+ "suspensionProvenance": {
1802
+ disposition: sum(row["disposition"] == disposition
1803
+ and (row["target"].startswith("physics.suspension")
1804
+ or row["target"].startswith("sourceSemantics.suspension"))
1805
+ for row in provenance)
1806
+ for disposition in ("source-literal", "derived", "defaulted", "unsupported")
1807
+ },
1282
1808
  "functionalDomains": functional_domains,
1283
1809
  "futureEligibility": {"rigidDetachment": {
1284
1810
  "eligiblePanelIds": future_detachable_panels,
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": "helix.bridge-plugin/1",
3
3
  "id": "hypersonic.beamng-vehicle",
4
4
  "name": "BeamNG Vehicle Importer",
5
- "version": "0.3.0",
5
+ "version": "0.5.0",
6
6
  "trust": "local-untrusted",
7
7
  "description": "Assess, convert, texture, lower, and acceptance-test a BeamNG vehicle plus its exact configuration before HELIX packaging.",
8
8
  "permissions": [
@@ -49,7 +49,7 @@
49
49
  "executable": "bin/beamng-vehicle.mjs",
50
50
  "timeoutSeconds": 120
51
51
  },
52
- "description": "Package the canonical vehicle GLB with its immutable source manifest, Vehicle Package v2, add-on records, and exact-selection Build."
52
+ "description": "Package the canonical vehicle GLB with its immutable source manifest, versioned lowering diagnostics, Vehicle Package v2, add-on records, and exact-selection Build."
53
53
  }
54
54
  ]
55
55
  }
@@ -5,7 +5,7 @@ export const BEAMNG_VEHICLE_SOURCE_SCHEMA = 'helix.beamng-vehicle-source/1';
5
5
 
6
6
  export function markVehicleSourceFunctionallyLowered(manifest, lowering) {
7
7
  if (manifest?.schemaVersion !== BEAMNG_VEHICLE_SOURCE_SCHEMA
8
- || lowering?.schemaVersion !== 'helix.beamng-functional-lowering/1'
8
+ || lowering?.schemaVersion !== 'helix.beamng-functional-lowering/2'
9
9
  || lowering?.identity?.candidateId !== manifest?.vehicle?.candidateId
10
10
  || lowering?.identity?.bridgePlanKey !== manifest?.identity?.bridgePlanKey) {
11
11
  throw new Error('cannot attach mismatched BeamNG functional lowering to source manifest');