@helix3/helix-cli 0.1.18-helix3.311 → 0.1.18-helix3.312

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.18-helix3.311",
3
+ "version": "0.1.18-helix3.312",
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",
@@ -36,6 +36,10 @@ Vehicle imports require the canonical Palette as a named, fingerprinted Bridge i
36
36
  helix bridge import <unpacked-mod> --input palette=<helix-materials/palette.json>
37
37
  ```
38
38
 
39
+ The Palette contract may be `helix-palette/4` (the current `asset-packs/helix-materials/palette.json`) or the older `helix-palette/3`; the importer reads only `families[].id`, `definitionVersion`, `physical.sizeMeters` and `shader.defaults`, which are identical in both, and the vehicle receipt records the contract it actually read. The supported list lives in `lib/palette-contract.mjs` and `bin/convert-beamng.py` (`SUPPORTED_PALETTE_CONTRACTS`).
40
+
41
+ Bridge snapshots this plugin directory into `<work>/plugins/<id>` before running it, so everything the plugin imports must live inside it. The Vehicle Package v2 validator is therefore mirrored byte-for-byte at `plugins/beamng-vehicle/vendor/vehicle-package-v2.mjs`; `scripts/vendor-vehicle-package-v2.mjs --write/--check` keeps it and the top-level `vendor/` copy in step. Source DAEs (500 MB and more) are scanned in bounded chunks (`lib/dae-scan.mjs`), never read into one JS string.
42
+
39
43
  Local development:
40
44
 
41
45
  ```bash
@@ -8,9 +8,11 @@ import process from 'node:process';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { auditVehicleGlb, injectVehicleMaterialBindings, injectVehicleReceipt, readVehicleReceipt } from '../lib/vehicle-qa.mjs';
10
10
  import { inspectBeamngCommonDependency, materializeBeamngCommonDependency } from '../lib/common-dependency.mjs';
11
+ import { daeHasName } from '../lib/dae-scan.mjs';
12
+ import { SUPPORTED_PALETTE_CONTRACTS } from '../lib/palette-contract.mjs';
11
13
  import { sha256FileSync } from '../lib/file-identity.mjs';
12
14
  import { buildVehicleSourceManifest, markVehicleSourceFunctionallyLowered, BEAMNG_VEHICLE_SOURCE_SCHEMA } from '../lib/vehicle-source.mjs';
13
- import { validateVehiclePackage } from '../../../vendor/vehicle-package-v2.mjs';
15
+ import { validateVehiclePackage } from '../vendor/vehicle-package-v2.mjs';
14
16
 
15
17
  const MAX_FILES = 50_000;
16
18
  const MAX_DAE = 12;
@@ -50,11 +52,11 @@ function semanticCount(files, pattern) {
50
52
  }
51
53
 
52
54
  function daeHasAuthoredSteering(path) {
53
- return /(?:id|name)="[^"]*steer(?:ing)?(?:wheel|rim)?[^"]*"/i.test(readFileSync(path, 'utf8'));
55
+ return daeHasName(path, /steer/i);
54
56
  }
55
57
 
56
58
  function daeHasAuthoredTyre(path) {
57
- return /(?:id|name)="[^"]*(?:tyre|tire)[^"]*"/i.test(readFileSync(path, 'utf8'));
59
+ return daeHasName(path, /(?:tyre|tire)/i);
58
60
  }
59
61
 
