@helix3/helix-cli 0.1.18-helix3.352 → 0.1.18-helix3.353
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 +1 -1
- package/plugins/beamng-vehicle/README.md +13 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_abs.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_audio.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_expression.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_jbeam.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/__pycache__/beamng_materials.cpython-312.pyc +0 -0
- package/plugins/beamng-vehicle/bin/beamng-vehicle.mjs +55 -1
- package/plugins/beamng-vehicle/bin/beamng_audio.py +370 -0
- package/plugins/beamng-vehicle/bin/lower-beamng.py +86 -5
- package/plugins/beamng-vehicle/bridge-plugin.json +1 -1
package/package.json
CHANGED
|
@@ -65,6 +65,19 @@ Selected tires lower one exact common or vehicle-local leaf per axle. Pressure,
|
|
|
65
65
|
|
|
66
66
|
Selected suspension evidence is lowered only from the chosen strut/coilover leaf part on each axle. Matching left/right spring, slow/fast bump, slow/fast rebound, velocity-split, precompression, and bound rows are retained with beam-space frequency and damping-ratio derivations. Structural parent/subframe beams and swaybar construction beams are explicitly excluded by part custody. Selected torsionbar stiffness is retained in `N.m/rad`, but the runtime suspension remains fail-closed: BeamNG element rates need motion-ratio geometry to become HELIX wheel rates, and the current package has one global damping triplet with no axle-specific fast/slow channels. The emitted HELIX suspension therefore remains an explicit fallback and `functionalDomains.suspension` remains false until those exact runtime mappings exist. Raw Lua/controller and other unsupported expressions remain explicit provenance instead of being presented as literal runtime parity. Soft-body deformation, fluids, wear, and detailed damage remain out of scope. Rigid panel poses are semantic on every device, physical bodies begin at `medium`, and detachment remains disabled on every production tier while source break groups are retained as future eligibility. Simulation, authority, package identity, and Build selection are quality-invariant. Bridge currently seals one canonical source GLB and truthfully maps every tier to it; optimized mesh/texture rendition IDs must not be selected until the optimizer has materialized and sealed those artifacts.
|
|
67
67
|
|
|
68
|
+
## Engine recordings
|
|
69
|
+
|
|
70
|
+
BeamNG names an engine sound with `soundConfig.sampleName` (and `soundConfigExhaust.sampleName`), which resolves to the mod's `art/sound/blends/<sampleName>.sfxBlend2D.json`. That blend is the mapping from recordings to rpm: `samples` is a list of load rows, each an rpm-ordered ladder of `["art/sound/engine/<set>/<file>.wav", rpm]`. (There is no `.sbeng` format in BeamNG; the blend JSON is the whole definition.) `lower-beamng.py` (via `bin/beamng_audio.py`) reads it and emits, for a mod that ships its own recordings:
|
|
71
|
+
|
|
72
|
+
- `<out>/audio/<name>.wav` for every recording the selected blend names. A 16-bit PCM WAV at 44.1 or 48 kHz is copied byte for byte. OGG, FLAC, 24-bit or other-rate WAVs are transcoded with `ffmpeg` (`pcm_s16le`, channels kept, one bank sample rate: the most common one among the files); the import is **refused with a clear message when ffmpeg is needed and not installed** (`HELIX_FFMPEG` overrides the lookup). Bare-number file names are prefixed with their folder (`PSH_1768.wav`).
|
|
73
|
+
- `audio.bank.blends.<sampleName>`: the source's own ladders, `[[file, rpm], ...]`, rpm values copied from the blend (sorted ascending; a repeated rpm makes the blend unusable rather than guessed at).
|
|
74
|
+
- `audio.bank.assets[]`: `{ name, file, path: "audio/<name>", role: "vehicle.audio.bank.sample", mediaType: "audio/wav", bytes, checksumSha256, cid }` and **no `url`**. The url is the public address of the sealed object, so `helix vehicle publish` uploads each file (found at `<package JSON dir>/<path>`) and fills it. The engine validator rejects a row with checksum/bytes but no url, so this plugin validates with a `pending-publish://` placeholder and emits the rows untouched; `helix vehicle validate` on an unpublished package reports exactly those url errors.
|
|
75
|
+
- `engineBlend` / `exhaustBlend`, `sampleCylinderCount` from `fundamentalFrequencyCylinderCount` (BeamNG has no field for the recorded engine's cylinder count, so it defaults to 4 and is recorded as `defaulted` provenance when the source omits it; set it by hand for a V6/V8 recording), `strokes: 4`, and `engineLadder` / `exhaustLadder` slots with `buffers: ["blend:<NAME>"]`, `consumed: true`. When the source declines a separate exhaust sample (`sampleName: "0"` or unset, as the 993 does), the one recording serves both ladders. A layer that is stock while the other ships is left out (no `exhaustBlend` naming a blend the bank does not hold) and its slot says why.
|
|
76
|
+
|
|
77
|
+
When the sampleName has no blend file inside the mod it is a stock BeamNG FMOD event whose recordings live in the game's own banks; nothing can be extracted and nothing is fabricated. Today's behaviour is kept (no `bank`, `engineBlend`/`exhaustBlend` name the event) and the slot reads `consumed: false`, `reason: "source uses BeamNG stock FMOD event <name>; no recording ships in the mod — platform fallback engine"`. A blend that names missing or path-escaping files, or an invalid ladder, is treated the same way with the cause in the reason. Starter, shut-off, horn and the other one-shot slots are not extracted: BeamNG ships those as stock FMOD events, not files.
|
|
78
|
+
|
|
79
|
+
Packaging copies the WAVs to `assets/audio/` beside `<id>.vehicle-package.json`, re-hashing each against its package row. The Bridge consumes an unpacked mod directory; unzip a mod archive first.
|
|
80
|
+
|
|
68
81
|
ABS is the first portable controller adapter. Version 1 recognizes only the
|
|
69
82
|
fingerprinted BeamNG 0.39.4 wheel-core shape: one selected `enableABS=true`, one
|
|
70
83
|
unambiguous brake owner per axle, the stock 100 Hz update rate, and equal
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -516,8 +516,57 @@ function confinedNewAsset(assets, file) {
|
|
|
516
516
|
return path;
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
// A bank row the importer extracted carries bytes + checksum + cid but no `url`: the url is the public
|
|
520
|
+
// transport address of the sealed object, which only exists after `helix vehicle publish` uploads the
|
|
521
|
+
// file. The engine validator rejects any row with runtime fields and no url, so validate the package
|
|
522
|
+
// with an explicit pending placeholder and leave the emitted rows untouched.
|
|
523
|
+
function withPendingBankUrls(pkg) {
|
|
524
|
+
const rows = pkg?.audio?.bank?.assets;
|
|
525
|
+
if (!Array.isArray(rows)) return pkg;
|
|
526
|
+
return { ...pkg, audio: { ...pkg.audio, bank: { ...pkg.audio.bank, assets: rows.map((row) => (
|
|
527
|
+
row && row.url === undefined && typeof row.checksumSha256 === 'string'
|
|
528
|
+
? { ...row, url: `pending-publish://sha256/${row.checksumSha256}` } : row)) } } };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Copy the WAVs the importer extracted next to the vehicle package, re-hashing each against the row
|
|
532
|
+
// the (already hash-bound) package declares. The package JSON is written to `assets/`, so a row's
|
|
533
|
+
// `path` ("audio/<name>.wav") resolves relative to it for `helix vehicle publish`.
|
|
534
|
+
function packageAudioSamples(vehiclePackage, importedGlb, assets, outputDir, candidateId) {
|
|
535
|
+
const rows = vehiclePackage?.audio?.bank?.assets;
|
|
536
|
+
if (!Array.isArray(rows) || rows.length === 0) return [];
|
|
537
|
+
const sourceDirectory = join(dirname(importedGlb), 'audio');
|
|
538
|
+
const destinationDirectory = join(assets, 'audio');
|
|
539
|
+
if (existsSync(destinationDirectory)) {
|
|
540
|
+
const info = lstatSync(destinationDirectory);
|
|
541
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('BeamNG vehicle packaging audio destination must be a non-symlink directory');
|
|
542
|
+
} else {
|
|
543
|
+
mkdirSync(destinationDirectory);
|
|
544
|
+
}
|
|
545
|
+
const artifacts = [];
|
|
546
|
+
for (const row of rows) {
|
|
547
|
+
const name = row?.name;
|
|
548
|
+
if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.wav$/.test(name) || row.path !== `audio/${name}`) {
|
|
549
|
+
throw new Error(`BeamNG vehicle packaging refused a non-portable audio sample row: ${String(name)}`);
|
|
550
|
+
}
|
|
551
|
+
const source = join(sourceDirectory, name);
|
|
552
|
+
if (!existsSync(source) || lstatSync(source).isSymbolicLink() || !lstatSync(source).isFile()) {
|
|
553
|
+
throw new Error(`BeamNG vehicle packaging cannot find the extracted audio sample ${name} beside the converted GLB`);
|
|
554
|
+
}
|
|
555
|
+
const identity = sha256FileSync(source);
|
|
556
|
+
if (identity.sha256 !== row.checksumSha256 || identity.sizeBytes !== row.bytes) {
|
|
557
|
+
throw new Error(`BeamNG vehicle packaging audio sample ${name} does not match its package checksum`);
|
|
558
|
+
}
|
|
559
|
+
const destination = confinedNewAsset(destinationDirectory, name);
|
|
560
|
+
copyFileSync(source, destination, constants.COPYFILE_EXCL);
|
|
561
|
+
artifacts.push({ kind: 'helix.package.asset', path: relative(outputDir, destination),
|
|
562
|
+
metadata: { role: 'vehicle-audio-bank-sample', vehicle: candidateId, mediaType: 'audio/wav',
|
|
563
|
+
sha256: identity.sha256, sizeBytes: identity.sizeBytes } });
|
|
564
|
+
}
|
|
565
|
+
return artifacts;
|
|
566
|
+
}
|
|
567
|
+
|
|
519
568
|
function assertValidVehiclePackage(pkg, boundary) {
|
|
520
|
-
const errors = validateVehiclePackage(pkg).filter((issue) => issue.level === 'error');
|
|
569
|
+
const errors = validateVehiclePackage(withPendingBankUrls(pkg)).filter((issue) => issue.level === 'error');
|
|
521
570
|
if (errors.length > 0) {
|
|
522
571
|
throw new Error(`${boundary} rejected invalid Vehicle Package v2:\n${errors
|
|
523
572
|
.map((issue) => `- ${issue.path}: ${issue.message}`).join('\n')}`);
|
|
@@ -996,6 +1045,8 @@ function lowerFunctionalVehicle({ source, candidate, sourceManifest, conversion,
|
|
|
996
1045
|
const sourcePath = join(outputDir, `.${candidate.id}.lowering-source.json`);
|
|
997
1046
|
const geometryPath = join(outputDir, `.${candidate.id}.lowering-geometry.json`);
|
|
998
1047
|
const bundlePath = join(outputDir, `${candidate.id}.functional-lowering.json`);
|
|
1048
|
+
const audioDir = join(outputDir, 'audio');
|
|
1049
|
+
rmSync(audioDir, { recursive: true, force: true });
|
|
999
1050
|
let lowered;
|
|
1000
1051
|
let bundle;
|
|
1001
1052
|
try {
|
|
@@ -1005,6 +1056,7 @@ function lowerFunctionalVehicle({ source, candidate, sourceManifest, conversion,
|
|
|
1005
1056
|
lowered = spawnSync('python3', [executable,
|
|
1006
1057
|
'--source-root', source, '--vehicle-root', candidate.root, '--configuration', configuration,
|
|
1007
1058
|
'--vehicle-source', sourcePath, '--geometry', geometryPath, '--output', bundlePath,
|
|
1059
|
+
'--audio-dir', audioDir,
|
|
1008
1060
|
'--part-index', selectedPartIndexArtifact.path,
|
|
1009
1061
|
...(absControllerReceipt ? ['--abs-controller-receipt', absControllerReceipt] : []),
|
|
1010
1062
|
...(commonRoot ? ['--common-root', commonRoot] : []),
|
|
@@ -1099,6 +1151,7 @@ function packageVehicleSource(request) {
|
|
|
1099
1151
|
artifacts.push({ kind: 'helix.package.asset', path: relative(request.outputDir, path),
|
|
1100
1152
|
metadata: { role, schemaVersion: value.schemaVersion, vehicle: candidateId } });
|
|
1101
1153
|
}
|
|
1154
|
+
artifacts.push(...packageAudioSamples(functional.vehiclePackage, input.path, assets, request.outputDir, candidateId));
|
|
1102
1155
|
return { artifacts };
|
|
1103
1156
|
}
|
|
1104
1157
|
|
|
@@ -1195,6 +1248,7 @@ async function main() {
|
|
|
1195
1248
|
path: basename(sourceManifestPath), sha256: sourceManifestDigest.sha256 },
|
|
1196
1249
|
functionalVehicle: { schemaVersion: functionalVehicle.schemaVersion,
|
|
1197
1250
|
vehiclePackage: functionalVehicle.vehiclePackage.id,
|
|
1251
|
+
audioBankAssets: functionalVehicle.vehiclePackage.audio?.bank?.assets?.length ?? 0,
|
|
1198
1252
|
explicitSelections: functionalVehicle.coverage.explicitSelections,
|
|
1199
1253
|
resolvedSelectionPercent: functionalVehicle.coverage.resolvedSelectionPercent },
|
|
1200
1254
|
qa,
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Extract a BeamNG mod's own engine recordings into HELIX vehicle-audio bank rows.
|
|
2
|
+
|
|
3
|
+
BeamNG names an engine sound by ``soundConfig.sampleName`` (and ``soundConfigExhaust.sampleName``).
|
|
4
|
+
That name resolves to ``art/sound/blends/<sampleName>.sfxBlend2D.json``. The blend is the whole
|
|
5
|
+
mapping from recordings to rpm::
|
|
6
|
+
|
|
7
|
+
{ "eventName": "event:>Engine>default",
|
|
8
|
+
"samples": [ [ ["art/sound/engine/PSH/1768.wav", 1768], ["...", 2238], ... ], # load row 0
|
|
9
|
+
[ ["art/sound/engine/PSH/1768.wav", 1768], ... ] ] } # load row 1
|
|
10
|
+
|
|
11
|
+
Each row is an rpm-ordered ladder; the HELIX bank blend keeps the same ``[[file, rpm], ...]`` shape.
|
|
12
|
+
|
|
13
|
+
A ``sampleName`` with no blend file inside the mod is a stock game event whose recordings live in
|
|
14
|
+
the game's own FMOD banks. Nothing ships in the mod, so nothing can be extracted and nothing is
|
|
15
|
+
fabricated: the caller keeps the platform fallback engine and states that in the slot reason.
|
|
16
|
+
|
|
17
|
+
Only the mod's real bytes are ever used. A 16-bit PCM WAV is copied verbatim. OGG, FLAC and WAVs
|
|
18
|
+
that are not 16-bit PCM are transcoded with ``ffmpeg`` (``pcm_s16le``, channels preserved); the
|
|
19
|
+
conversion is refused with a clear message when ``ffmpeg`` is not installed.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import struct
|
|
29
|
+
import subprocess
|
|
30
|
+
import tempfile
|
|
31
|
+
from collections import Counter
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from beamng_jbeam import parse_jbeam_text
|
|
36
|
+
|
|
37
|
+
BLEND_SUFFIX = ".sfxBlend2D.json"
|
|
38
|
+
BLEND_DIRECTORY = ("art", "sound", "blends")
|
|
39
|
+
MAX_BLEND_BYTES = 1024 * 1024
|
|
40
|
+
MAX_SAMPLE_FILES = 400
|
|
41
|
+
MAX_SAMPLE_BYTES = 128 * 1024 * 1024
|
|
42
|
+
MAX_TOTAL_BYTES = 768 * 1024 * 1024
|
|
43
|
+
MAX_WALK_ENTRIES = 200_000
|
|
44
|
+
BANK_RATES = (44100, 48000)
|
|
45
|
+
DEFAULT_RATE = 44100
|
|
46
|
+
ASSET_ROLE = "vehicle.audio.bank.sample"
|
|
47
|
+
TRANSCODE_EXTENSIONS = {".ogg", ".flac", ".mp3", ".opus", ".aif", ".aiff"}
|
|
48
|
+
# BeamNG treats a sampleName it cannot resolve as "no sound for this layer"; modders use "0".
|
|
49
|
+
DISABLED_SAMPLE_NAMES = {"", "0", "none", "null", "false"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AudioExtractionError(ValueError):
|
|
53
|
+
"""The mod ships audio the importer must not silently drop or guess at (refuse the import)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AudioUnavailable(AudioExtractionError):
|
|
57
|
+
"""The mod's own blend is unusable (missing files, malformed ladder, over a limit).
|
|
58
|
+
|
|
59
|
+
The importer keeps the platform fallback engine and records this text as the slot reason.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------------------------
|
|
64
|
+
# WAV inspection
|
|
65
|
+
# ---------------------------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def inspect_wav(path: Path) -> dict[str, int] | None:
|
|
68
|
+
"""Return ``{format, channels, rate, bits}`` for a RIFF/WAVE file, or ``None`` if it is not one.
|
|
69
|
+
|
|
70
|
+
``format`` is the effective format tag: WAVE_FORMAT_EXTENSIBLE is resolved to its sub-format.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
with path.open("rb") as handle:
|
|
74
|
+
head = handle.read(12)
|
|
75
|
+
if len(head) < 12 or head[:4] != b"RIFF" or head[8:12] != b"WAVE":
|
|
76
|
+
return None
|
|
77
|
+
while True:
|
|
78
|
+
chunk = handle.read(8)
|
|
79
|
+
if len(chunk) < 8:
|
|
80
|
+
return None
|
|
81
|
+
tag, size = chunk[:4], struct.unpack("<I", chunk[4:])[0]
|
|
82
|
+
if tag == b"fmt ":
|
|
83
|
+
body = handle.read(min(size, 40))
|
|
84
|
+
if len(body) < 16:
|
|
85
|
+
return None
|
|
86
|
+
fmt, channels, rate, _byte_rate, _align, bits = struct.unpack("<HHIIHH", body[:16])
|
|
87
|
+
if fmt == 0xFFFE and len(body) >= 26:
|
|
88
|
+
fmt = struct.unpack("<H", body[24:26])[0]
|
|
89
|
+
return {"format": fmt, "channels": channels, "rate": rate, "bits": bits}
|
|
90
|
+
handle.seek(size + (size & 1), os.SEEK_CUR)
|
|
91
|
+
except OSError:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_runtime_pcm(info: dict[str, int] | None) -> bool:
|
|
96
|
+
return bool(info) and info["format"] == 1 and info["bits"] == 16 and info["channels"] in (1, 2)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------------------------
|
|
100
|
+
# Source resolution
|
|
101
|
+
# ---------------------------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _is_safe_sample_name(name: str) -> bool:
|
|
104
|
+
return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.\- ]{0,200}", name))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ci_child(directory: Path, name: str) -> Path | None:
|
|
108
|
+
"""Case-insensitive child lookup (BeamNG runs on Windows, so mods routinely mismatch case)."""
|
|
109
|
+
exact = directory / name
|
|
110
|
+
if os.path.lexists(exact):
|
|
111
|
+
return exact
|
|
112
|
+
lowered = name.lower()
|
|
113
|
+
try:
|
|
114
|
+
for entry in os.scandir(directory):
|
|
115
|
+
if entry.name.lower() == lowered:
|
|
116
|
+
return Path(entry.path)
|
|
117
|
+
except OSError:
|
|
118
|
+
return None
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def confine(source_root: Path, portable: str) -> Path | None:
|
|
123
|
+
"""Resolve a mod-relative path inside ``source_root`` without following symlinks."""
|
|
124
|
+
if not isinstance(portable, str):
|
|
125
|
+
return None
|
|
126
|
+
portable = portable.replace("\\", "/").lstrip("/") if not re.match(r"^[A-Za-z]:", portable) else ""
|
|
127
|
+
if not portable:
|
|
128
|
+
return None
|
|
129
|
+
current = source_root
|
|
130
|
+
for piece in portable.split("/"):
|
|
131
|
+
if piece in ("", ".", ".."):
|
|
132
|
+
return None
|
|
133
|
+
found = _ci_child(current, piece)
|
|
134
|
+
if found is None or found.is_symlink():
|
|
135
|
+
return None
|
|
136
|
+
current = found
|
|
137
|
+
try:
|
|
138
|
+
current.resolve().relative_to(source_root.resolve())
|
|
139
|
+
except ValueError:
|
|
140
|
+
return None
|
|
141
|
+
return current if current.is_file() else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def find_blend(source_root: Path, sample_name: str) -> Path | None:
|
|
145
|
+
"""The ``<sampleName>.sfxBlend2D.json`` the mod ships, or ``None`` when it is a stock event."""
|
|
146
|
+
if not _is_safe_sample_name(sample_name):
|
|
147
|
+
return None
|
|
148
|
+
wanted = (sample_name + BLEND_SUFFIX).lower()
|
|
149
|
+
standard = confine(source_root, "/".join((*BLEND_DIRECTORY, sample_name + BLEND_SUFFIX)))
|
|
150
|
+
if standard is not None:
|
|
151
|
+
return standard
|
|
152
|
+
seen = 0
|
|
153
|
+
for directory, subdirectories, files in os.walk(source_root, followlinks=False):
|
|
154
|
+
subdirectories[:] = [d for d in subdirectories if not os.path.islink(os.path.join(directory, d))]
|
|
155
|
+
for name in files:
|
|
156
|
+
seen += 1
|
|
157
|
+
if seen > MAX_WALK_ENTRIES:
|
|
158
|
+
return None
|
|
159
|
+
if name.lower() == wanted and not os.path.islink(os.path.join(directory, name)):
|
|
160
|
+
return Path(directory, name)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def parse_blend(path: Path) -> dict[str, Any]:
|
|
165
|
+
"""Parse a sfxBlend2D file into ``{eventName, ladders: [[[portable, rpm], ...], ...]}``.
|
|
166
|
+
|
|
167
|
+
Rpm values come straight from the file. Each ladder is sorted by rpm; a ladder with fewer than
|
|
168
|
+
two distinct rungs, a non-numeric rpm or a duplicate rpm is refused rather than repaired.
|
|
169
|
+
"""
|
|
170
|
+
if path.stat().st_size > MAX_BLEND_BYTES:
|
|
171
|
+
raise AudioUnavailable(f"sound blend {path.name} exceeds {MAX_BLEND_BYTES} bytes")
|
|
172
|
+
try:
|
|
173
|
+
body, _ = parse_jbeam_text(path.read_text(errors="ignore"))
|
|
174
|
+
except (ValueError, json.JSONDecodeError) as error:
|
|
175
|
+
raise AudioUnavailable(f"sound blend {path.name} is not valid JSON: {error}") from error
|
|
176
|
+
rows = body.get("samples")
|
|
177
|
+
if not isinstance(rows, list) or not rows:
|
|
178
|
+
raise AudioUnavailable(f"sound blend {path.name} has no `samples` ladders")
|
|
179
|
+
ladders: list[list[list[Any]]] = []
|
|
180
|
+
for row_index, row in enumerate(rows):
|
|
181
|
+
if not isinstance(row, list) or not row:
|
|
182
|
+
raise AudioUnavailable(f"sound blend {path.name} ladder {row_index} is empty")
|
|
183
|
+
ladder: list[list[Any]] = []
|
|
184
|
+
for rung in row:
|
|
185
|
+
if (not isinstance(rung, list) or len(rung) < 2 or not isinstance(rung[0], str) or not rung[0]
|
|
186
|
+
or isinstance(rung[1], bool) or not isinstance(rung[1], (int, float)) or rung[1] <= 0):
|
|
187
|
+
raise AudioUnavailable(
|
|
188
|
+
f"sound blend {path.name} ladder {row_index} has a rung that is not [file, rpm]: {rung!r}")
|
|
189
|
+
rpm = rung[1]
|
|
190
|
+
ladder.append([rung[0], int(rpm) if float(rpm).is_integer() else float(rpm)])
|
|
191
|
+
ladder.sort(key=lambda rung: rung[1])
|
|
192
|
+
rpms = [rung[1] for rung in ladder]
|
|
193
|
+
if len(set(rpms)) != len(rpms):
|
|
194
|
+
raise AudioUnavailable(f"sound blend {path.name} ladder {row_index} repeats an rpm value")
|
|
195
|
+
ladders.append(ladder)
|
|
196
|
+
event = body.get("eventName")
|
|
197
|
+
return {"eventName": event if isinstance(event, str) and event else "event:>Engine>default", "ladders": ladders}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------------------------
|
|
201
|
+
# Transcoding
|
|
202
|
+
# ---------------------------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def locate_ffmpeg() -> str | None:
|
|
205
|
+
explicit = os.environ.get("HELIX_FFMPEG")
|
|
206
|
+
if explicit:
|
|
207
|
+
return explicit if os.access(explicit, os.X_OK) else None
|
|
208
|
+
return shutil.which("ffmpeg")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def transcode_to_pcm16(ffmpeg: str, source: Path, target: Path, rate: int | None) -> None:
|
|
212
|
+
command = [ffmpeg, "-nostdin", "-v", "error", "-y", "-i", str(source), "-map", "0:a:0",
|
|
213
|
+
"-c:a", "pcm_s16le", "-fflags", "+bitexact", "-flags:a", "+bitexact"]
|
|
214
|
+
if rate:
|
|
215
|
+
command += ["-ar", str(rate)]
|
|
216
|
+
command.append(str(target))
|
|
217
|
+
try:
|
|
218
|
+
result = subprocess.run(command, capture_output=True, text=True, timeout=180)
|
|
219
|
+
except subprocess.TimeoutExpired as error:
|
|
220
|
+
raise AudioExtractionError(f"ffmpeg timed out transcoding {source.name}") from error
|
|
221
|
+
if result.returncode != 0 or not target.is_file() or target.stat().st_size == 0:
|
|
222
|
+
raise AudioExtractionError(f"ffmpeg could not transcode {source.name}: {result.stderr.strip()[:300]}")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def sha256_bytes(path: Path) -> tuple[str, int]:
|
|
226
|
+
digest = hashlib.sha256()
|
|
227
|
+
size = 0
|
|
228
|
+
with path.open("rb") as handle:
|
|
229
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
230
|
+
digest.update(chunk)
|
|
231
|
+
size += len(chunk)
|
|
232
|
+
return digest.hexdigest(), size
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def asset_file_name(source_path: str, taken: dict[str, str], source_digest: str) -> str:
|
|
236
|
+
"""A portable ``<stem>.wav`` name. Bare numbers (``1768.wav``) take their folder as a prefix and a
|
|
237
|
+
name already used by different bytes gets the content hash appended, so names stay unique."""
|
|
238
|
+
clean = lambda value: re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._-") # noqa: E731
|
|
239
|
+
path = Path(source_path.replace("\\", "/"))
|
|
240
|
+
stem = clean(path.stem) or "sample"
|
|
241
|
+
if stem.isdigit() and clean(path.parent.name):
|
|
242
|
+
stem = f"{clean(path.parent.name)}_{stem}"
|
|
243
|
+
name = f"{stem}.wav"
|
|
244
|
+
if taken.get(name, source_digest) != source_digest:
|
|
245
|
+
name = f"{stem}-{source_digest[:8]}.wav"
|
|
246
|
+
return name
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------------------------
|
|
250
|
+
# Public entry point
|
|
251
|
+
# ---------------------------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def stock_reason(event: str) -> str:
|
|
254
|
+
return (f"source uses BeamNG stock FMOD event {event}; no recording ships in the mod "
|
|
255
|
+
"— platform fallback engine")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def resolve_layers(source_root: Path, engine_name: str | None, exhaust_name: str | None) -> dict[str, Any]:
|
|
259
|
+
"""Classify the engine and exhaust layers as shipped-in-the-mod, stock-only, or absent."""
|
|
260
|
+
layers: dict[str, Any] = {}
|
|
261
|
+
for layer, name in (("engine", engine_name), ("exhaust", exhaust_name)):
|
|
262
|
+
clean = name.strip() if isinstance(name, str) else ""
|
|
263
|
+
if clean.lower() in DISABLED_SAMPLE_NAMES:
|
|
264
|
+
layers[layer] = {"state": "absent", "name": clean or None}
|
|
265
|
+
continue
|
|
266
|
+
blend = find_blend(source_root, clean)
|
|
267
|
+
layers[layer] = ({"state": "shipped", "name": clean, "blend": blend} if blend is not None
|
|
268
|
+
else {"state": "stock", "name": clean})
|
|
269
|
+
return layers
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def extract_engine_bank(source_root: Path, layers: dict[str, Any], audio_dir: Path,
|
|
273
|
+
ffmpeg: str | None = None) -> dict[str, Any]:
|
|
274
|
+
"""Write the WAVs for every shipped layer into ``audio_dir`` and return the bank description.
|
|
275
|
+
|
|
276
|
+
Returns ``{"blends": {NAME: {eventName, samples}}, "assets": [row, ...], "sources": [...],
|
|
277
|
+
"blendNames": {"engine": NAME | None, "exhaust": NAME | None}}``. Raises ``AudioExtractionError``
|
|
278
|
+
when the mod ships audio that cannot be turned into a faithful PCM WAV.
|
|
279
|
+
"""
|
|
280
|
+
source_root = source_root.resolve()
|
|
281
|
+
shipped: dict[str, dict[str, Any]] = {}
|
|
282
|
+
for layer, info in layers.items():
|
|
283
|
+
if info["state"] != "shipped":
|
|
284
|
+
continue
|
|
285
|
+
parsed = parse_blend(info["blend"])
|
|
286
|
+
shipped[info["name"]] = {"parsed": parsed, "blend": info["blend"]}
|
|
287
|
+
|
|
288
|
+
# Gather every distinct recording the shipped blends name, in blend then ladder order.
|
|
289
|
+
resolved: dict[str, Path] = {}
|
|
290
|
+
for name, entry in shipped.items():
|
|
291
|
+
missing: list[str] = []
|
|
292
|
+
for ladder in entry["parsed"]["ladders"]:
|
|
293
|
+
for portable, _rpm in ladder:
|
|
294
|
+
if portable in resolved:
|
|
295
|
+
continue
|
|
296
|
+
path = confine(source_root, portable)
|
|
297
|
+
if path is None:
|
|
298
|
+
missing.append(portable)
|
|
299
|
+
else:
|
|
300
|
+
resolved[portable] = path
|
|
301
|
+
if missing:
|
|
302
|
+
raise AudioUnavailable(
|
|
303
|
+
f"sound blend {name} names {len(missing)} recording(s) the mod does not contain, "
|
|
304
|
+
f"for example {missing[0]}")
|
|
305
|
+
if len(resolved) > MAX_SAMPLE_FILES:
|
|
306
|
+
raise AudioUnavailable(f"the mod's engine blends name {len(resolved)} recordings (limit {MAX_SAMPLE_FILES})")
|
|
307
|
+
total = 0
|
|
308
|
+
inspected: dict[str, dict[str, int] | None] = {}
|
|
309
|
+
for portable, path in resolved.items():
|
|
310
|
+
size = path.stat().st_size
|
|
311
|
+
if size > MAX_SAMPLE_BYTES:
|
|
312
|
+
raise AudioUnavailable(f"recording {portable} is {size} bytes (limit {MAX_SAMPLE_BYTES})")
|
|
313
|
+
total += size
|
|
314
|
+
inspected[portable] = inspect_wav(path)
|
|
315
|
+
if total > MAX_TOTAL_BYTES:
|
|
316
|
+
raise AudioUnavailable(f"the mod's engine recordings total {total} bytes (limit {MAX_TOTAL_BYTES})")
|
|
317
|
+
|
|
318
|
+
# One bank sample rate: the most common rate among the recordings, else 44.1 kHz.
|
|
319
|
+
rates = Counter(info["rate"] for info in inspected.values() if info)
|
|
320
|
+
bank_rate = next((rate for rate, _ in sorted(rates.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
321
|
+
if rate in BANK_RATES), DEFAULT_RATE)
|
|
322
|
+
|
|
323
|
+
def needs_transcode(portable: str) -> tuple[bool, int | None]:
|
|
324
|
+
info = inspected[portable]
|
|
325
|
+
if info is None or not is_runtime_pcm(info):
|
|
326
|
+
return True, bank_rate if (info is None or info["rate"] != bank_rate) else None
|
|
327
|
+
return (info["rate"] != bank_rate), (bank_rate if info["rate"] != bank_rate else None)
|
|
328
|
+
|
|
329
|
+
ffmpeg_needed = [p for p in resolved if needs_transcode(p)[0]]
|
|
330
|
+
if ffmpeg_needed and ffmpeg is None:
|
|
331
|
+
raise AudioExtractionError(
|
|
332
|
+
f"{len(ffmpeg_needed)} engine recording(s) (for example {ffmpeg_needed[0]}) are not 16-bit PCM WAV "
|
|
333
|
+
f"at {BANK_RATES[0]}/{BANK_RATES[1]} Hz and need ffmpeg to convert; install ffmpeg (or set "
|
|
334
|
+
"HELIX_FFMPEG) and run the import again. The importer never ships OGG/FLAC or fabricates samples.")
|
|
335
|
+
|
|
336
|
+
audio_dir.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
names_by_source: dict[str, str] = {}
|
|
338
|
+
digest_by_name: dict[str, str] = {}
|
|
339
|
+
rows: dict[str, dict[str, Any]] = {}
|
|
340
|
+
sources: list[dict[str, Any]] = []
|
|
341
|
+
with tempfile.TemporaryDirectory(prefix="beamng-audio-") as scratch:
|
|
342
|
+
for portable, path in resolved.items():
|
|
343
|
+
source_digest, _ = sha256_bytes(path)
|
|
344
|
+
name = asset_file_name(portable, digest_by_name, source_digest)
|
|
345
|
+
digest_by_name[name] = source_digest
|
|
346
|
+
names_by_source[portable] = name
|
|
347
|
+
target = audio_dir / name
|
|
348
|
+
transcode, rate = needs_transcode(portable)
|
|
349
|
+
if transcode:
|
|
350
|
+
staged = Path(scratch) / name
|
|
351
|
+
transcode_to_pcm16(ffmpeg, path, staged, rate) # type: ignore[arg-type]
|
|
352
|
+
shutil.move(str(staged), target)
|
|
353
|
+
else:
|
|
354
|
+
shutil.copyfile(path, target)
|
|
355
|
+
checksum, size = sha256_bytes(target)
|
|
356
|
+
rows[name] = {"name": name, "file": name, "path": f"audio/{name}", "role": ASSET_ROLE,
|
|
357
|
+
"mediaType": "audio/wav", "bytes": size, "checksumSha256": checksum,
|
|
358
|
+
"cid": f"cid:sha256:{checksum}"}
|
|
359
|
+
sources.append({"asset": name, "sourcePath": portable, "sourceSha256": source_digest,
|
|
360
|
+
"transcoded": transcode})
|
|
361
|
+
|
|
362
|
+
blends: dict[str, dict[str, Any]] = {}
|
|
363
|
+
for name, entry in shipped.items():
|
|
364
|
+
blends[name] = {"eventName": entry["parsed"]["eventName"],
|
|
365
|
+
"samples": [[[names_by_source[portable], rpm] for portable, rpm in ladder]
|
|
366
|
+
for ladder in entry["parsed"]["ladders"]]}
|
|
367
|
+
return {"blends": blends, "assets": [rows[name] for name in sorted(rows)], "sources": sources,
|
|
368
|
+
"sampleRate": bank_rate,
|
|
369
|
+
"blendNames": {layer: (info["name"] if info["state"] == "shipped" else None)
|
|
370
|
+
for layer, info in layers.items()}}
|
|
@@ -25,6 +25,8 @@ from beamng_jbeam import (JBeamDocumentCache, MAX_SELECTED_PART_INDEX_BYTES,
|
|
|
25
25
|
resolve_part_closure, selected_part_axle)
|
|
26
26
|
from beamng_expression import ExpressionResolution, resolve_beamng_expression
|
|
27
27
|
from beamng_abs import lower_selected_abs
|
|
28
|
+
from beamng_audio import (AudioExtractionError, AudioUnavailable, extract_engine_bank, locate_ffmpeg,
|
|
29
|
+
resolve_layers, stock_reason)
|
|
28
30
|
|
|
29
31
|
SCHEMA = "helix.beamng-functional-lowering/3"
|
|
30
32
|
SOURCE_SCHEMA = "helix.beamng-vehicle-source/1"
|
|
@@ -1525,6 +1527,8 @@ def main() -> None:
|
|
|
1525
1527
|
parser.add_argument("--part-index")
|
|
1526
1528
|
parser.add_argument("--abs-controller-receipt")
|
|
1527
1529
|
parser.add_argument("--common-root", action="append", default=[])
|
|
1530
|
+
parser.add_argument("--audio-dir", help="where the mod's own engine recordings are written as PCM WAV "
|
|
1531
|
+
"(default: <output dir>/audio)")
|
|
1528
1532
|
args = parser.parse_args()
|
|
1529
1533
|
|
|
1530
1534
|
source_root = Path(args.source_root).resolve()
|
|
@@ -2045,12 +2049,87 @@ def main() -> None:
|
|
|
2045
2049
|
candidate_sample = next((x for x in indexed_values(value_index, "sampleName")
|
|
2046
2050
|
if isinstance(x, str) and "exhaust" in x.lower()), None)
|
|
2047
2051
|
if candidate_sample: exhaust_sample = candidate_sample
|
|
2048
|
-
|
|
2052
|
+
declared_cylinders = sound.get("fundamentalFrequencyCylinderCount")
|
|
2053
|
+
cylinders = int(declared_cylinders) if isinstance(declared_cylinders, (int, float)) \
|
|
2054
|
+
and not isinstance(declared_cylinders, bool) and declared_cylinders >= 1 else 4
|
|
2055
|
+
audio_layers = resolve_layers(source_root, engine_sample, exhaust_sample)
|
|
2056
|
+
audio_bank: dict[str, Any] | None = None
|
|
2057
|
+
audio_unavailable: str | None = None
|
|
2058
|
+
if any(layer["state"] == "shipped" for layer in audio_layers.values()):
|
|
2059
|
+
try:
|
|
2060
|
+
audio_bank = extract_engine_bank(source_root, audio_layers,
|
|
2061
|
+
Path(args.audio_dir) if args.audio_dir else Path(args.output).parent / "audio",
|
|
2062
|
+
locate_ffmpeg())
|
|
2063
|
+
except AudioUnavailable as exc:
|
|
2064
|
+
audio_unavailable = str(exc)
|
|
2065
|
+
engine_sample = engine_sample if isinstance(engine_sample, str) and audio_layers["engine"]["state"] != "absent" else None
|
|
2066
|
+
exhaust_sample = exhaust_sample if isinstance(exhaust_sample, str) and audio_layers["exhaust"]["state"] != "absent" else None
|
|
2067
|
+
|
|
2068
|
+
def layer_reason(layer: str) -> str:
|
|
2069
|
+
state, name = audio_layers[layer]["state"], audio_layers[layer]["name"]
|
|
2070
|
+
if audio_unavailable and state == "shipped":
|
|
2071
|
+
return f"source blend {name} is unusable ({audio_unavailable}); platform fallback engine"
|
|
2072
|
+
if state == "stock":
|
|
2073
|
+
return stock_reason(name)
|
|
2074
|
+
return f"source declares no {layer} sound sample (sampleName {name!r}); platform fallback engine"
|
|
2075
|
+
|
|
2076
|
+
bank_blends = audio_bank["blends"] if audio_bank else {}
|
|
2077
|
+
engine_blend = audio_bank["blendNames"]["engine"] if audio_bank else None
|
|
2078
|
+
exhaust_blend = audio_bank["blendNames"]["exhaust"] if audio_bank else None
|
|
2079
|
+
exhaust_shares_engine = False
|
|
2080
|
+
if audio_bank and engine_blend and audio_layers["exhaust"]["state"] == "absent":
|
|
2081
|
+
# BeamNG mods such as the 993 disable the exhaust layer (sampleName "0") because the single
|
|
2082
|
+
# recording already carries the exhaust; the same set serves both HELIX ladders.
|
|
2083
|
+
exhaust_blend, exhaust_shares_engine = engine_blend, True
|
|
2084
|
+
|
|
2085
|
+
def ladder_note(blend_name: str) -> str:
|
|
2086
|
+
ladders = bank_blends[blend_name]["samples"]
|
|
2087
|
+
rungs = [rung[1] for rung in ladders[0]]
|
|
2088
|
+
return (f"{len(ladders[0])} rungs {min(rungs):g}-{max(rungs):g} rpm from the mod's own recording "
|
|
2089
|
+
f"(sfxBlend2D {blend_name}), {len(ladders)} load row(s).")
|
|
2090
|
+
|
|
2049
2091
|
audio_slots = []
|
|
2050
2092
|
for slot_name in AUDIO_SLOTS:
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2093
|
+
row: dict[str, Any] = {"slot": slot_name, "buffers": [], "consumed": False,
|
|
2094
|
+
"reason": "No selected source event was mapped to this HELIX slot."}
|
|
2095
|
+
if slot_name in ("engineLadder", "exhaustLadder"):
|
|
2096
|
+
layer = "engine" if slot_name == "engineLadder" else "exhaust"
|
|
2097
|
+
blend_name = engine_blend if layer == "engine" else exhaust_blend
|
|
2098
|
+
sample = engine_sample if layer == "engine" else exhaust_sample
|
|
2099
|
+
if blend_name:
|
|
2100
|
+
note = ladder_note(blend_name)
|
|
2101
|
+
if layer == "exhaust" and exhaust_shares_engine:
|
|
2102
|
+
declared = audio_layers["exhaust"]["name"]
|
|
2103
|
+
note = (f"The source declares no separate exhaust sample (sampleName {declared!r}); the engine "
|
|
2104
|
+
"recording is one combined set used on both ladders. " + note) if declared is not None else \
|
|
2105
|
+
("The source declares no separate exhaust sample; the engine recording is one combined "
|
|
2106
|
+
"set used on both ladders. " + note)
|
|
2107
|
+
row = {"slot": slot_name, "buffers": [f"blend:{blend_name}"], "consumed": True, "note": note}
|
|
2108
|
+
else:
|
|
2109
|
+
row = {"slot": slot_name, "buffers": [sample] if sample and not audio_bank else [],
|
|
2110
|
+
"consumed": False, "reason": layer_reason(layer)}
|
|
2111
|
+
audio_slots.append(row)
|
|
2112
|
+
|
|
2113
|
+
if audio_bank:
|
|
2114
|
+
bank_source = source_label(Path(audio_layers["engine"]["blend"] if audio_layers["engine"]["state"] == "shipped"
|
|
2115
|
+
else audio_layers["exhaust"]["blend"]))
|
|
2116
|
+
for layer, info in audio_layers.items():
|
|
2117
|
+
if info["state"] == "shipped":
|
|
2118
|
+
prov(f"audio.bank.blends.{info['name']}", source_label(info["blend"]), "source-literal",
|
|
2119
|
+
"rpm ladders copied from the mod's own sfxBlend2D file; rpm values are the source's.")
|
|
2120
|
+
for origin in audio_bank["sources"]:
|
|
2121
|
+
prov(f"audio.bank.assets.{origin['asset']}", source_label(source_root / origin["sourcePath"]),
|
|
2122
|
+
"derived" if origin["transcoded"] else "source-literal",
|
|
2123
|
+
"Recording shipped in the mod; transcoded to 16-bit PCM WAV." if origin["transcoded"]
|
|
2124
|
+
else "Recording shipped in the mod, copied byte for byte.")
|
|
2125
|
+
if not isinstance(declared_cylinders, (int, float)):
|
|
2126
|
+
prov("audio.sampleCylinderCount", bank_source, "defaulted",
|
|
2127
|
+
"BeamNG does not declare how many cylinders the recorded engine had; defaulted to 4. "
|
|
2128
|
+
"Set audio.sampleCylinderCount to the recorded engine's cylinder count.")
|
|
2129
|
+
elif audio_unavailable or engine_sample or exhaust_sample:
|
|
2130
|
+
prov("audio.bank", "selected soundConfig sampleName", "unsupported",
|
|
2131
|
+
audio_unavailable or "Source uses BeamNG stock FMOD events; no recording ships in the mod, so the "
|
|
2132
|
+
"platform fallback engine plays.")
|
|
2054
2133
|
|
|
2055
2134
|
engine_spec = {"torqueCurve": {"points": torque_curve}, "idleRpm": idle, "limiterRpm": limiter,
|
|
2056
2135
|
"revLimiterType": "soft", "inertia": inertia, "friction": friction,
|
|
@@ -2144,7 +2223,9 @@ def main() -> None:
|
|
|
2144
2223
|
else sourced(fallback, unit, note="Provisional functional acceptance target; no BeamNG literal supplied.")) | {"tolerance": .35}
|
|
2145
2224
|
for name, (raw, fallback, unit) in target_specs.items()},
|
|
2146
2225
|
"audio": {"bankId": package_id, "sampleCylinderCount": cylinders, "strokes": 4,
|
|
2147
|
-
**({"engineBlend":
|
|
2226
|
+
**({"engineBlend": engine_blend} if engine_blend else {"engineBlend": engine_sample} if engine_sample and not audio_bank else {}),
|
|
2227
|
+
**({"exhaustBlend": exhaust_blend} if exhaust_blend else {"exhaustBlend": exhaust_sample} if exhaust_sample and not audio_bank else {}),
|
|
2228
|
+
**({"bank": {"blends": audio_bank["blends"], "assets": audio_bank["assets"]}} if audio_bank else {}),
|
|
2148
2229
|
"slots": audio_slots, "engineGainDb": float(sound.get("mainGain", 0) or 0), "exhaustGainDb": float(exhaust.get("mainGain", 0) or 0),
|
|
2149
2230
|
"minLoadMix": float(sound.get("minLoadMix", 0) or 0), "maxLoadMix": float(sound.get("maxLoadMix", 1) or 1), "muffling": float(sound.get("intakeMuffling", 0) or 0)},
|
|
2150
2231
|
"lights": {"lamps": lamps, "indicatorHz": 1.5, "decelBrakeLightThreshold": 4},
|
|
@@ -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.
|
|
5
|
+
"version": "0.7.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": [
|