60
62
  function commonGeometryRoot(root) {
@@ -65,8 +67,7 @@ function commonGeometryRoot(root) {
65
67
  }
66
68
 
67
69
  function daeHasAuthoredRoadWheel(path) {
68
- const names = [...readFileSync(path, 'utf8').matchAll(/(?:id|name)="([^"]+)"/gi)].map((row) => row[1]);
69
- return names.some((name) => /(?:tyre|tire|(?<!steering)(?<!steering_)(?<!steer_)wheel(?!well)|(?<!t)rim)/i.test(name));
70
+ return daeHasName(path, /(?:tyre|tire|(?<!steering)(?<!steering_)(?<!steer_)wheel(?!well)|(?<!t)rim)/i);
70
71
  }
71
72
 
72
73
  function authoredTyreDependency(root) {
@@ -100,8 +101,8 @@ function vehiclePalette(path) {
100
101
  let document;
101
102
  try { document = JSON.parse(readFileSync(path, 'utf8')); }
102
103
  catch (error) { throw new Error(`BeamNG Palette input is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); }
103
- if (document?.contract !== 'helix-palette/3' || !Array.isArray(document.families)) {
104
- throw new Error("BeamNG Palette input contract must be 'helix-palette/3' with a families array");
104
+ if (!SUPPORTED_PALETTE_CONTRACTS.includes(document?.contract) || !Array.isArray(document.families)) {
105
+ throw new Error(`BeamNG Palette input contract must be ${SUPPORTED_PALETTE_CONTRACTS.map((id) => `'${id}'`).join(' or ')} with a families array`);
105
106
  }
106
107
  const required = ['vehicle-paint-metallic', 'vehicle-glass', 'tire-rubber', 'chrome', 'stainless-steel', 'aluminum-brushed',
107
108
  'brake-rotor', 'brake-caliper-painted', 'vehicle-interior-plastic', 'vehicle-leather', 'vehicle-carpet',
@@ -118,6 +119,7 @@ function vehiclePalette(path) {
118
119
  }
119
120
  return {
120
121
  path: resolve(path),
122
+ contract: document.contract,
121
123
  familyVersions: Object.fromEntries(required.map((id) => [id, families.get(id).definitionVersion])),
122
124
  familyDefaults: Object.fromEntries(required.map((id) => [id, {
123
125
  roughness: families.get(id).shader.defaults.roughness,
@@ -1217,6 +1219,12 @@ async function main() {
1217
1219
 
1218
1220
  export { convert };
1219
1221
 
1220
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1222
+ // Compare real paths: argv[1] keeps a symlinked prefix (macOS /var -> /private/var) that import.meta.url
1223
+ // has already resolved, and a plain comparison then exits 0 without running anything.
1224
+ const invokedDirectly = (() => {
1225
+ try { return Boolean(process.argv[1]) && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
1226
+ catch { return false; }
1227
+ })();
1228
+ if (invokedDirectly) {
1221
1229
  main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; });
1222
1230
  }
@@ -16,6 +16,12 @@ import sys
16
16
  import xml.etree.ElementTree as ET
17
17
  from pathlib import Path
18
18
 
19
+ # Blender runs `--python <script>` without the script's directory on sys.path (4.0 and 4.5 on
20
+ # Linux), so sibling modules must be made importable explicitly before they are imported.
21
+ _SCRIPT_DIR = str(Path(__file__).resolve().parent)
22
+ if _SCRIPT_DIR not in sys.path:
23
+ sys.path.insert(0, _SCRIPT_DIR)
24
+
19
25
  import bpy
20
26
  import numpy as np
21
27
  from mathutils import Matrix, Vector
@@ -588,13 +594,20 @@ VEHICLE_MATERIAL_ROLES = (
588
594
  )
589
595
 
590
596
 
591
- def load_vehicle_palette(path: Path) -> dict[str, dict]:
597
+ # Contracts whose `families[]` carry the fields this converter reads (id, definitionVersion,
598
+ # physical.sizeMeters, shader.defaults). /4 is the current asset pack; /3 stays accepted.
599
+ SUPPORTED_PALETTE_CONTRACTS = ("helix-palette/4", "helix-palette/3")
600
+
601
+
602
+ def load_vehicle_palette(path: Path) -> tuple[str, dict[str, dict]]:
592
603
  try:
593
604
  document = json.loads(path.read_text())
594
605
  except Exception as error:
595
606
  raise RuntimeError(f"Palette input is not valid JSON: {error}")
596
- if document.get("contract") != "helix-palette/3" or not isinstance(document.get("families"), list):
597
- raise RuntimeError("Palette input must use contract helix-palette/3 with a families array")
607
+ contract = document.get("contract")
608
+ if contract not in SUPPORTED_PALETTE_CONTRACTS or not isinstance(document.get("families"), list):
609
+ raise RuntimeError(f"Palette input must use contract {' or '.join(SUPPORTED_PALETTE_CONTRACTS)} "
610
+ "with a families array")
598
611
  families = {row.get("id"): row for row in document["families"] if isinstance(row, dict)}
599
612
  required = {row[2] for row in VEHICLE_MATERIAL_ROLES} | {"vehicle-interior-plastic"}
600
613
  for family_id in required:
@@ -606,7 +619,7 @@ def load_vehicle_palette(path: Path) -> dict[str, dict]:
606
619
  value = defaults.get(field)
607
620
  if not isinstance(value, (int, float)) or not 0 <= value <= 1:
608
621
  raise RuntimeError(f"Palette family {family_id} has invalid {field}")
609
- return families
622
+ return contract, families
610
623
 
611
624
 
612
625
  def vehicle_material_role(name: str) -> tuple[str, str, tuple]:
@@ -640,7 +653,7 @@ def helix_material_binding(family: dict, source_id: str, tint: tuple | None,
640
653
 
641
654
  def restore_materials(roots: list[Path], palette_path: Path, vehicle_root: Path,
642
655
  configured_paint: list[float] | None = None) -> dict:
643
- palette = load_vehicle_palette(palette_path)
656
+ palette_contract, palette = load_vehicle_palette(palette_path)
644
657
  definitions: dict[str, tuple[dict, Path]] = {}
645
658
  source_diagnostics = {key: [] for key in ("parseFailures", "shadowedDeclarations", "stageConflicts",
646
659
  "dynamicTextures", "sentinels", "unsupportedMaps")}
@@ -840,7 +853,7 @@ def restore_materials(roots: list[Path], palette_path: Path, vehicle_root: Path,
840
853
  declaration_provenance = sorted({json.dumps(row[0].get("provenance"), sort_keys=True)
841
854
  for row in definitions.values() if row[0].get("provenance")})
842
855
  return {"definitions": len({id(row[0]) for row in definitions.values()}), "resolved": resolved, "mapped": mapped, "materials": total,
843
- "paletteContract": "helix-palette/3", "bound": total, "factorDefaults": factor_defaults,
856
+ "paletteContract": palette_contract, "bound": total, "factorDefaults": factor_defaults,
844
857
  "familyVersions": {family_id: family["definitionVersion"] for family_id, family in palette.items()},
845
858
  "renderReady": total, "renderReadyCoverage": 1.0 if total else 0.0,
846
859
  "roles": role_counts, "bindings": bindings,
@@ -1315,6 +1328,7 @@ def decimate(objects: list[bpy.types.Object], limit: int = 285_000) -> tuple[int
1315
1328
  def main() -> None:
1316
1329
  args = arguments()
1317
1330
  source_root = Path(args.source_root).resolve()
1331
+ vehicle_root = Path(args.vehicle_root).resolve()
1318
1332
  bpy.ops.wm.read_factory_settings(use_empty=True)
1319
1333
  for dae in args.dae:
1320
1334
  print(f"[beamng] importing {dae}")
@@ -1322,7 +1336,7 @@ def main() -> None:
1322
1336
  objects = meshes()
1323
1337
  if not objects:
1324
1338
  raise RuntimeError("selected BeamNG DAE contains no renderable meshes")
1325
- wanted, config = configured_meshes(Path(args.vehicle_root).resolve(), source_root, args.dae,
1339
+ wanted, config = configured_meshes(vehicle_root, source_root, args.dae,
1326
1340
  args.configuration_hint, {obj.name for obj in objects},
1327
1341
  [Path(value).resolve() for value in args.material_root],
1328
1342
  Path(args.part_index).resolve(), args.bridge_plan_key,
@@ -1485,7 +1499,6 @@ def main() -> None:
1485
1499
  for obj in rims.values():
1486
1500
  size = dimensions(obj)
1487
1501
  wheel_radii.append(max(size.y, size.z) * 0.5)
1488
- vehicle_root = Path(args.vehicle_root)
1489
1502
  audio_limits = {extension: AUDIO_SCAN_LIMIT for extension in (".wav", ".ogg", ".flac", ".mp3")}
1490
1503
  audio_files = len(bounded_asset_paths([vehicle_root], audio_limits))
1491
1504
  audio_refs = int(bool(config.get("selectedPartSummary", {}).get("semanticEvidence", {}).get("audio")))
@@ -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.6.1",
5
+ "version": "0.6.2",
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": [
@@ -0,0 +1,40 @@
1
+ import { closeSync, constants, openSync, readSync } from 'node:fs';
2
+
3
+ // A BeamNG DAE can exceed 500 MB, far past V8's ~512 MiB (0x1fffffe8) string limit, so a DAE must
4
+ // never be read into one JS string. These helpers scan it in bounded chunks instead.
5
+ const CHUNK_BYTES = 8 * 1024 * 1024;
6
+ // Longest id/name attribute that can straddle a chunk boundary. Real COLLADA ids are < 200 chars.
7
+ const BOUNDARY_CARRY_CHARS = 4096;
8
+ const NAME_ATTRIBUTE = /(?:id|name)="([^"]+)"/gi;
9
+
10
+ /**
11
+ * Visit every `id="..."` / `name="..."` attribute value of a DAE without holding the file in memory.
12
+ * `visit` returns true to stop early. Decoded as latin1 so multi-byte UTF-8 can never split a
13
+ * character across chunks; the callers only test ASCII patterns against the names.
14
+ */
15
+ export function scanDaeNames(path, visit, { chunkBytes = CHUNK_BYTES } = {}) {
16
+ const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
17
+ const buffer = Buffer.allocUnsafe(chunkBytes);
18
+ let carry = '';
19
+ try {
20
+ for (;;) {
21
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.byteLength, null);
22
+ if (bytesRead === 0) return false;
23
+ const text = carry + buffer.latin1Slice(0, bytesRead);
24
+ NAME_ATTRIBUTE.lastIndex = 0;
25
+ for (const match of text.matchAll(NAME_ATTRIBUTE)) {
26
+ // Matches wholly inside the carried tail were already visited with the previous chunk.
27
+ if (match.index + match[0].length <= carry.length) continue;
28
+ if (visit(match[1])) return true;
29
+ }
30
+ carry = text.slice(-BOUNDARY_CARRY_CHARS);
31
+ }
32
+ } finally {
33
+ closeSync(descriptor);
34
+ }
35
+ }
36
+
37
+ export function daeHasName(path, pattern, options) {
38
+ const test = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
39
+ return scanDaeNames(path, (name) => test.test(name), options);
40
+ }
@@ -0,0 +1,6 @@
1
+ // Palette contracts the BeamNG vehicle importer can project. The current asset pack
2
+ // (asset-packs/helix-materials/palette.json) is /4; /3 stays accepted for older packs.
3
+ // The first entry is the preferred/current contract. The families[] fields the importer reads
4
+ // (id, definitionVersion, physical.sizeMeters, shader.defaults.roughness/metallic) are
5
+ // identical in both. Keep bin/convert-beamng.py SUPPORTED_PALETTE_CONTRACTS in sync.
6
+ export const SUPPORTED_PALETTE_CONTRACTS = Object.freeze(['helix-palette/4', 'helix-palette/3']);
@@ -10,6 +10,7 @@ import {
10
10
  writeSync,
11
11
  } from 'node:fs';
12
12
  import { randomUUID } from 'node:crypto';
13
+ import { SUPPORTED_PALETTE_CONTRACTS } from './palette-contract.mjs';
13
14
  import { basename, dirname, join } from 'node:path';
14
15
  import { classifyVehicleMaterialSemantic, stampVehicleMaterialSemantics } from './material-semantics.mjs';
15
16
 
@@ -478,7 +479,11 @@ export function auditVehicleGlb(path, options = {}) {
478
479
  check('cabin.seats', seatIds.length === 2 || seatIds.length === 4, Object.fromEntries(seatIds.map((id) => [id, sockets[id]])),
479
480
  'two or four measured H-points');
480
481
  const interiorNodes = nodeNames.filter((name) => /(seat|dash|interior|cabin|console|steer|gauge|door.?card|door.?panel|pedal)/i.test(name));
481
- check('cabin.meshes', interiorNodes.length >= 8, interiorNodes.length, 'at least 8 semantic interior nodes');
482
+ // The runtime builds the cabin from the measured sockets checked above (seats, steering, pedals, cockpit
483
+ // camera) and merely ADOPTS authored geometry when a socket names it; it has no minimum node count (see
484
+ // VehicleInterior.adoptNamed). Converters routinely merge a small two-door cabin into a handful of meshes,
485
+ // so a count is not evidence. Require only that authored interior geometry survived conversion.
486
+ check('cabin.meshes', interiorNodes.length >= 1, interiorNodes.length, 'at least one semantic interior node; the cabin contract is the measured seat, steering, pedal and cockpit sockets');
482
487
  check('source.authored-seats', Number.isInteger(sourceMeasurements.seatHPoints)
483
488
  && (sourceMeasurements.seatHPoints === 2 || sourceMeasurements.seatHPoints === 4),
484
489
  sourceMeasurements.seatHPoints ?? null, 'two or four H-points measured from authored seat geometry');
@@ -542,11 +547,11 @@ export function auditVehicleGlb(path, options = {}) {
542
547
  { materials: materials.length, mapped, textureCoverage: Number(coverage.toFixed(3)), images: doc.images?.length ?? 0,
543
548
  sourceResolved, sourceMaterialTotal, sourceBindingCoverage: Number(sourceBindingCoverage.toFixed(3)) },
544
549
  'embedded maps on half of exported materials, source PBR bindings on at least 75%, or valid Palette render readiness on at least 90%');
545
- check('materials.palette-bindings', importQuality.materials?.paletteContract === 'helix-palette/3'
550
+ check('materials.palette-bindings', SUPPORTED_PALETTE_CONTRACTS.includes(importQuality.materials?.paletteContract)
546
551
  && paletteBound / Math.max(1, materials.length) >= 0.9 && renderReadyCoverage >= 0.9,
547
552
  { materials: materials.length, paletteBound, renderReady, renderReadyCoverage: Number(renderReadyCoverage.toFixed(3)),
548
553
  roles: finalRoles },
549
- 'helix-palette/3 projection with valid helix-material-binding/1 metadata and at least 90% render-ready material coverage');
554
+ 'helix-palette/4 (or /3) projection with valid helix-material-binding/1 metadata and at least 90% render-ready material coverage');
550
555
  const rear = semanticZ(doc, /(mainexhaust|exhaust|taillight|tailgate|trunk|boot|bumper[_-]?r)/i)
551
556
  .filter((z) => Math.abs(z) >= Math.max(0.75, dimensions[2] * 0.25));
552
557
  const exhaust = sockets.exhaust;
@@ -1,5 +1,6 @@
1
1
  import { basename, isAbsolute, relative, resolve, sep } from 'node:path';
2
2
  import { sha256FileSync } from './file-identity.mjs';
3
+ import { SUPPORTED_PALETTE_CONTRACTS } from './palette-contract.mjs';
3
4
 
4
5
  export const BEAMNG_VEHICLE_SOURCE_SCHEMA = 'helix.beamng-vehicle-source/1';
5
6
 
@@ -204,7 +205,7 @@ export function buildVehicleSourceManifest({ request, source, candidate, convers
204
205
  definitions: materials.definitions ?? 0,
205
206
  resolved: materials.resolved ?? 0,
206
207
  mapped: materials.mapped ?? 0,
207
- paletteContract: materials.paletteContract ?? 'helix-palette/3',
208
+ paletteContract: materials.paletteContract ?? SUPPORTED_PALETTE_CONTRACTS[0],
208
209
  declarationProvenance: materials.declarationProvenance ?? [],
209
210
  stageConflicts: materials.stageConflicts ?? [],
210
211
  sentinels: materials.sentinels ?? [],
@@ -0,0 +1,813 @@
1
+ // Generated dependency-free copy of the HELIX engine Vehicle Package validator.
2
+ // Bridge JSON/schema preflight is deliberately separate below.
3
+ // Source commit: 2a36fbb3c3672c6de695f8039ef8f1a9af40dd45
4
+ // Regenerate with scripts/vendor-vehicle-package-v2.mjs; do not edit by hand.
5
+
6
+ export const VEHICLE_PACKAGE_V2_SOURCE = Object.freeze({
7
+ "commit": "2a36fbb3c3672c6de695f8039ef8f1a9af40dd45",
8
+ "path": "systems/humanoid-character/src/core/vehicle/package/format.ts",
9
+ "sourceSha256": "e3c75ab4c358ff760de4bb0341174362cf18daf2c55bea6c0bb30d8223ad9454",
10
+ "specialtyPath": "systems/humanoid-character/src/core/vehicle/specialty.ts",
11
+ "specialtySourceSha256": "ba820b03720cc4df417ee731dd1463bd50c041e688996f5e5cf42d1035709c1c"
12
+ });
13
+
14
+ export const VEHICLE_PACKAGE_SCHEMA_VERSION = 'helix.vehicle-package/2';
15
+
16
+ function isRecord(value) {
17
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ function checkJsonValue(value, path, issues) {
21
+ if (typeof value === 'number' && !Number.isFinite(value)) {
22
+ issues.push({ level: 'error', path, message: 'must be a finite JSON number' });
23
+ return;
24
+ }
25
+ if (ArrayBuffer.isView(value)) {
26
+ issues.push({ level: 'error', path, message: 'typed arrays are not JSON-safe; use number arrays in package DTOs' });
27
+ return;
28
+ }
29
+ if (Array.isArray(value)) {
30
+ value.forEach((item, index) => checkJsonValue(item, `${path}[${index}]`, issues));
31
+ } else if (isRecord(value)) {
32
+ for (const [key, item] of Object.entries(value)) checkJsonValue(item, `${path}.${key}`, issues);
33
+ } else if (value !== null && !['string', 'number', 'boolean'].includes(typeof value)) {
34
+ issues.push({ level: 'error', path, message: `contains non-JSON value of type ${typeof value}` });
35
+ }
36
+ }
37
+
38
+ export function validateVehiclePackageJsonBoundary(candidate) {
39
+ const issues = [];
40
+ const err = (path, message) => { issues.push({ level: 'error', path, message }); };
41
+ if (!isRecord(candidate)) {
42
+ err('$', 'expected a vehicle package object');
43
+ return issues;
44
+ }
45
+ checkJsonValue(candidate, '$', issues);
46
+ if (candidate.schemaVersion !== VEHICLE_PACKAGE_SCHEMA_VERSION) {
47
+ err('schemaVersion', `expected ${VEHICLE_PACKAGE_SCHEMA_VERSION}, got ${String(candidate.schemaVersion)}`);
48
+ }
49
+ if (candidate.packageVersion !== VEHICLE_PACKAGE_VERSION) {
50
+ err('packageVersion', `expected ${VEHICLE_PACKAGE_VERSION}, got ${String(candidate.packageVersion)}`);
51
+ }
52
+ if (!isRecord(candidate.physics) || !isRecord(candidate.physics.engine) || !isRecord(candidate.physics.gearbox)) {
53
+ err('physics', 'must define engine and gearbox objects');
54
+ }
55
+ if (!Array.isArray(candidate.citations)) err('citations', 'must be an array');
56
+ if (!isRecord(candidate.mesh)) err('mesh', 'must be an object');
57
+ if (!isRecord(candidate.targets)) err('targets', 'must be an object');
58
+ if (!isRecord(candidate.audio) || !Array.isArray(candidate.audio.slots)) err('audio.slots', 'must be an array');
59
+ if (!Array.isArray(candidate.vfx)) err('vfx', 'must be an array');
60
+ if (!isRecord(candidate.lights) || !Array.isArray(candidate.lights.lamps)) err('lights.lamps', 'must be an array');
61
+ if (candidate.specialty !== undefined && (!isRecord(candidate.specialty)
62
+ || !isRecord(candidate.specialty.module) || !Array.isArray(candidate.specialty.state)
63
+ || !Array.isArray(candidate.specialty.actions) || !Array.isArray(candidate.specialty.channels))) {
64
+ err('specialty', 'must define module, state, actions, and channels');
65
+ }
66
+ return issues;
67
+ }
68
+
69
+ // Generated from the two pinned engine source files by scripts/vendor-vehicle-package-v2.mjs.
70
+ // Do not edit. This intermediate TypeScript is transpiled into the standalone vendor artifact.
71
+ const LAND_SOLVER_V2_FORMAT = 1;
72
+ const FIDELITY_TIERS = ['minimal', 'low', 'medium', 'high', 'ultra'];
73
+ export const VEHICLE_QUALITY_TIERS = FIDELITY_TIERS;
74
+ function isPlainObject(value) {
75
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
76
+ }
77
+ /** Optional Universal Interactable extension carried by the normal vehicle package. */
78
+ export const VEHICLE_SPECIALTY_FORMAT = 1;
79
+ const ID = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$/;
80
+ const PACKAGE_SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
81
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
82
+ const CALL = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
83
+ const SHA256 = /^[a-f0-9]{64}$/;
84
+ const INPUT_ACTIONS = new Set(['primary', 'secondary', 'reload', 'melee']);
85
+ const ROLES = new Set(['owner', 'driver', 'occupant', 'nearby']);
86
+ const SURFACES = new Set(['nearby', 'cockpit', 'keybind']);
87
+ function finite(value) {
88
+ return typeof value === 'number' && Number.isFinite(value);
89
+ }
90
+ export function validateVehicleSpecialtyManifest(manifest) {
91
+ const errors = [];
92
+ if (manifest.format !== VEHICLE_SPECIALTY_FORMAT)
93
+ errors.push(`format must be ${VEHICLE_SPECIALTY_FORMAT}`);
94
+ if (!ID.test(manifest.module.id))
95
+ errors.push('module.id must be namespaced');
96
+ if (!PACKAGE_SLUG.test(manifest.module.bundleId))
97
+ errors.push('module.bundleId must be a package slug');
98
+ if (manifest.module.version !== undefined && !SEMVER.test(manifest.module.version)) {
99
+ errors.push('module.version must be semantic version x.y.z');
100
+ }
101
+ if (!SHA256.test(manifest.module.bundleChecksumSha256))
102
+ errors.push('module.bundleChecksumSha256 must be lowercase SHA-256');
103
+ for (const [seat, variable] of Object.entries(manifest.module.seatVars ?? {})) {
104
+ if (!CALL.test(seat))
105
+ errors.push(`module.seatVars key '${seat}' is invalid`);
106
+ if (!CALL.test(variable))
107
+ errors.push(`module.seatVars.${seat} is invalid`);
108
+ }
109
+ const state = new Map();
110
+ for (const [index, declaration] of manifest.state.entries()) {
111
+ const at = `state[${index}]`;
112
+ if (!CALL.test(declaration.var))
113
+ errors.push(`${at}.var is invalid`);
114
+ if (state.has(declaration.var))
115
+ errors.push(`${at}.var duplicates '${declaration.var}'`);
116
+ state.set(declaration.var, declaration);
117
+ if (!['boolean', 'number', 'string'].includes(declaration.type))
118
+ errors.push(`${at}.type is invalid`);
119
+ if (declaration.persistence !== 'durable' && declaration.persistence !== 'session')
120
+ errors.push(`${at}.persistence is invalid`);
121
+ }
122
+ const actionIds = new Set();
123
+ const actionCalls = new Set();
124
+ const controlCalls = new Set();
125
+ const inputActions = new Set();
126
+ for (const [index, action] of manifest.actions.entries()) {
127
+ const at = `actions[${index}]`;
128
+ if (!ID.test(action.id))
129
+ errors.push(`${at}.id must be namespaced`);
130
+ if (actionIds.has(action.id))
131
+ errors.push(`${at}.id duplicates '${action.id}'`);
132
+ actionIds.add(action.id);
133
+ if (!action.label.trim())
134
+ errors.push(`${at}.label is required`);
135
+ if (!CALL.test(action.call))
136
+ errors.push(`${at}.call is invalid`);
137
+ if (actionCalls.has(action.call))
138
+ errors.push(`${at}.call duplicates '${action.call}'`);
139
+ actionCalls.add(action.call);
140
+ if (action.control)
141
+ controlCalls.add(action.call);
142
+ if (action.surfaces.length === 0)
143
+ errors.push(`${at}.surfaces is empty`);
144
+ for (const surface of action.surfaces)
145
+ if (!SURFACES.has(surface))
146
+ errors.push(`${at}.surfaces contains '${surface}'`);
147
+ if (action.access.length === 0)
148
+ errors.push(`${at}.access is empty`);
149
+ for (const role of action.access)
150
+ if (!ROLES.has(role))
151
+ errors.push(`${at}.access contains '${role}'`);
152
+ if (action.control === 'enter-driver' && !action.access.includes('nearby')) {
153
+ errors.push(`${at}.control enter-driver requires nearby access`);
154
+ }
155
+ if (action.control === 'exit-driver' && !action.access.includes('driver')) {
156
+ errors.push(`${at}.control exit-driver requires driver access`);
157
+ }
158
+ if (action.input) {
159
+ if (!action.surfaces.includes('keybind'))
160
+ errors.push(`${at}.input requires the keybind surface`);
161
+ if (!INPUT_ACTIONS.has(action.input.action))
162
+ errors.push(`${at}.input.action is not platform-owned`);
163
+ if (inputActions.has(action.input.action))
164
+ errors.push(`${at}.input.action '${action.input.action}' is already used`);
165
+ inputActions.add(action.input.action);
166
+ }
167
+ if (action.kind === 'number') {
168
+ if (!action.var || !state.has(action.var))
169
+ errors.push(`${at}.var must name declared state`);
170
+ if (!finite(action.min) || !finite(action.max) || !finite(action.step))
171
+ errors.push(`${at} number bounds must be finite`);
172
+ else if (action.max <= action.min || action.step <= 0)
173
+ errors.push(`${at} number bounds are invalid`);
174
+ }
175
+ if (action.kind === 'choice' && (action.options?.length ?? 0) === 0)
176
+ errors.push(`${at}.options is empty`);
177
+ }
178
+ const componentActions = manifest.componentActions;
179
+ if (componentActions !== undefined) {
180
+ if (componentActions.version !== 1)
181
+ errors.push('componentActions.version must be 1');
182
+ const initialBits = componentActions.initialBits;
183
+ if (!Number.isInteger(initialBits) || initialBits < 0 || initialBits > 0xffff) {
184
+ errors.push('componentActions.initialBits must be uint16');
185
+ }
186
+ const actionMap = componentActions.actions;
187
+ if (typeof actionMap !== 'object' || actionMap === null || Array.isArray(actionMap)) {
188
+ errors.push('componentActions.actions must be an object');
189
+ return errors;
190
+ }
191
+ const descriptors = Object.entries(actionMap);
192
+ if (descriptors.length === 0)
193
+ errors.push('componentActions.actions is empty');
194
+ let declaredMask = 0;
195
+ for (const [call, descriptor] of descriptors) {
196
+ const at = `componentActions.actions.${call}`;
197
+ if (!CALL.test(call))
198
+ errors.push(`${at} call is invalid`);
199
+ if (!actionCalls.has(call))
200
+ errors.push(`${at} must match a declared specialty action call`);
201
+ if (controlCalls.has(call))
202
+ errors.push(`${at} cannot also be a vehicle control action`);
203
+ if (typeof descriptor !== 'object' || descriptor === null || Array.isArray(descriptor)) {
204
+ errors.push(`${at} must be an object`);
205
+ continue;
206
+ }
207
+ if (!Number.isInteger(descriptor.mask) || descriptor.mask <= 0 || descriptor.mask > 0xffff) {
208
+ errors.push(`${at}.mask must be non-zero uint16`);
209
+ continue;
210
+ }
211
+ declaredMask |= descriptor.mask;
212
+ if (!['toggle', 'set', 'clear', 'replace'].includes(descriptor.operation)) {
213
+ errors.push(`${at}.operation is invalid`);
214
+ continue;
215
+ }
216
+ if (descriptor.operation === 'replace') {
217
+ if (!Array.isArray(descriptor.allowedTargets)
218
+ || descriptor.allowedTargets.length === 0
219
+ || descriptor.allowedTargets.length > 32) {
220
+ errors.push(`${at}.allowedTargets must contain 1..32 states`);
221
+ continue;
222
+ }
223
+ const seenTargets = new Set();
224
+ for (const target of descriptor.allowedTargets) {
225
+ if (!Number.isInteger(target) || target < 0 || target > 0xffff || (target & ~descriptor.mask) !== 0) {
226
+ errors.push(`${at}.allowedTargets must be unique uint16 states wholly inside mask`);
227
+ }
228
+ if (seenTargets.has(target))
229
+ errors.push(`${at}.allowedTargets duplicates ${String(target)}`);
230
+ seenTargets.add(target);
231
+ }
232
+ }
233
+ else if ('allowedTargets' in descriptor) {
234
+ errors.push(`${at}.allowedTargets is only valid for replace`);
235
+ }
236
+ }
237
+ if (Number.isInteger(initialBits) && (initialBits & ~declaredMask) !== 0) {
238
+ errors.push('componentActions.initialBits contains bits outside declared action masks');
239
+ }
240
+ }
241
+ const channelIds = new Set();
242
+ for (const [index, channel] of manifest.channels.entries()) {
243
+ const at = `channels[${index}]`;
244
+ if (!ID.test(channel.id))
245
+ errors.push(`${at}.id must be namespaced`);
246
+ if (channelIds.has(channel.id))
247
+ errors.push(`${at}.id duplicates '${channel.id}'`);
248
+ channelIds.add(channel.id);
249
+ if (!state.has(channel.var))
250
+ errors.push(`${at}.var must name declared state`);
251
+ else if (state.get(channel.var)?.type === 'string')
252
+ errors.push(`${at}.var must be boolean or normalized number state`);
253
+ if (!finite(channel.durationS) || channel.durationS < 0)
254
+ errors.push(`${at}.durationS must be finite and non-negative`);
255
+ if (channel.kind === 'rotation' && (!finite(channel.closedRad) || !finite(channel.openRad)))
256
+ errors.push(`${at} rotation bounds must be finite`);
257
+ if (channel.kind === 'translation' && ![...channel.closed, ...channel.open].every(finite))
258
+ errors.push(`${at} translation bounds must be finite`);
259
+ }
260
+ const events = new Set();
261
+ for (const [index, effect] of (manifest.effects ?? []).entries()) {
262
+ const at = `effects[${index}]`;
263
+ if (!CALL.test(effect.event))
264
+ errors.push(`${at}.event is invalid`);
265
+ if (events.has(effect.event))
266
+ errors.push(`${at}.event duplicates '${effect.event}'`);
267
+ events.add(effect.event);
268
+ if (!effect.audio && !effect.vfx && !effect.clip)
269
+ errors.push(`${at} has no presentation binding`);
270
+ }
271
+ return errors;
272
+ }
273
+ /**
274
+ * ── THE HELIX VEHICLE PACKAGE, v2 ──────────────────────────────────────────────────────────────────────────────
275
+ *
276
+ * ONE document that carries everything a specific, named vehicle is: its mesh and the parts inside it, its physics,
277
+ * its audio slot bindings, its lights, its VFX — and, for every physical number, WHERE THAT NUMBER CAME FROM.
278
+ *
279
+ * ── Why this exists next to `VehicleTuning` rather than replacing it ───────────────────────────────────────────
280
+ * `VehicleTuning` is what the SOLVER eats: spring rates in N/m, contact positions in the body frame, a torque LUT.
281
+ * It is a machine input and it is deliberately opinion-free — it cannot tell you whether 1527 kg was measured,
282
+ * guessed, or scaled off a bounding box, and it has nowhere to say so.
283
+ *
284
+ * A package is what a HUMAN authors and what an ARCHIVE keeps. It states the real car's published figures, cites
285
+ * each one, and derives the solver's numbers from them. `vehiclePackageToTuning()` is the one-way door between
286
+ * the two: package → tuning, never back.
287
+ *
288
+ * ── THE FOUR RULES (they are what make the next car a fill-in-the-blanks job) ──────────────────────────────────
289
+ *
290
+ * 1. **No bare numbers in the physics block.** Every figure is a `Sourced<T>` carrying either `sources` (citation
291
+ * ids into `citations`) or `derivedFrom` (prose naming the calculation). `validateVehiclePackage` REJECTS a
292
+ * package whose figure cites an id that is not in the citation list, so a citation cannot rot into decoration.
293
+ *
294
+ * 2. **Targets are separate from inputs, and both are checked.** `physics` is what we feed the solver; `targets`
295
+ * is the published BEHAVIOUR the finished car must reproduce — 0–60, top speed, lateral g — each with an
296
+ * explicit tolerance. `assertPackageBehaviour()` drives the car headlessly and compares. A package whose inputs
297
+ * are all beautifully cited but which does 0–60 in 3 s is still a failing package.
298
+ *
299
+ * 3. **Geometry may come from the mesh; character may not.** Wheel positions, wheelbase and track are read off the
300
+ * creator's model through the existing socket ladder when one is supplied, because that is where they are
301
+ * genuinely known. Mass, CoM height, torque curve, gear ratios and aero are STATED — no bounding box knows them.
302
+ *
303
+ * 4. **Slots are declared even when nothing consumes them yet.** An audio slot or a light channel with no runtime
304
+ * consumer is still part of the vehicle. It carries `consumed: false` and a reason, so the gap is visible in the
305
+ * document instead of invisible in the code.
306
+ *
307
+ * ── Distribution ──────────────────────────────────────────────────────────────────────────────────────────────
308
+ * `provenance.distributable` is the hard gate. The reference package built from ripped game-mod assets is
309
+ * `false` and must never reach the publish pipeline; `assertPackagePublishable()` is the check that enforces it.
310
+ *
311
+ * A package is publishable two ways once its assets are clean: as a UNIVERSAL ITEM (one mesh + this manifest;
312
+ * per-instance state — odometer, fuel, damage, paint — lives on the item instance), or as a VAULT ASSET another
313
+ * world installs. Both consume the same document; nothing here is specific to either.
314
+ */
315
+ /**
316
+ * v2 adds explicit simulation, rendition and rigid-articulation contracts. The fields are additive: v1 packages
317
+ * remain readable so already-published vehicle manifests do not stop spawning merely because the authoring schema
318
+ * learned how to describe the higher-fidelity runtime.
319
+ */
320
+ export const VEHICLE_PACKAGE_VERSION = 2;
321
+ export const VEHICLE_PACKAGE_COMPATIBLE_VERSIONS = [1, VEHICLE_PACKAGE_VERSION];
322
+ export const VEHICLE_SIMULATION_FORMAT = 1;
323
+ export const VEHICLE_VISUAL_FORMAT = 1;
324
+ export const VEHICLE_ARTICULATION_FORMAT = 1;
325
+ function isSourced(v) {
326
+ return typeof v === 'object' && v !== null && 'value' in v && 'unit' in v;
327
+ }
328
+ /**
329
+ * Walk the physics + targets trees and check every `Sourced` leaf.
330
+ *
331
+ * Generic on purpose: a hand-written list of field names is a list that goes stale the first time someone adds a
332
+ * field, and a citation checker that silently stops covering new fields is worse than none — it reports a clean
333
+ * pass over the half it still knows about.
334
+ */
335
+ function checkSourcedTree(node, path, citationIds, issues) {
336
+ if (isSourced(node)) {
337
+ const s = node.sources ?? [];
338
+ if (s.length === 0 && node.derivedFrom === undefined) {
339
+ issues.push({ level: 'error', path, message: 'has neither a citation nor a `derivedFrom` explanation' });
340
+ }
341
+ if (s.length > 0 && node.derivedFrom !== undefined) {
342
+ issues.push({ level: 'warn', path, message: 'carries BOTH citations and `derivedFrom`; say which one is the authority' });
343
+ }
344
+ for (const id of s) {
345
+ if (!citationIds.has(id)) {
346
+ issues.push({ level: 'error', path, message: `cites '${id}', which is not in the package's citation list` });
347
+ }
348
+ }
349
+ if (typeof node.unit !== 'string' || node.unit.length === 0) {
350
+ issues.push({ level: 'error', path, message: 'has no unit' });
351
+ }
352
+ return;
353
+ }
354
+ if (Array.isArray(node))
355
+ return; // a LUT's point list is data, not a tree of figures
356
+ if (typeof node === 'object' && node !== null) {
357
+ for (const [k, v] of Object.entries(node))
358
+ checkSourcedTree(v, `${path}.${k}`, citationIds, issues);
359
+ }
360
+ }
361
+ function validateFiniteJsonTree(value, path, err) {
362
+ if (typeof value === 'number') {
363
+ if (!Number.isFinite(value))
364
+ err(path, 'must be finite');
365
+ return;
366
+ }
367
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
368
+ return;
369
+ if (ArrayBuffer.isView(value)) {
370
+ err(path, 'must be a JSON number array, not a typed array');
371
+ return;
372
+ }
373
+ if (Array.isArray(value)) {
374
+ for (let i = 0; i < value.length; i++)
375
+ validateFiniteJsonTree(value[i], `${path}[${i}]`, err);
376
+ return;
377
+ }
378
+ if (isPlainObject(value)) {
379
+ for (const [key, child] of Object.entries(value))
380
+ validateFiniteJsonTree(child, `${path}.${key}`, err);
381
+ return;
382
+ }
383
+ err(path, 'must contain only JSON-safe values');
384
+ }
385
+ function validatePointLut(value, path, err) {
386
+ if (!isPlainObject(value) || !Array.isArray(value.points)) {
387
+ err(path, 'must contain a points array');
388
+ return;
389
+ }
390
+ if (value.points.length < 2)
391
+ err(`${path}.points`, 'needs at least 2 points');
392
+ let previous = Number.NEGATIVE_INFINITY;
393
+ for (let i = 0; i < value.points.length; i++) {
394
+ const point = value.points[i];
395
+ if (!Array.isArray(point) || point.length !== 2 || !point.every((entry) => typeof entry === 'number' && Number.isFinite(entry))) {
396
+ err(`${path}.points[${i}]`, 'must be a finite [input, output] pair');
397
+ continue;
398
+ }
399
+ if (point[0] <= previous)
400
+ err(`${path}.points`, `inputs must strictly ascend; index ${i} does not`);
401
+ previous = point[0];
402
+ }
403
+ }
404
+ function validateLandAuthoring(value, path, err) {
405
+ if (!isPlainObject(value)) {
406
+ err(path, 'must be an object');
407
+ return;
408
+ }
409
+ validateFiniteJsonTree(value, path, err);
410
+ if (value.version !== LAND_SOLVER_V2_FORMAT) {
411
+ err(`${path}.version`, `expected ${LAND_SOLVER_V2_FORMAT}, got ${String(value.version)}`);
412
+ }
413
+ const driveline = value.driveline;
414
+ if (!isPlainObject(driveline)) {
415
+ err(`${path}.driveline`, 'must be an object');
416
+ }
417
+ else {
418
+ const engine = driveline.engine;
419
+ if (!isPlainObject(engine)) {
420
+ err(`${path}.driveline.engine`, 'must be an object');
421
+ }
422
+ else {
423
+ validatePointLut(engine.torqueCurve, `${path}.driveline.engine.torqueCurve`, err);
424
+ validatePointLut(engine.burnEfficiency, `${path}.driveline.engine.burnEfficiency`, err);
425
+ }
426
+ const gearbox = driveline.gearbox;
427
+ if (!isPlainObject(gearbox) || !Array.isArray(gearbox.ratios) || gearbox.ratios.length === 0) {
428
+ err(`${path}.driveline.gearbox.ratios`, 'needs at least one forward gear');
429
+ }
430
+ if (isPlainObject(driveline.turbine)) {
431
+ validatePointLut(driveline.turbine.pressureMap, `${path}.driveline.turbine.pressureMap`, err);
432
+ validatePointLut(driveline.turbine.exhaustFactor, `${path}.driveline.turbine.exhaustFactor`, err);
433
+ validatePointLut(driveline.turbine.volumetricEfficiency, `${path}.driveline.turbine.volumetricEfficiency`, err);
434
+ }
435
+ }
436
+ if (!Array.isArray(value.tyreByAxle) || value.tyreByAxle.length === 0) {
437
+ err(`${path}.tyreByAxle`, 'needs at least one axle tyre');
438
+ return;
439
+ }
440
+ const positiveTyreFields = [
441
+ 'alphaPeakRad', 'cxMult', 'sigmaX', 'sigmaY', 'dxOverDy', 'sFall', 'radius', 'angularInertia',
442
+ ];
443
+ for (let index = 0; index < value.tyreByAxle.length; index++) {
444
+ const tyrePath = `${path}.tyreByAxle[${index}]`;
445
+ const tyre = value.tyreByAxle[index];
446
+ if (!isPlainObject(tyre)) {
447
+ err(tyrePath, 'must be an object');
448
+ continue;
449
+ }
450
+ for (const field of positiveTyreFields) {
451
+ if (!(typeof tyre[field] === 'number' && Number.isFinite(tyre[field]) && tyre[field] > 0)) {
452
+ err(`${tyrePath}.${field}`, 'must be finite and > 0');
453
+ }
454
+ }
455
+ if (!(typeof tyre.rollingResistance === 'number' && Number.isFinite(tyre.rollingResistance) && tyre.rollingResistance >= 0)) {
456
+ err(`${tyrePath}.rollingResistance`, 'must be finite and >= 0');
457
+ }
458
+ const lut = tyre.muLut;
459
+ if (!isPlainObject(lut)) {
460
+ err(`${tyrePath}.muLut`, 'must be an object');
461
+ continue;
462
+ }
463
+ const fz = lut.fz;
464
+ const mu = lut.mu;
465
+ if (!Array.isArray(fz))
466
+ err(`${tyrePath}.muLut.fz`, 'must be a JSON number array');
467
+ if (!Array.isArray(mu))
468
+ err(`${tyrePath}.muLut.mu`, 'must be a JSON number array');
469
+ if (!Array.isArray(fz) || !Array.isArray(mu))
470
+ continue;
471
+ if (fz.length === 0)
472
+ err(`${tyrePath}.muLut.fz`, 'needs at least one point');
473
+ if (fz.length !== mu.length)
474
+ err(`${tyrePath}.muLut`, 'fz and mu must have the same length');
475
+ let previous = Number.NEGATIVE_INFINITY;
476
+ for (let i = 0; i < fz.length; i++) {
477
+ const load = fz[i];
478
+ const coefficient = mu[i];
479
+ if (!(typeof load === 'number' && Number.isFinite(load) && load > 0)) {
480
+ err(`${tyrePath}.muLut.fz[${i}]`, 'must be finite and > 0');
481
+ }
482
+ else if (load <= previous) {
483
+ err(`${tyrePath}.muLut.fz`, `loads must strictly ascend; index ${i} does not`);
484
+ }
485
+ if (!(typeof coefficient === 'number' && Number.isFinite(coefficient) && coefficient > 0)) {
486
+ err(`${tyrePath}.muLut.mu[${i}]`, 'must be finite and > 0');
487
+ }
488
+ if (typeof load === 'number')
489
+ previous = load;
490
+ }
491
+ if (!(typeof lut.slideRatio === 'number' && Number.isFinite(lut.slideRatio) && lut.slideRatio > 0 && lut.slideRatio <= 1)) {
492
+ err(`${tyrePath}.muLut.slideRatio`, 'must be finite and in (0, 1]');
493
+ }
494
+ }
495
+ }
496
+ /**
497
+ * Structural + provenance validation. Returns issues rather than throwing, so a world with one bad package still
498
+ * boots — the same contract `validateTuning` uses.
499
+ */
500
+ export function validateEngineVehiclePackage(pkg) {
501
+ const issues = [];
502
+ const err = (path, message) => { issues.push({ level: 'error', path, message }); };
503
+ // The same cross-field rule `validateTuning` enforces on the resolved tree, checked HERE as well, because a
504
+ // creator can act on it at publish and cannot act on it at spawn. `wastegateBar > maxBoostBar` means the gate
505
+ // can never open, so one of the two numbers is dead weight and nothing downstream can tell which.
506
+ const fi = pkg.physics?.induction;
507
+ if (fi !== undefined) {
508
+ if (fi.maxBoostBar.value <= 0)
509
+ err('physics.induction.maxBoostBar', 'must be > 0 bar (omit the block for naturally aspirated)');
510
+ if (fi.wastegateBar.value <= 0)
511
+ err('physics.induction.wastegateBar', 'must be > 0 bar');
512
+ if (fi.wastegateBar.value > fi.maxBoostBar.value) {
513
+ err('physics.induction.wastegateBar', `wastegate (${fi.wastegateBar.value}) must be ≤ maxBoostBar (${fi.maxBoostBar.value})`);
514
+ }
515
+ if (fi.referenceRpm.value <= 0)
516
+ err('physics.induction.referenceRpm', 'must be > 0 rpm');
517
+ if (fi.gamma.value <= 0)
518
+ err('physics.induction.gamma', 'must be > 0 (1 = linear with rpm, >1 = late onset)');
519
+ if (fi.spoolUpTime.value < 0)
520
+ err('physics.induction.spoolUpTime', 'must be ≥ 0 s');
521
+ if (fi.spoolDownTime.value < 0)
522
+ err('physics.induction.spoolDownTime', 'must be ≥ 0 s');
523
+ if (fi.blowOffThresholdBar.value < 0)
524
+ err('physics.induction.blowOffThresholdBar', 'must be ≥ 0 bar');
525
+ // THE DOUBLE-COUNTED BOOST CHECK. A published peak-torque figure is the BOOSTED one; the curve must be the
526
+ // naturally-aspirated base. If the authored curve's peak matches the published peak instead of the
527
+ // de-boosted peak, the creator fed in the brochure numbers and the car will make about twice its real
528
+ // output. Only checked where the two are meaningfully different — under ~0.15 bar they converge and the
529
+ // test would fire on rounding.
530
+ const points = pkg.physics?.engine?.torqueCurve?.value ?? [];
531
+ const publishedPeak = pkg.physics?.engine?.peakTorqueNm?.value;
532
+ if (!fi.torqueCurveIncludesBoost
533
+ && fi.wastegateBar.value >= 0.15
534
+ && points.length > 0
535
+ && typeof publishedPeak === 'number'
536
+ && publishedPeak > 0) {
537
+ const curvePeak = Math.max(...points.map(([, nm]) => nm));
538
+ const deBoosted = publishedPeak / (1 + fi.wastegateBar.value);
539
+ // ⚠ FIRES ONLY ON AN ESSENTIALLY EXACT MATCH to the published figure — within 2 % — never on "closer to
540
+ // one than the other". The looser test had a real false positive: the repo's only authored package has a
541
+ // curve peak of 407.0 against a `peakTorqueNm` of 406.7, so the demonstrated convention is that the two
542
+ // AGREE, and a creator following it while adding induction would have been told their car makes twice its
543
+ // real output. That is a confident, wrong, actionable message — the worst kind. An exact match is
544
+ // unambiguous evidence the de-boost step was skipped; anything in between is not evidence at all, so this
545
+ // stays silent there and lets the behaviour targets catch it.
546
+ if (Math.abs(curvePeak - publishedPeak) <= publishedPeak * 0.02) {
547
+ err('physics.engine.torqueCurve', `the curve peaks at ${curvePeak.toFixed(0)} N·m, matching \`peakTorqueNm\` (${publishedPeak.toFixed(0)}) `
548
+ + `exactly. When \`induction\` is declared the curve must be the NATURALLY-ASPIRATED base — about `
549
+ + `${deBoosted.toFixed(0)} N·m here (peakTorqueNm / (1 + wastegate ${fi.wastegateBar.value})) — because `
550
+ + 'boost multiplies it again at runtime. `peakTorqueNm` is the published, already-boosted figure; the '
551
+ + 'curve is not.');
552
+ }
553
+ }
554
+ }
555
+ if (!VEHICLE_PACKAGE_COMPATIBLE_VERSIONS.includes(pkg.packageVersion)) {
556
+ err('packageVersion', `expected a compatible version (${VEHICLE_PACKAGE_COMPATIBLE_VERSIONS.join(' or ')}), got ${pkg.packageVersion}`);
557
+ }
558
+ const ids = new Set();
559
+ for (const c of pkg.citations) {
560
+ if (ids.has(c.id))
561
+ err(`citations.${c.id}`, 'duplicate citation id');
562
+ ids.add(c.id);
563
+ }
564
+ checkSourcedTree(pkg.physics, 'physics', ids, issues);
565
+ checkSourcedTree(pkg.targets, 'targets', ids, issues);
566
+ for (const [k, t] of Object.entries(pkg.targets)) {
567
+ const target = t;
568
+ if (!(target.tolerance > 0) || target.tolerance >= 1) {
569
+ err(`targets.${k}.tolerance`, `expected 0 < tolerance < 1, got ${String(target.tolerance)}`);
570
+ }
571
+ }
572
+ const curve = pkg.physics.engine.torqueCurve.value;
573
+ if (curve.length < 2)
574
+ err('physics.engine.torqueCurve', 'needs at least 2 points');
575
+ for (let i = 1; i < curve.length; i++) {
576
+ if ((curve[i]?.[0] ?? 0) <= (curve[i - 1]?.[0] ?? 0)) {
577
+ err('physics.engine.torqueCurve', `points must ascend in rpm; index ${i} does not`);
578
+ }
579
+ }
580
+ if (pkg.physics.engine.limiterRpm.value <= pkg.physics.engine.idleRpm.value) {
581
+ err('physics.engine.limiterRpm', 'must exceed idleRpm');
582
+ }
583
+ if (pkg.physics.gearbox.reverseRatio.value >= 0)
584
+ err('physics.gearbox.reverseRatio', 'must be negative');
585
+ if (pkg.physics.gearbox.ratios.value.length === 0)
586
+ err('physics.gearbox.ratios', 'needs at least one forward gear');
587
+ // A curve that does not reach the limiter leaves the top of the rev range to `evalLut`'s clamped tail, which is a
588
+ // flat torque plateau nobody authored — the car then pulls to the limiter as hard as it did at peak.
589
+ const topRpm = curve[curve.length - 1]?.[0] ?? 0;
590
+ if (topRpm < pkg.physics.engine.limiterRpm.value) {
591
+ issues.push({
592
+ level: 'warn',
593
+ path: 'physics.engine.torqueCurve',
594
+ message: `curve ends at ${topRpm} rpm but the limiter is ${pkg.physics.engine.limiterRpm.value} rpm — the tail is `
595
+ + 'held flat by the LUT clamp, which is a torque plateau nobody authored',
596
+ });
597
+ }
598
+ for (const b of pkg.audio.slots) {
599
+ if (b.buffers.length === 0 && (b.reason === undefined || b.reason.length === 0)) {
600
+ err(`audio.slots.${b.slot}`, 'has no buffers and no `reason` — an empty slot must say why it is empty');
601
+ }
602
+ if (!b.consumed && (b.reason === undefined || b.reason.length === 0)) {
603
+ err(`audio.slots.${b.slot}`, 'is not consumed and gives no reason');
604
+ }
605
+ }
606
+ const seenSlots = new Set();
607
+ for (const b of pkg.audio.slots) {
608
+ if (seenSlots.has(b.slot))
609
+ err(`audio.slots.${b.slot}`, 'duplicate slot binding');
610
+ seenSlots.add(b.slot);
611
+ }
612
+ for (const v of pkg.vfx) {
613
+ if (!v.consumed && (v.reason === undefined || v.reason.length === 0)) {
614
+ err(`vfx.${v.slot}`, 'is not consumed and gives no reason');
615
+ }
616
+ }
617
+ const lampIds = new Set();
618
+ for (const l of pkg.lights.lamps) {
619
+ if (lampIds.has(l.id))
620
+ err(`lights.lamps.${l.id}`, 'duplicate lamp id');
621
+ lampIds.add(l.id);
622
+ if (l.presentation === 'emissive+beam' && l.beam === undefined) {
623
+ err(`lights.lamps.${l.id}`, "presentation is 'emissive+beam' but no `beam` is defined");
624
+ }
625
+ }
626
+ if (!(pkg.lights.indicatorHz > 0))
627
+ err('lights.indicatorHz', 'must be > 0');
628
+ const simulation = pkg.simulation;
629
+ if (simulation !== undefined) {
630
+ if (!isPlainObject(simulation)) {
631
+ err('simulation', 'must be an object');
632
+ }
633
+ else {
634
+ if (simulation.format !== VEHICLE_SIMULATION_FORMAT) {
635
+ err('simulation.format', `expected ${VEHICLE_SIMULATION_FORMAT}, got ${String(simulation.format)}`);
636
+ }
637
+ if (!(typeof simulation.fixedStepHz === 'number'
638
+ && simulation.fixedStepHz > 0
639
+ && Number.isFinite(simulation.fixedStepHz))) {
640
+ err('simulation.fixedStepHz', 'must be a finite positive frequency');
641
+ }
642
+ if (simulation.qualityInvariant !== true) {
643
+ err('simulation.qualityInvariant', 'must be true — quality tiers may not change vehicle semantics');
644
+ }
645
+ if (simulation.land !== undefined)
646
+ validateLandAuthoring(simulation.land, 'simulation.land', err);
647
+ }
648
+ }
649
+ const visual = pkg.mesh.visual;
650
+ if (visual !== undefined) {
651
+ if (!isPlainObject(visual)) {
652
+ err('mesh.visual', 'must be an object');
653
+ }
654
+ else {
655
+ if (visual.format !== VEHICLE_VISUAL_FORMAT)
656
+ err('mesh.visual.format', `expected ${VEHICLE_VISUAL_FORMAT}, got ${String(visual.format)}`);
657
+ const renditionIds = new Set();
658
+ const renditions = Array.isArray(visual.renditions) ? visual.renditions : [];
659
+ if (!Array.isArray(visual.renditions))
660
+ err('mesh.visual.renditions', 'must be an array');
661
+ for (const rawRendition of renditions) {
662
+ if (!isPlainObject(rawRendition)) {
663
+ err('mesh.visual.renditions', 'every rendition must be an object');
664
+ continue;
665
+ }
666
+ const rendition = rawRendition;
667
+ const path = `mesh.visual.renditions.${rendition.id}`;
668
+ if (renditionIds.has(rendition.id))
669
+ err(path, 'duplicate rendition id');
670
+ renditionIds.add(rendition.id);
671
+ if (!(rendition.triangleBudget > 0) || !Number.isFinite(rendition.triangleBudget))
672
+ err(`${path}.triangleBudget`, 'must be finite and > 0');
673
+ if (rendition.kind === 'derived' && !rendition.sourceRenditionId) {
674
+ err(`${path}.sourceRenditionId`, 'a derived rendition must name its source');
675
+ }
676
+ }
677
+ const policies = isPlainObject(visual.tiers) ? visual.tiers : undefined;
678
+ if (!policies)
679
+ err('mesh.visual.tiers', 'must be an object');
680
+ for (const tier of VEHICLE_QUALITY_TIERS) {
681
+ const policy = policies?.[tier];
682
+ if (!policy) {
683
+ err(`mesh.visual.tiers.${tier}`, 'missing engine quality tier');
684
+ }
685
+ else if (!isPlainObject(policy)) {
686
+ err(`mesh.visual.tiers.${tier}`, 'must be an object');
687
+ }
688
+ else if (!renditionIds.has(String(policy.renditionId))) {
689
+ err(`mesh.visual.tiers.${tier}.renditionId`, `references unknown rendition '${policy.renditionId}'`);
690
+ }
691
+ }
692
+ for (const rendition of renditions.filter(isPlainObject)) {
693
+ if (rendition.sourceRenditionId && !renditionIds.has(rendition.sourceRenditionId)) {
694
+ err(`mesh.visual.renditions.${rendition.id}.sourceRenditionId`, `references unknown rendition '${rendition.sourceRenditionId}'`);
695
+ }
696
+ }
697
+ }
698
+ }
699
+ const articulation = pkg.mesh.articulation;
700
+ if (articulation !== undefined) {
701
+ if (!isPlainObject(articulation)) {
702
+ err('mesh.articulation', 'must be an object');
703
+ }
704
+ else {
705
+ if (articulation.format !== VEHICLE_ARTICULATION_FORMAT) {
706
+ err('mesh.articulation.format', `expected ${VEHICLE_ARTICULATION_FORMAT}, got ${String(articulation.format)}`);
707
+ }
708
+ const panelIds = new Set();
709
+ const panelNodes = new Set();
710
+ const panelSignals = new Set();
711
+ const tiers = new Set(VEHICLE_QUALITY_TIERS);
712
+ const panels = Array.isArray(articulation.panels) ? articulation.panels : [];
713
+ if (!Array.isArray(articulation.panels))
714
+ err('mesh.articulation.panels', 'must be an array');
715
+ for (const rawPanel of panels) {
716
+ if (!isPlainObject(rawPanel)) {
717
+ err('mesh.articulation.panels', 'every panel must be an object');
718
+ continue;
719
+ }
720
+ const panel = rawPanel;
721
+ const path = `mesh.articulation.panels.${panel.id}`;
722
+ if (panelIds.has(panel.id))
723
+ err(path, 'duplicate panel id');
724
+ panelIds.add(panel.id);
725
+ if (typeof panel.signal !== 'string' || panel.signal.trim().length === 0) {
726
+ err(`${path}.signal`, 'must be nonempty');
727
+ }
728
+ else if (panelSignals.has(panel.signal)) {
729
+ err(`${path}.signal`, `duplicate panel signal '${panel.signal}'`);
730
+ }
731
+ else {
732
+ panelSignals.add(panel.signal);
733
+ }
734
+ const nodes = Array.isArray(panel.nodes) ? panel.nodes : [];
735
+ if (!Array.isArray(panel.nodes))
736
+ err(`${path}.nodes`, 'must be an array');
737
+ if (nodes.length === 0)
738
+ err(`${path}.nodes`, 'needs at least one carried visual node');
739
+ for (const node of nodes) {
740
+ if (panelNodes.has(node))
741
+ err(`${path}.nodes`, `visual node '${node}' is carried by more than one panel`);
742
+ panelNodes.add(node);
743
+ }
744
+ const hinge = isPlainObject(panel.hinge) ? panel.hinge : undefined;
745
+ if (!hinge)
746
+ err(`${path}.hinge`, 'must be an object');
747
+ const axis = hinge && Array.isArray(hinge.axis) ? hinge.axis : [];
748
+ const validAxis = axis.length === 3
749
+ && axis.every((entry) => typeof entry === 'number' && Number.isFinite(entry));
750
+ if (!validAxis) {
751
+ err(`${path}.hinge.axis`, 'must be a finite three-number vector');
752
+ }
753
+ else {
754
+ const axisLength = Math.hypot(axis[0], axis[1], axis[2]);
755
+ if (Math.abs(axisLength - 1) > 1e-3)
756
+ err(`${path}.hinge.axis`, `must be normalized; length is ${axisLength}`);
757
+ }
758
+ if (hinge?.openDeg === hinge?.closedDeg)
759
+ err(`${path}.hinge.openDeg`, 'must differ from closedDeg');
760
+ const collider = isPlainObject(panel.collider) ? panel.collider : undefined;
761
+ if (!collider)
762
+ err(`${path}.collider`, 'must be an object');
763
+ if (!(typeof collider?.minHalfExtentM === 'number' && Number.isFinite(collider.minHalfExtentM) && collider.minHalfExtentM > 0)) {
764
+ err(`${path}.collider.minHalfExtentM`, 'must be finite and > 0');
765
+ }
766
+ const dynamics = isPlainObject(panel.dynamics) ? panel.dynamics : undefined;
767
+ if (!dynamics)
768
+ err(`${path}.dynamics`, 'must be an object');
769
+ if (!(typeof dynamics?.latchFraction === 'number' && dynamics.latchFraction >= 0 && dynamics.latchFraction < 1)) {
770
+ err(`${path}.dynamics.latchFraction`, 'must be in [0, 1)');
771
+ }
772
+ const fidelity = isPlainObject(panel.fidelity) ? panel.fidelity : undefined;
773
+ if (!fidelity)
774
+ err(`${path}.fidelity`, 'must be an object');
775
+ const physical = fidelity && Array.isArray(fidelity.physical) ? fidelity.physical : [];
776
+ const detachable = fidelity && Array.isArray(fidelity.detachable) ? fidelity.detachable : [];
777
+ if (fidelity && !Array.isArray(fidelity.physical))
778
+ err(`${path}.fidelity.physical`, 'must be an array');
779
+ if (fidelity && !Array.isArray(fidelity.detachable))
780
+ err(`${path}.fidelity.detachable`, 'must be an array');
781
+ for (const tier of physical)
782
+ if (!tiers.has(tier))
783
+ err(`${path}.fidelity`, `unknown quality tier '${tier}'`);
784
+ const physicalSet = new Set(physical);
785
+ for (const tier of detachable) {
786
+ if (!tiers.has(tier))
787
+ err(`${path}.fidelity`, `unknown quality tier '${tier}'`);
788
+ if (!physicalSet.has(tier)) {
789
+ err(`${path}.fidelity.detachable`, `tier '${tier}' must also enable physical articulation`);
790
+ }
791
+ }
792
+ }
793
+ }
794
+ }
795
+ if (pkg.specialty) {
796
+ for (const message of validateVehicleSpecialtyManifest(pkg.specialty))
797
+ err('specialty', message);
798
+ }
799
+ return issues;
800
+ }
801
+
802
+ export function validateVehiclePackage(candidate) {
803
+ const boundary = validateVehiclePackageJsonBoundary(candidate);
804
+ if (boundary.some((issue) => issue.level === 'error')) return boundary;
805
+ return [...boundary, ...validateEngineVehiclePackage(candidate)];
806
+ }
807
+
808
+ export const vehiclePackageV2Validator = Object.freeze({
809
+ source: VEHICLE_PACKAGE_V2_SOURCE,
810
+ schemaVersion: VEHICLE_PACKAGE_SCHEMA_VERSION,
811
+ packageVersion: VEHICLE_PACKAGE_VERSION,
812
+ validate: validateVehiclePackage,
813
+ });