From f4e224e218f56f5dd47e942995a56fcd955e33b1 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sat, 8 Aug 2026 22:52:46 +0200 Subject: [PATCH 1/3] chore(backlog): mark CHORE-UNTRACK-BUILT-ENGINE merged (PR #110) Carried here rather than into a one-line PR of its own, per Zip. The convention is to set the index line inside the lot's own PR; #110 shipped without it. Co-Authored-By: Claude Opus 5 --- .backlog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.backlog/README.md b/.backlog/README.md index 5fcf2bd..7f0158e 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -14,7 +14,7 @@ authored **per lot, when the lot is started** (not in batch). | Lot | Status | Description | Branch | |-----|--------|-------------|--------| -| `CHORE-UNTRACK-BUILT-ENGINE` | in progress | `CTLD.lua` is generated and committed anyway. `.gitignore` line 5 calls it deliberate — *"available at repo root for DCS missions"* — a bootstrap-era reason that no longer holds: nothing points at it, and **VMCT, the assumed consumer, does not** (its `vendored.yaml` pins `2.0.0-rc3` with *"re-download the CTLD.lua asset from the matching release"* and watches `github-release`). The cost is paid every time: **26 of the 28 merges touching `src/` over 30 days carried the regenerated file** — a one-megabyte generated diff nobody reviews, and a guaranteed conflict between parallel PRs. The trap, measured rather than assumed: `python-quality` runs on ubuntu and never builds the engine, so deleting the file alone would drop the suite from **262 passed** to **234 passed / 27 skipped / 1 failed** while CI stayed green. So: build the engine in that job first (`merge_CTLD.ps1` made portable — two `\` paths), fix `test_inject_into_miz` (it crashes instead of skipping), then untrack. Depends on `FEAT-DEV-BUILD-CHANNEL`, which is what keeps the engine downloadable. No history rewriting (471 blobs = 2.8 MiB packed). | `chore/untrack-built-engine` | +| `CHORE-UNTRACK-BUILT-ENGINE` | merged (PR #110) | `CTLD.lua` is generated and committed anyway. `.gitignore` line 5 calls it deliberate — *"available at repo root for DCS missions"* — a bootstrap-era reason that no longer holds: nothing points at it, and **VMCT, the assumed consumer, does not** (its `vendored.yaml` pins `2.0.0-rc3` with *"re-download the CTLD.lua asset from the matching release"* and watches `github-release`). The cost is paid every time: **26 of the 28 merges touching `src/` over 30 days carried the regenerated file** — a one-megabyte generated diff nobody reviews, and a guaranteed conflict between parallel PRs. The trap, measured rather than assumed: `python-quality` runs on ubuntu and never builds the engine, so deleting the file alone would drop the suite from **262 passed** to **234 passed / 27 skipped / 1 failed** while CI stayed green. So: build the engine in that job first (`merge_CTLD.ps1` made portable — two `\` paths), fix `test_inject_into_miz` (it crashes instead of skipping), then untrack. Depends on `FEAT-DEV-BUILD-CHANNEL`, which is what keeps the engine downloadable. No history rewriting (471 blobs = 2.8 MiB packed). | `chore/untrack-built-engine` | | `FEAT-CUSTOM-BEACON-SOUNDS` | planned | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` | | `FEAT-DEV-BUILD-CHANNEL` | merged (PR #109) | An exe to hand a tester between two releases. Zip's first idea — the exe grafting an arbitrary `CTLD.lua` into a copy of itself — **works** (verified: rc6 + 1.17 MB appended still runs) and was dropped anyway: it pairs a new engine with the exe's older schema and interface, an unsigned exe altered after the build reads as tampered, and `--version` would keep lying. The `build-exe` job already produces a complete exe from a commit in **2 min 06 s** on free public-repo runners; it only lacked a trigger. Built on every merge into `develop`, published as an artifact **and** a floating `dev` pre-release (an artifact answers `401` to an anonymous download), versioned `-`. | `feature/dev-build-channel` | | [`FIX-FIELD-EXTRACT-CASUALTIES`](FIX-FIELD-EXTRACT-CASUALTIES/PRD.md) | merged (PR #111) | Field extraction (`embarkFromField`) returns the troop count frozen at deploy time instead of the survivor count — an undeclared legacy-parity deviation. Fix counts live DCS units (excluding `SVNT_*` servants) at extraction time; adds troop counts to the "Extract from field" menu labels; auto-despawns an orphaned mortar servant when its operator dies leaving zero real troops. Also fixed a pre-existing bug where `onUnitDead` never fired in-game. | `fix/field-extract-casualties` | From fdb58a4e14fcfca5231b6427fbc10e392214e915 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sun, 9 Aug 2026 10:57:18 +0200 Subject: [PATCH 2/3] feat(tool): a beacon sound the Mission Maker chooses (FEAT-CUSTOM-BEACON-SOUNDS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit radioSound and radioSoundFC3 were text boxes: typing a name changed what the engine plays without putting any such file in the mission, so using your own beacon tone meant editing the .miz by hand — the manual step FEAT-ONE-CLICK-INSTALL exists to remove. Each now gets a Default / Custom picker. A chosen .ogg is read at selection, kept in the session, and written into the archive with its resource key and preload trigger like the bundled ones. The model, per ADR 0012: a chosen file enters the mission under a reserved name (CTLD_beacon_custom.ogg), so the configuration value itself says the sound is customised — no second key that could contradict the engine, and no misreading of a Mission Maker whose own file is called beacon.ogg. The name it had on disk survives as a schema-only label, never catalogued: a catalogue entry would be a parameter under ADR 0011 Addendum 1, so completeness would demand it and every pre-lot configuration would report a missing setting at mission start (FIX-TOOL-I18N-LANG's wall). Reading the bytes at selection rather than at install is what makes an installed mission reconfigurable: reopening the .miz recovers the sound, so it reinstalls on another machine with the original file deleted. A .yaml cannot carry a binary, so reopening one blocks the install with a validation error naming the file to pick again — unless the target mission already holds it. An OggS signature check catches the renamed .mp3 that would give silent beacons discovered in flight; no size cap, the size is reported instead. The picker is bound to `editor: sound` in the schema, never to a setting name in a component (FEAT-EDITOR-COVERAGE), and `hidden: true` keeps the labels out of the families and out of search. Typing a file name by hand still works for a sound added through the Mission Editor. Documented EN + FR, in the tool guide and the configuration reference. Co-Authored-By: Claude Opus 5 --- .../01-schema-sound-editor-and-labels.md | 12 +- .../02-session-holds-the-sound-bytes.md | 11 +- .../tickets/03-install-the-chosen-sound.md | 15 +- .../tickets/04-validate-the-sound.md | 13 +- .../tickets/05-the-picker-in-the-interface.md | 12 +- .../tickets/06-documentation.md | 7 +- .backlog/README.md | 2 +- CHANGELOG.md | 17 ++ docs/mission-maker/configuration.fr.md | 4 +- docs/mission-maker/configuration.md | 4 +- docs/mission-maker/ctld-tools.fr.md | 28 +++ docs/mission-maker/ctld-tools.md | 27 ++ src/CTLD_config_schema.yaml | 27 ++ .../ctld_tools/data/locales/en.json | 9 + .../ctld_tools/data/locales/fr.json | 9 + tools/ctld-tools/ctld_tools/install.py | 109 ++++++++- tools/ctld-tools/ctld_tools/resources.py | 36 +++ tools/ctld-tools/ctld_tools/schema.py | 20 ++ tools/ctld-tools/ctld_tools/validate.py | 30 +++ tools/ctld-tools/ctld_tools/web/app.py | 160 +++++++++++- tools/ctld-tools/ctld_tools/web/dialogs.py | 9 + tools/ctld-tools/ctld_tools/web/state.py | 37 ++- tools/ctld-tools/tests/test_schema.py | 32 ++- tools/ctld-tools/tests/test_sounds.py | 231 ++++++++++++++++++ tools/ctld-tools/tests/test_web_app.py | 10 + tools/ctld-tools/web/src/App.svelte | 34 ++- .../ctld-tools/web/src/lib/SettingRow.svelte | 14 +- .../ctld-tools/web/src/lib/SoundPicker.svelte | 129 ++++++++++ .../web/src/lib/SoundPicker.test.ts | 108 ++++++++ tools/ctld-tools/web/src/lib/api.ts | 35 +++ tools/ctld-tools/web/src/lib/model.test.ts | 30 ++- tools/ctld-tools/web/src/lib/model.ts | 15 +- tools/ctld-tools/web/src/lib/strings.ts | 9 + 33 files changed, 1184 insertions(+), 61 deletions(-) create mode 100644 tools/ctld-tools/tests/test_sounds.py create mode 100644 tools/ctld-tools/web/src/lib/SoundPicker.svelte create mode 100644 tools/ctld-tools/web/src/lib/SoundPicker.test.ts diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/01-schema-sound-editor-and-labels.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/01-schema-sound-editor-and-labels.md index 5acf28e..6856049 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/01-schema-sound-editor-and-labels.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/01-schema-sound-editor-and-labels.md @@ -1,6 +1,6 @@ # 01 — The schema declares the sound editor and the original-name labels -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -24,7 +24,9 @@ this lot would report a missing setting at mission start — `FIX-TOOL-I18N-LANG ## Acceptance -- [ ] `Schema.editor("radioSound") == "sound"`; a setting without the field returns `None`. -- [ ] The two label keys are absent from `src/CTLD_config.yaml`, so `validate` on an untouched - pre-lot configuration reports nothing new (test with a `2.0.0-rc6` config). -- [ ] The header comment of the schema documents `editor:`. +- [x] `Schema.editor("radioSound") == "sound"`; a setting without the field returns `None`. +- [x] The two label keys are absent from `src/CTLD_config.yaml` (asserted in `test_schema.py`), so + the completeness rule never demands them; the whole suite still passes on the shipped + catalogue. +- [x] The header comment of the schema documents `editor:` and `hidden:`. +- [x] `/api/schema` carries both, so the UI binds on metadata rather than on a setting name. diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/02-session-holds-the-sound-bytes.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/02-session-holds-the-sound-bytes.md index 42a47f8..67509ba 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/02-session-holds-the-sound-bytes.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/02-session-holds-the-sound-bytes.md @@ -1,6 +1,6 @@ # 02 — The session holds the sound, not a path -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -25,7 +25,8 @@ it, which ticket 04 turns into a blocking error rather than a surprise. ## Acceptance -- [ ] Choosing a file then installing writes those bytes, with the source file deleted in between. -- [ ] Opening a `.miz` with custom sounds, then installing into a **different** mission, reproduces - them — no access to the original file. -- [ ] Loading the defaults after a custom choice leaves no bytes behind. +- [x] Choosing a file then installing writes those bytes, with the source file deleted in between + (`test_a_custom_sound_survives_install_reopen_and_reinstall`). +- [x] Opening a `.miz` with custom sounds, then installing into a **different** mission, reproduces + them byte for byte — the original file is `unlink()`ed and the session reset in between. +- [x] Loading the defaults, a `.yaml`, or resetting the session leaves no bytes behind. diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/03-install-the-chosen-sound.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/03-install-the-chosen-sound.md index b0fdcfb..3954ebd 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/03-install-the-chosen-sound.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/03-install-the-chosen-sound.md @@ -1,6 +1,6 @@ # 03 — Install writes the chosen sound under its reserved name -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -22,9 +22,10 @@ and whether it was the default or a chosen file. ## Acceptance -- [ ] A custom install puts `CTLD_beacon_custom.ogg` in `l10n/DEFAULT/`, in `mapResource`, and in - the preload trigger — same three places as a default one. -- [ ] `radioSound` in the injected configuration matches the file actually written. -- [ ] Reinstalling over the same mission replaces rather than accumulates (existing idempotence - test, extended to the custom case). -- [ ] Going back to the default restores `beacon.ogg` and leaves the mission playable. +- [x] A custom install puts `CTLD_beacon_custom.ogg` in `l10n/DEFAULT/`, in `mapResource` (via + `sound_key`) and in the preload trigger — the same three places as a bundled one. +- [x] `radioSound` in the injected configuration matches the file actually written; the report now + carries `{setting, file, size, custom}` per sound. +- [x] Reinstalling over the same mission still replaces rather than accumulates (the existing + idempotence tests pass unchanged). +- [x] Going back to the default restores `beacon.ogg`. diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/04-validate-the-sound.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/04-validate-the-sound.md index 7d20b04..da5e51d 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/04-validate-the-sound.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/04-validate-the-sound.md @@ -1,6 +1,6 @@ # 04 — A sound that cannot be produced blocks the install -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -23,7 +23,10 @@ Two ways to end up with silent beacons, both invisible until someone flies: ## Acceptance -- [ ] A non-Ogg file is refused at selection; the configuration is unchanged. -- [ ] Opening a `.yaml` that names a custom sound, then targeting a mission without it → blocking - error, install refused. -- [ ] Same configuration, but the target mission already holds the file → no error, install allowed. +- [x] A non-Ogg file is refused at selection (422, `OggS` named in the message) and the + configuration is left pointing at the bundled sound. +- [x] Opening a `.yaml` that names a custom sound, then targeting a mission without it → blocking + `validate.sound.missing`, install refused. +- [x] Same configuration, but the target mission already holds the file → no error, install allowed + and the existing bytes rewritten. +- [x] No size cap; the size is reported instead. diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/05-the-picker-in-the-interface.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/05-the-picker-in-the-interface.md index f50a185..4ff98ce 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/05-the-picker-in-the-interface.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/05-the-picker-in-the-interface.md @@ -1,6 +1,6 @@ # 05 — Default or custom, in the interface -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -23,7 +23,9 @@ Both sounds are independent: one may be custom and the other default. ## Acceptance -- [ ] Neither `radioSound` nor `radioSoundFC3` appears as a literal in any component. -- [ ] Reopening a mission with a custom sound shows *Custom* and the original file name. -- [ ] A component test covers the round trip Default → Custom → Default. -- [ ] EN and FR strings, in step (the interface's own parity test). +- [x] Neither setting name appears in any component: the picker is bound to `meta.editor === 'sound'`. +- [x] Reopening a mission with a custom sound shows *Custom*, the original file name, the size and + the reserved name; an unavailable file gets its own warning line. +- [x] `SoundPicker.test.ts` covers Default → Custom → Default, cancellation, and a refusal from the + backend (7 tests). The hidden labels are excluded from the families and from search. +- [x] EN and FR strings in step — `i18n.parity.test.ts` passes. diff --git a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/06-documentation.md b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/06-documentation.md index d7a9e1f..ecb6a22 100644 --- a/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/06-documentation.md +++ b/.backlog/FEAT-CUSTOM-BEACON-SOUNDS/tickets/06-documentation.md @@ -1,6 +1,6 @@ # 06 — Document the custom sounds -**Status:** todo +**Status:** done **Lot:** FEAT-CUSTOM-BEACON-SOUNDS ## Problem @@ -20,5 +20,6 @@ file, and both would be wrong the day this ships. ## Acceptance -- [ ] EN and FR say the same thing, FR anchors matching their EN counterparts. -- [ ] No claim about DCS audio formats that has not been verified. +- [x] EN and FR say the same thing; the FR section carries `{ #beacon-sounds }`. +- [x] No claim about DCS audio formats beyond what the code enforces: the docs say `.ogg` and that + a non-Ogg file is refused, and never assert what else DCS might accept. diff --git a/.backlog/README.md b/.backlog/README.md index 7f0158e..2afea02 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -15,7 +15,7 @@ authored **per lot, when the lot is started** (not in batch). | Lot | Status | Description | Branch | |-----|--------|-------------|--------| | `CHORE-UNTRACK-BUILT-ENGINE` | merged (PR #110) | `CTLD.lua` is generated and committed anyway. `.gitignore` line 5 calls it deliberate — *"available at repo root for DCS missions"* — a bootstrap-era reason that no longer holds: nothing points at it, and **VMCT, the assumed consumer, does not** (its `vendored.yaml` pins `2.0.0-rc3` with *"re-download the CTLD.lua asset from the matching release"* and watches `github-release`). The cost is paid every time: **26 of the 28 merges touching `src/` over 30 days carried the regenerated file** — a one-megabyte generated diff nobody reviews, and a guaranteed conflict between parallel PRs. The trap, measured rather than assumed: `python-quality` runs on ubuntu and never builds the engine, so deleting the file alone would drop the suite from **262 passed** to **234 passed / 27 skipped / 1 failed** while CI stayed green. So: build the engine in that job first (`merge_CTLD.ps1` made portable — two `\` paths), fix `test_inject_into_miz` (it crashes instead of skipping), then untrack. Depends on `FEAT-DEV-BUILD-CHANNEL`, which is what keeps the engine downloadable. No history rewriting (471 blobs = 2.8 MiB packed). | `chore/untrack-built-engine` | -| `FEAT-CUSTOM-BEACON-SOUNDS` | planned | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` | +| `FEAT-CUSTOM-BEACON-SOUNDS` | in progress | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` | | `FEAT-DEV-BUILD-CHANNEL` | merged (PR #109) | An exe to hand a tester between two releases. Zip's first idea — the exe grafting an arbitrary `CTLD.lua` into a copy of itself — **works** (verified: rc6 + 1.17 MB appended still runs) and was dropped anyway: it pairs a new engine with the exe's older schema and interface, an unsigned exe altered after the build reads as tampered, and `--version` would keep lying. The `build-exe` job already produces a complete exe from a commit in **2 min 06 s** on free public-repo runners; it only lacked a trigger. Built on every merge into `develop`, published as an artifact **and** a floating `dev` pre-release (an artifact answers `401` to an anonymous download), versioned `-`. | `feature/dev-build-channel` | | [`FIX-FIELD-EXTRACT-CASUALTIES`](FIX-FIELD-EXTRACT-CASUALTIES/PRD.md) | merged (PR #111) | Field extraction (`embarkFromField`) returns the troop count frozen at deploy time instead of the survivor count — an undeclared legacy-parity deviation. Fix counts live DCS units (excluding `SVNT_*` servants) at extraction time; adds troop counts to the "Extract from field" menu labels; auto-despawns an orphaned mortar servant when its operator dies leaving zero real troops. Also fixed a pre-existing bug where `onUnitDead` never fired in-game. | `fix/field-extract-casualties` | | [`FIX-MENU-DOUBLE-MULTICREW`](FIX-MENU-DOUBLE-MULTICREW/PRD.md) | merged (PR #106) | F10 menu duplication on multi-crew aircraft (CH-47 pilot + copilot); menu loss when one crew member leaves a shared group. | — | diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f7856..e992848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,23 @@ Versioning follows [Semantic Versioning](https://semver.org/). every other bridge-registered death handler in the codebase. A group that loses its last real trooper while a mortar servant survives is now despawned reactively at that moment. +### Added — beacon sounds a Mission Maker can choose (FEAT-CUSTOM-BEACON-SOUNDS) + +- **`ctld-tools` can install your own beacon sound.** The two sound settings (`radioSound`, + `radioSoundFC3`) are no longer free-text boxes naming a file the tool never installed: each gets + a **Default / Custom** picker, and a chosen `.ogg` is written into the `.miz` with its resource + key and preload trigger, exactly like the bundled ones. The file is checked for an `OggS` + signature when picked — a renamed `.mp3` would play nothing in DCS. +- **A custom sound travels in the mission.** Reopening an installed `.miz` recovers it, so the + mission can be reconfigured and reinstalled on another machine after the original file is gone. + A configuration saved as `.yaml` carries only the name, so reopening one asks for the file again + and blocks installation until it is supplied (unless the target mission already holds it). +- **Schema:** a sound chosen through the tool enters the mission under a reserved name + (`CTLD_beacon_custom.ogg` / `CTLD_beaconsilent_custom.ogg`) — see ADR 0012 — with the name it had + on disk kept as a label (`radioSoundOriginalName`, `radioSoundFC3OriginalName`, schema-only, so no + existing configuration reports a missing setting). Typing a file name by hand is unchanged and + still supported for a sound added through the Mission Editor. + ### Fixed — F10 menu duplication and multi-crew menu loss (FIX-MENU-DOUBLE-MULTICREW) - **F10 menu no longer duplicates** when a second crew member joins a multi-crew aircraft diff --git a/docs/mission-maker/configuration.fr.md b/docs/mission-maker/configuration.fr.md index 4e514b6..60ae242 100644 --- a/docs/mission-maker/configuration.fr.md +++ b/docs/mission-maker/configuration.fr.md @@ -197,8 +197,8 @@ aléatoire de 90–120 % de `SOLDIER_WEIGHT`, plus l'équipement et le matériel |---|---|---| | `enabledRadioBeaconDrop` | `true` | Autorise le déploiement de beacon | | `deployedBeaconBattery` | `30` | Durée de vie de la batterie du beacon (minutes) | -| `radioSound` | `"beacon.ogg"` | Fichier son du beacon — l'outil le place dans le `.miz` ; si vous installez à la main, ajoutez-le vous-même, sinon les balises resteront muettes | -| `radioSoundFC3` | `"beaconsilent.ogg"` | Fichier de beacon silencieux pour les appareils FC3, même règle | +| `radioSound` | `"beacon.ogg"` | Fichier son du beacon — l'outil le place dans le `.miz` ; si vous installez à la main, ajoutez-le vous-même, sinon les balises resteront muettes. `CTLD_beacon_custom.ogg` désigne [un son que vous avez choisi](ctld-tools.fr.md#beacon-sounds) | +| `radioSoundFC3` | `"beaconsilent.ogg"` | Fichier de beacon silencieux pour les appareils FC3, même règle (personnalisé : `CTLD_beaconsilent_custom.ogg`) | ### Systèmes AA { #aa-systems } diff --git a/docs/mission-maker/configuration.md b/docs/mission-maker/configuration.md index 20ab03b..f200cef 100644 --- a/docs/mission-maker/configuration.md +++ b/docs/mission-maker/configuration.md @@ -191,8 +191,8 @@ randomised 90–120 % of `SOLDIER_WEIGHT`, plus kit and role-specific gear. |---|---|---| | `enabledRadioBeaconDrop` | `true` | Allow beacon deployment | | `deployedBeaconBattery` | `30` | Beacon battery life (minutes) | -| `radioSound` | `"beacon.ogg"` | Beacon sound file — the tool puts it in the `.miz`; if you install by hand, add it yourself or beacons stay silent | -| `radioSoundFC3` | `"beaconsilent.ogg"` | Silent beacon file for FC3 aircraft, same rule | +| `radioSound` | `"beacon.ogg"` | Beacon sound file — the tool puts it in the `.miz`; if you install by hand, add it yourself or beacons stay silent. `CTLD_beacon_custom.ogg` means [a sound you chose](ctld-tools.md#beacon-sounds) | +| `radioSoundFC3` | `"beaconsilent.ogg"` | Silent beacon file for FC3 aircraft, same rule (custom: `CTLD_beaconsilent_custom.ogg`) | ### AA systems diff --git a/docs/mission-maker/ctld-tools.fr.md b/docs/mission-maker/ctld-tools.fr.md index a631062..1b27689 100644 --- a/docs/mission-maker/ctld-tools.fr.md +++ b/docs/mission-maker/ctld-tools.fr.md @@ -98,6 +98,34 @@ Les entrées de catalogue — **caisses**, **groupes de troupes**, **capacités type d'aéronef dans la liste DCS), **zones**, noms des pilotes de transport, poids des véhicules — s'éditent sous forme de tableaux, en bas de la famille à laquelle elles appartiennent. +### Sons des balises { #beacon-sounds } + +Les deux sons de balise — l'audible et le silencieux réservé aux appareils FC3 — sont le seul +endroit où un réglage est un **fichier** : ils ont donc un sélecteur plutôt qu'une zone de texte. +**Par défaut** utilise le son embarqué dans l'outil, **Personnalisé** ouvre un explorateur de +fichiers pour votre propre `.ogg`. + +Le fichier que vous choisissez est lu **immédiatement** et voyage dans la mission. C'est plus +important qu'il n'y paraît : rouvrir ce `.miz` plus tard en ramène le son, donc vous pouvez +reconfigurer et réinstaller la mission **sur une autre machine, des mois après, même si vous avez +supprimé le fichier d'origine**. + +Deux détails à connaître : + +- **Dans la mission, le fichier est renommé** en `CTLD_beacon_custom.ogg` (ou + `CTLD_beaconsilent_custom.ogg`). Votre fichier à vous n'est pas touché, et l'outil conserve son + nom pour vous l'afficher — ce nom réservé est ce qui lui permet de distinguer votre son de celui + d'origine, même si le vôtre s'appelle lui aussi `beacon.ogg`. +- **Un `.yaml` enregistré ne peut pas porter le son**, seulement son nom. Si vous rouvrez une telle + configuration, l'outil signalera le fichier manquant et refusera d'installer tant que vous ne + l'aurez pas resélectionné — mieux vaut ça qu'une mission dont les balises sont muettes sans que + rien ne l'explique. Si la mission visée contient déjà le fichier, il n'y a rien à faire. + +!!! tip "Ajouter le son vous-même" + Saisir un nom de fichier fonctionne toujours, pour un son que vous avez ajouté via l'éditeur de + mission : CTLD joue le `l10n/DEFAULT/` que vous lui indiquez. Le sélecteur est la voie + recommandée ; le champ texte reste là pour une mission câblée à la main. + ### Annuler une modification { #undoing-a-change } Tout réglage que vous modifiez est marqué **modifié**, et la famille reçoit un compteur dans la diff --git a/docs/mission-maker/ctld-tools.md b/docs/mission-maker/ctld-tools.md index a86f71e..f1a1fe2 100644 --- a/docs/mission-maker/ctld-tools.md +++ b/docs/mission-maker/ctld-tools.md @@ -89,6 +89,33 @@ Catalogue entries — **crates**, **troop groups**, **aircraft capabilities** (p from the DCS list), **zones**, transport pilot names, vehicle weights — are edited as tables, at the bottom of the family they belong to. +### Beacon sounds + +The two beacon sounds — the audible one and the silent one FC3 aircraft use — are the one place +where a setting is a **file**, so they get a picker rather than a text box: **Default** uses the +sound the tool carries, **Custom** opens a file browser for your own `.ogg`. + +A file you choose is read **immediately** and travels inside the mission. That matters more than it +sounds: reopening that `.miz` later brings the sound back with it, so you can reconfigure and +reinstall the mission **on another machine, months later, even if you have deleted the file you +originally picked**. + +Two details worth knowing: + +- **Inside the mission the file is renamed** to `CTLD_beacon_custom.ogg` (or + `CTLD_beaconsilent_custom.ogg`). Your own file is untouched, and the tool keeps its name to show + you — the reserved name is what lets it tell your sound apart from the bundled one, even when + yours happens to be called `beacon.ogg` too. +- **A saved `.yaml` cannot carry the sound**, only its name. Reopen such a configuration and the + tool will say the file is missing and refuse to install until you pick it again — better than a + mission whose beacons are silent for reasons nobody can see. If the mission you are installing + into already holds the file, there is nothing to do. + +!!! tip "Adding the sound yourself" + Typing a file name still works, for a sound you added through the Mission Editor: CTLD plays + whatever `l10n/DEFAULT/` you point it at. The picker is the supported path; the text + field remains for a mission wired by hand. + ### Undoing a change Any setting you change is marked **changed**, and the family gets a counter in the left column, so diff --git a/src/CTLD_config_schema.yaml b/src/CTLD_config_schema.yaml index f18d5b4..ac53abe 100644 --- a/src/CTLD_config_schema.yaml +++ b/src/CTLD_config_schema.yaml @@ -5,6 +5,11 @@ # group: functional family key (see the `families:` section below) # standard: true = shown under "Common settings"; false/absent = Advanced # choices: allowed values (a fixed enum) — offered as a value list +# editor: the control the UI must use when the value's type is not enough to pick one +# (`sound` = the default/custom beacon-sound picker). Declared here rather than +# keyed off setting names inside a component, per FEAT-EDITOR-COVERAGE. +# hidden: true = written by the tool beside another setting, never listed as a setting +# of its own (the *OriginalName labels — see ADR 0012) # label: { en, fr } short display name, shown instead of the raw config key # unit: unit symbol shown next to a numeric value ("m", "s", "min", "m/s", "kg"). # Not translated — symbols are language-independent. Each one was traced to @@ -45,6 +50,7 @@ location_DMS: radioSound: group: general standard: true + editor: sound label: {en: Beacon sound file, fr: Fichier son du beacon} description: en: Beacon sound file — must be added to the mission .miz, or beacons will not work @@ -52,10 +58,31 @@ radioSound: radioSoundFC3: group: general standard: true + editor: sound label: {en: Silent beacon file for FC3, fr: Fichier de beacon silencieux FC3} description: en: Silent beacon file for FC3 aircraft fr: Fichier de beacon silencieux pour les appareils FC3 +# The two labels below record the name a customised sound had on the Mission Maker's disk, which +# the reserved in-mission name (ADR 0012) would otherwise lose. Written by the tool, read only by +# it, never shown as a setting (`hidden`), and deliberately absent from CTLD_config.yaml: a +# catalogue entry would make them *parameters* under ADR 0011 Addendum 1, so the completeness rule +# would demand them of every snapshot — FIX-TOOL-I18N-LANG's wall. No `default:` either: absent +# means the sound is the bundled one. +radioSoundOriginalName: + group: general + hidden: true + label: {en: Original name of the custom beacon sound, fr: Nom d'origine du son de balise personnalisé} + description: + en: File name the custom beacon sound had when it was chosen — shown in the tool, ignored by CTLD + fr: Nom du fichier son personnalisé au moment de sa sélection — affiché dans l'outil, ignoré par CTLD +radioSoundFC3OriginalName: + group: general + hidden: true + label: {en: Original name of the custom FC3 beacon sound, fr: Nom d'origine du son de balise FC3 personnalisé} + description: + en: File name the custom silent FC3 sound had when it was chosen — shown in the tool, ignored by CTLD + fr: Nom du fichier son FC3 silencieux personnalisé au moment de sa sélection — affiché dans l'outil, ignoré par CTLD addPlayerAircraftByType: group: general label: {en: Detect player units by type, fr: Détecter les unités joueur par type} diff --git a/tools/ctld-tools/ctld_tools/data/locales/en.json b/tools/ctld-tools/ctld_tools/data/locales/en.json index ca722d1..4d49e79 100644 --- a/tools/ctld-tools/ctld_tools/data/locales/en.json +++ b/tools/ctld-tools/ctld_tools/data/locales/en.json @@ -93,6 +93,7 @@ "validate.setting.bad_choice": "setting '{name}' = {value} is not one of: {choices}", "validate.setting.unknown": "unknown setting '{name}' — it will have no effect in game", "validate.setting.unknown_hint": "unknown setting '{name}' — did you mean '{suggestion}'?", + "validate.sound.missing": "the beacon sound for '{name}' is a file you chose ({file}), but the tool no longer holds it — pick it again, or switch that sound back to the default", "validate.troop.missing_name": "missing 'name'", "validate.troop.unknown": "no troop group named '{name}'", "validate.troop.unknown_hint": "no troop group named '{name}' — did you mean '{suggestion}'?", @@ -223,6 +224,14 @@ "web.slot.troops_blue": "BLUE coalition", "web.slot.troops_hint": "0 = no limit", "web.slot.troops_red": "RED coalition", + "web.sound.choose": "Choose a file…", + "web.sound.custom": "Custom", + "web.sound.default": "Default", + "web.sound.from": "from {name}", + "web.sound.in_mission": "in the mission: {name}", + "web.sound.missing": "The file is no longer loaded — choose it again, or switch back to the default.", + "web.sound.not_ogg": "That file is not an Ogg sound, so DCS would play nothing.", + "web.sound.replace": "Choose another file…", "web.state.clean": "No changes", "web.state.dirty": "Unsaved changes", "web.state.saved": "Saved", diff --git a/tools/ctld-tools/ctld_tools/data/locales/fr.json b/tools/ctld-tools/ctld_tools/data/locales/fr.json index 0fee4a6..bd53081 100644 --- a/tools/ctld-tools/ctld_tools/data/locales/fr.json +++ b/tools/ctld-tools/ctld_tools/data/locales/fr.json @@ -93,6 +93,7 @@ "validate.setting.bad_choice": "le réglage « {name} » = {value} n'est pas parmi : {choices}", "validate.setting.unknown": "réglage inconnu « {name} » — il n'aura aucun effet en jeu", "validate.setting.unknown_hint": "réglage inconnu « {name} » — vouliez-vous dire « {suggestion} » ?", + "validate.sound.missing": "le son de balise de « {name} » est un fichier que vous avez choisi ({file}), mais l'outil ne l'a plus — resélectionnez-le, ou revenez au son par défaut", "validate.troop.missing_name": "« name » manquant", "validate.troop.unknown": "aucun groupe de troupes nommé « {name} »", "validate.troop.unknown_hint": "aucun groupe de troupes nommé « {name} » — vouliez-vous dire « {suggestion} » ?", @@ -223,6 +224,14 @@ "web.slot.troops_blue": "Coalition BLEUE", "web.slot.troops_hint": "0 = aucune limite", "web.slot.troops_red": "Coalition ROUGE", + "web.sound.choose": "Choisir un fichier…", + "web.sound.custom": "Personnalisé", + "web.sound.default": "Par défaut", + "web.sound.from": "d'après {name}", + "web.sound.in_mission": "dans la mission : {name}", + "web.sound.missing": "Le fichier n'est plus chargé — resélectionnez-le, ou revenez au son par défaut.", + "web.sound.not_ogg": "Ce fichier n'est pas un son Ogg : DCS ne jouerait rien.", + "web.sound.replace": "Choisir un autre fichier…", "web.state.clean": "Aucune modification", "web.state.dirty": "Modifications non enregistrées", "web.state.saved": "Enregistré", diff --git a/tools/ctld-tools/ctld_tools/install.py b/tools/ctld-tools/ctld_tools/install.py index 720c182..f8032f0 100644 --- a/tools/ctld-tools/ctld_tools/install.py +++ b/tools/ctld-tools/ctld_tools/install.py @@ -55,9 +55,22 @@ ENGINE_KEY = "CTLD_MapKey_Engine" CONFIG_KEY = "CTLD_MapKey_UserConfig" -#: One resource key per beacon sound, keyed by file name. Present so the Mission Editor keeps the -#: files (see the module docstring): a sound no trigger refers to is dropped on the next save. -SOUND_KEYS = {name: f"CTLD_MapKey_Sound_{name.split('.')[0]}" for name in resources.SOUND_NAMES} + +def sound_key(name: str) -> str: + """The resource key for a beacon sound file. + + Derived from the file name, which is what makes a custom sound land on its own key. Switching + between the bundled and a custom sound therefore leaves the previous entry behind, pointing at + a file no trigger references any more; the Mission Editor drops both on its next save, and + nothing in the mission or in game depends on them meanwhile. Deliberate: the alternative was + for this tool to delete files out of a Mission Maker's archive. + """ + return f"CTLD_MapKey_Sound_{name.split('.')[0]}" + + +#: One resource key per bundled beacon sound. Present so the Mission Editor keeps the files (see +#: the module docstring): a sound no trigger refers to is dropped on the next save. +SOUND_KEYS = {name: sound_key(name) for name in resources.SOUND_NAMES} #: Trigger comments — how a re-install recognises what a previous run put there. CONFIG_MARKER = MARKER @@ -106,6 +119,60 @@ def read_config(miz_path: str | Path) -> FoundConfig | None: return None +def custom_sound_name(catalog: object, setting: str) -> str | None: + """The reserved file name `setting` points at, when it names a customised sound. + + ADR 0012: a sound chosen through the tool always enters the mission under a reserved name, so + the value *itself* says whether it is customised. Comparing against the bundled name instead + would call a Mission Maker's own `beacon.ogg` "the default one" and overwrite it on the next + install. + """ + spec = resources.SOUND_SETTINGS.get(setting) + if spec is None: + return None + value = catalog.get(setting) if hasattr(catalog, "get") else None + return spec["custom"] if value == spec["custom"] else None + + +def read_sounds_from_miz(miz_path: str | Path, catalog: object) -> dict[str, bytes]: + """The customised beacon sounds a mission carries, keyed by setting name. + + This is what makes an installed mission reconfigurable later: the sound travels **in the + archive**, so reopening the `.miz` is enough to reinstall it identically — on another machine, + with the file the Mission Maker chose long deleted. + """ + out: dict[str, bytes] = {} + with zipfile.ZipFile(Path(miz_path)) as z: + members = set(z.namelist()) + for setting in resources.SOUND_SETTINGS: + name = custom_sound_name(catalog, setting) + if name and f"{L10N}/{name}" in members: + out[setting] = z.read(f"{L10N}/{name}") + return out + + +def sounds_to_write(catalog: object, held: dict[str, bytes] | None = None) -> dict[str, bytes]: + """The sound files an install must write, keyed by file name. + + Each of the two settings resolves to either the bundled sound or the held custom bytes. A + customised sound with nothing held raises: `validate` blocks that case long before here, and + writing the mission without it would give silent beacons discovered in flight. + """ + held = held or {} + out: dict[str, bytes] = {} + bundled = resources.read_sounds() + for setting, spec in resources.SOUND_SETTINGS.items(): + name = custom_sound_name(catalog, setting) + if name is None: + out[spec["default"]] = bundled[spec["default"]] + continue + data = held.get(setting) + if data is None: + raise FileNotFoundError(f"{setting} names a custom sound ({name}) but no file is loaded — choose it again") + out[name] = data + return out + + @dataclass class InstallReport: """What an install wrote, so the caller can show it without reopening the archive.""" @@ -115,6 +182,9 @@ class InstallReport: files: list[str] = field(default_factory=list) triggers: list[str] = field(default_factory=list) replaced_previous: bool = False + #: One entry per beacon sound: `{"setting", "file", "size", "custom"}`. The Mission Maker has + #: no other way to check that the file they picked is the one that landed. + sounds: list[dict] = field(default_factory=list) def engine_version(engine: bytes) -> str | None: @@ -227,24 +297,36 @@ def _sound_trigger(keys: list[str], comment: str, country: int) -> dict: } -def install(miz_path: str | Path, userconfig_lua: str, out_path: str | Path | None = None) -> InstallReport: +def install( + miz_path: str | Path, + userconfig_lua: str, + out_path: str | Path | None = None, + *, + catalog: object | None = None, + held_sounds: dict[str, bytes] | None = None, +) -> InstallReport: """Write the engine, the sounds, the configuration and their triggers into a `.miz`. Idempotent: a previous install's triggers (matched by comment), resource-map entries and files are replaced, never accumulated. `out_path` defaults to `miz_path` (in-place). + + `catalog` + `held_sounds` are how a **customised** beacon sound reaches the archive: the + catalogue says which of the two settings names a reserved file, and `held_sounds` carries its + bytes (see `sounds_to_write`). Without them the two bundled sounds are written, which is what + every caller did before ADR 0012 and what a default configuration still means. """ miz_path = Path(miz_path) out_path = Path(out_path) if out_path else miz_path engine = resources.read_engine() - sounds = resources.read_sounds() + sounds = sounds_to_write(catalog, held_sounds) if catalog is not None else resources.read_sounds() mission = read_mission(miz_path) map_resource = _read_map_resource(miz_path) config = _script_trigger(CONFIG_KEY, CONFIG_MARKER) engine_trigger = _script_trigger(ENGINE_KEY, ENGINE_MARKER) - sound_keys = [SOUND_KEYS[name] for name in sorted(sounds)] + sound_keys = [sound_key(name) for name in sorted(sounds)] sounds_trigger = _sound_trigger(sound_keys, SOUNDS_MARKER, _silent_country(mission)) # Configuration first: the engine reads ctld.configUser while loading. The sounds last: their @@ -262,7 +344,7 @@ def install(miz_path: str | Path, userconfig_lua: str, out_path: str | Path | No map_resource[CONFIG_KEY] = CONFIG_FILE map_resource[ENGINE_KEY] = ENGINE_FILE for name in sorted(sounds): - map_resource[SOUND_KEYS[name]] = name + map_resource[sound_key(name)] = name payload: dict[str, bytes] = { f"{L10N}/{ENGINE_FILE}": engine, @@ -274,12 +356,25 @@ def install(miz_path: str | Path, userconfig_lua: str, out_path: str | Path | No _write_miz(mission, miz_path, out_path, payload) + written = { + setting: (custom_sound_name(catalog, setting) or spec["default"]) if catalog is not None else spec["default"] + for setting, spec in resources.SOUND_SETTINGS.items() + } return InstallReport( miz=out_path.name, engine_version=engine_version(engine), files=[ENGINE_FILE, CONFIG_FILE, *sorted(sounds)], triggers=["configuration", "engine", "sounds"], replaced_previous=replaced, + sounds=[ + { + "setting": setting, + "file": name, + "size": len(sounds[name]), + "custom": name != resources.SOUND_SETTINGS[setting]["default"], + } + for setting, name in written.items() + ], ) diff --git a/tools/ctld-tools/ctld_tools/resources.py b/tools/ctld-tools/ctld_tools/resources.py index 7a2d4e6..981c3d0 100644 --- a/tools/ctld-tools/ctld_tools/resources.py +++ b/tools/ctld-tools/ctld_tools/resources.py @@ -28,6 +28,42 @@ #: `radioSound` / `radioSoundFC3` defaults; a mission without these files has silent beacons. SOUND_NAMES = ("beacon.ogg", "beaconsilent.ogg") +#: The two settings that name a beacon sound, each with the bundled file it points at by default, +#: the name a **customised** file takes inside a mission, and the label recording what that file +#: was called on the Mission Maker's disk. +#: +#: The reserved name is the whole of ADR 0012: it is what tells the tool, on reopening a mission, +#: that this sound is not the bundled one — without a second configuration key that could +#: contradict the engine. Comparing against the default name instead would misread the ordinary +#: case of a Mission Maker whose own file is called `beacon.ogg`. +SOUND_SETTINGS: dict[str, dict[str, str]] = { + "radioSound": { + "default": "beacon.ogg", + "custom": "CTLD_beacon_custom.ogg", + "label": "radioSoundOriginalName", + }, + "radioSoundFC3": { + "default": "beaconsilent.ogg", + "custom": "CTLD_beaconsilent_custom.ogg", + "label": "radioSoundFC3OriginalName", + }, +} + +#: Every beacon-sound file name this tool owns — the two bundled ones and the two reserved custom +#: ones. Nothing outside this set is ever touched in a Mission Maker's archive. +OWNED_SOUND_NAMES = frozenset( + [*(s["default"] for s in SOUND_SETTINGS.values()), *(s["custom"] for s in SOUND_SETTINGS.values())] +) + +#: The first four bytes of any Ogg stream. Checked when a file is chosen: renaming an `.mp3` to +#: `.ogg` takes two seconds, DCS then plays nothing, and the Mission Maker finds out in flight. +OGG_MAGIC = b"OggS" + + +def is_ogg(data: bytes) -> bool: + return data[:4] == OGG_MAGIC + + ENGINE_NAME = "CTLD.lua" diff --git a/tools/ctld-tools/ctld_tools/schema.py b/tools/ctld-tools/ctld_tools/schema.py index fb90638..2cfdd67 100644 --- a/tools/ctld-tools/ctld_tools/schema.py +++ b/tools/ctld-tools/ctld_tools/schema.py @@ -60,6 +60,26 @@ def label(self, key: str, lang: str = "en") -> str | None: label = self._entry(key).get("label") return label.get(lang) or label.get("en") if isinstance(label, dict) else None + def editor(self, key: str) -> str | None: + """The editor the UI must use for this setting, or None for the type-derived default. + + Only declared where the value's *type* is not enough to pick a control: `radioSound` is a + string, but a text box invites typing a file name that puts no file anywhere. Naming the + two keys inside a component instead would survive a schema rename with nothing failing — + the box would silently come back (`FEAT-EDITOR-COVERAGE` banned that pattern for `choices`). + """ + editor = self._entry(key).get("editor") + return str(editor) if editor is not None else None + + def hidden(self, key: str) -> bool: + """True when the key is written by the tool and must not appear as a setting. + + For values the tool maintains beside another setting — the original file name of a custom + beacon sound (ADR 0012). They live in the configuration, so the UI has to know not to list + them; hand-editing one would only desynchronise it from the setting it describes. + """ + return bool(self._entry(key).get("hidden", False)) + def unit(self, key: str) -> str | None: """The unit a value is expressed in ("m", "s", "kg", …), or None. diff --git a/tools/ctld-tools/ctld_tools/validate.py b/tools/ctld-tools/ctld_tools/validate.py index 0855c46..7d34f73 100644 --- a/tools/ctld-tools/ctld_tools/validate.py +++ b/tools/ctld-tools/ctld_tools/validate.py @@ -149,11 +149,39 @@ def _validate_completeness(catalog: Catalog, default: Catalog, out: list[Finding out.append(Finding(ERROR, f"settings.{key}", "validate.parameter.missing", {"name": key})) +def _validate_custom_sounds(catalog: Catalog, available: Collection[str], out: list[Finding]) -> None: + """A customised beacon sound the install could not produce blocks the export. + + `available` is the set of settings whose bytes the caller can write — held in the session + (chosen from disk, or recovered from the mission that was opened) or already sitting in the + target mission under the same name. Anything else means the archive would get a configuration + pointing at a file nobody wrote: silent beacons, discovered in flight, with nothing on screen + to explain them. That is the failure `FIX-INSTALL-SOUND-ORPHANS` and `FEAT-ONE-CLICK-INSTALL` + spent two lots removing, so it is an error rather than a line in the report. + """ + from ctld_tools import resources + + for setting, spec in resources.SOUND_SETTINGS.items(): + if catalog.get(setting) != spec["custom"]: + continue + if setting in available: + continue + out.append( + Finding( + ERROR, + f"settings.{setting}", + "validate.sound.missing", + {"name": setting, "file": spec["custom"]}, + ) + ) + + def validate( catalog: Catalog, schema: Schema, types: Collection[str] | None = None, default: Catalog | None = None, + sounds_available: Collection[str] | None = None, ) -> list[Finding]: """Return findings for a complete catalogue against the DCS types + the schema. @@ -172,6 +200,8 @@ def validate( _validate_choices(catalog, schema, out) if default is not None: _validate_completeness(catalog, default, out) + if sounds_available is not None: + _validate_custom_sounds(catalog, sounds_available, out) return out diff --git a/tools/ctld-tools/ctld_tools/web/app.py b/tools/ctld-tools/ctld_tools/web/app.py index 443df06..9cf3213 100644 --- a/tools/ctld-tools/ctld_tools/web/app.py +++ b/tools/ctld-tools/ctld_tools/web/app.py @@ -6,6 +6,7 @@ from __future__ import annotations +import zipfile from pathlib import Path from typing import Any @@ -150,6 +151,8 @@ def get_schema(lang: str | None = None) -> dict[str, Any]: "group": schema.group(k), "standard": schema.standard(k), "choices": schema.choices(k), + "editor": schema.editor(k), + "hidden": schema.hidden(k), "label": schema.label(k, language), "unit": schema.unit(k), "description": schema.description(k, language) or schema.description(k), @@ -311,13 +314,146 @@ def run_validate() -> dict[str, Any]: cat = session.catalog except LookupError as exc: raise HTTPException(status_code=409, detail="no catalogue loaded") from exc - findings = validate(cat, session.schema, default=session.default_catalog()) + findings = validate( + cat, + session.schema, + default=session.default_catalog(), + sounds_available=_sounds_available(session.mission_path), + ) return { "hasErrors": has_errors(findings), "findings": [{"severity": f.severity, "where": f.where, "key": f.key, "message": f.message} for f in findings], } +def _sounds_available(target: str | Path | None = None) -> set[str]: + """Which beacon sounds an install could actually write right now. + + A customised sound is writable when the session holds its bytes — picked from disk, or read out + of the mission that was opened — **or** when the target mission already carries a file of that + name. The second case is the Mission Maker reinstalling over the same mission, or one who put + the file there through the Mission Editor: nothing is missing, so nothing should be reported. + """ + from ctld_tools.install import L10N, custom_sound_name + + cat = session.catalog + out = set(session.sounds()) + if target is None: + return out + path = Path(target) + if not path.is_file(): + return out + try: + with zipfile.ZipFile(path) as z: + members = set(z.namelist()) + except (zipfile.BadZipFile, OSError): + return out + for setting in resources.SOUND_SETTINGS: + name = custom_sound_name(cat, setting) + if name and f"{L10N}/{name}" in members: + out.add(setting) + return out + + +def _target_sounds(target: str | Path, catalog: Any) -> dict[str, bytes]: + """Customised sounds the target mission already carries, so a reinstall can rewrite them.""" + from ctld_tools.install import read_sounds_from_miz + + path = Path(target) + if not path.is_file(): + return {} + try: + return read_sounds_from_miz(path, catalog) + except (zipfile.BadZipFile, OSError): + return {} + + +def _sound_state() -> list[dict[str, Any]]: + """What the UI needs to render the two pickers: source, name, size, availability.""" + cat = session.catalog + state: list[dict[str, Any]] = [] + for setting, spec in resources.SOUND_SETTINGS.items(): + value = cat.get(setting) + custom = value == spec["custom"] + held = session.sound(setting) + state.append( + { + "setting": setting, + "custom": custom, + "file": value, + "originalName": cat.get(spec["label"]) if custom else None, + "size": len(held) if held is not None else None, + "available": (not custom) or setting in _sounds_available(session.mission_path), + } + ) + return state + + +@app.get("/api/sounds") +def get_sounds() -> dict[str, Any]: + try: + session.catalog + except LookupError as exc: + raise HTTPException(status_code=409, detail="no catalogue loaded") from exc + return {"sounds": _sound_state()} + + +@app.post("/api/sounds/{setting}/custom") +def choose_sound(setting: str) -> dict[str, Any]: + """Pick a beacon sound from disk: read it now, keep the bytes, point the setting at them. + + The bytes are read **here**, not at install time, so the choice survives the file being moved, + the drive being unplugged, or the configuration being carried to another machine — the whole + point of ADR 0012's model. + """ + spec = resources.SOUND_SETTINGS.get(setting) + if spec is None: + raise HTTPException(status_code=404, detail=f"not a beacon sound setting: {setting}") + try: + cat = session.catalog + except LookupError as exc: + raise HTTPException(status_code=409, detail="no catalogue loaded") from exc + + from ctld_tools.web import dialogs + + chosen = dialogs.pick_sound() + if not chosen: + return {"cancelled": True} + path = Path(chosen) + data = path.read_bytes() + if not resources.is_ogg(data): + raise HTTPException( + status_code=422, + detail=f"{path.name} is not an Ogg file (no OggS signature) — DCS would play nothing", + ) + + session.set_sound(setting, data) + cat.set(setting, spec["custom"]) + if cat.has(spec["label"]): + cat.set(spec["label"], path.name) + else: + cat.add_setting(spec["label"], path.name) + return {"setting": setting, "file": spec["custom"], "originalName": path.name, "size": len(data)} + + +@app.post("/api/sounds/{setting}/default") +def reset_sound(setting: str) -> dict[str, Any]: + """Go back to the bundled sound: drop the bytes, the reserved name and the label.""" + spec = resources.SOUND_SETTINGS.get(setting) + if spec is None: + raise HTTPException(status_code=404, detail=f"not a beacon sound setting: {setting}") + try: + cat = session.catalog + except LookupError as exc: + raise HTTPException(status_code=409, detail="no catalogue loaded") from exc + + session.drop_sound(setting) + cat.set(setting, spec["default"]) + if cat.has(spec["label"]): + cat.remove(spec["label"]) + return {"setting": setting, "file": spec["default"]} + + @app.get("/api/dialog/{kind}") def dialog(kind: str) -> dict[str, str | None]: """Open a native OS file dialog (open / save / miz) and return the chosen path (or null).""" @@ -345,10 +481,27 @@ def inject(req: InjectRequest) -> dict[str, Any]: cat = session.catalog except LookupError as exc: raise HTTPException(status_code=409, detail="no catalogue loaded") from exc - if has_errors(validate(cat, session.schema, default=session.default_catalog())): + # Validated against the **target** mission, not the open one: a customised sound already sitting + # in the mission being written to is not missing, wherever the configuration came from. + findings = validate( + cat, + session.schema, + default=session.default_catalog(), + sounds_available=_sounds_available(req.miz), + ) + if has_errors(findings): raise HTTPException(status_code=422, detail="fix validation errors before injecting") try: - report = install(req.miz, wrap(cat.dumps(), "configUser"), req.miz) + report = install( + req.miz, + wrap(cat.dumps(), "configUser"), + req.miz, + catalog=cat, + # The session wins where it has something; the target mission fills the rest, which is + # the "already there" case validation just allowed. Merged, not `or`-ed: one sound may + # come from each side. + held_sounds={**_target_sounds(req.miz, cat), **session.sounds()}, + ) except FileNotFoundError as exc: # a checkout with no built engine, or a missing .miz raise HTTPException(status_code=500, detail=str(exc)) from exc @@ -361,6 +514,7 @@ def inject(req: InjectRequest) -> dict[str, Any]: "triggers": report.triggers, "replacedPrevious": report.replaced_previous, "changedSettings": changed, + "sounds": report.sounds, } diff --git a/tools/ctld-tools/ctld_tools/web/dialogs.py b/tools/ctld-tools/ctld_tools/web/dialogs.py index fe09d0a..c36ac24 100644 --- a/tools/ctld-tools/ctld_tools/web/dialogs.py +++ b/tools/ctld-tools/ctld_tools/web/dialogs.py @@ -45,3 +45,12 @@ def save_config() -> str | None: def pick_miz() -> str | None: return _ask("askopenfilename", filetypes=[("DCS mission", "*.miz")]) + + +def pick_sound() -> str | None: + """Pick a beacon sound. `.ogg` first — the only format the engine is known to play here. + + The filter is a convenience, not a guarantee: renaming an `.mp3` gets past it, so the bytes are + checked for the `OggS` signature once read (see `resources.is_ogg`). + """ + return _ask("askopenfilename", filetypes=[("Ogg sound", "*.ogg"), ("All files", "*.*")]) diff --git a/tools/ctld-tools/ctld_tools/web/state.py b/tools/ctld-tools/ctld_tools/web/state.py index 9ac7227..b20ee3b 100644 --- a/tools/ctld-tools/ctld_tools/web/state.py +++ b/tools/ctld-tools/ctld_tools/web/state.py @@ -23,6 +23,12 @@ def __init__(self) -> None: self._mission_path: Path | None = None # "file" (this tool's install, or a .yaml) or "inline" (a mission installed by rc1–rc3). self._config_shape: str = "file" + # The bytes of each customised beacon sound, keyed by setting name. Filled either from the + # disk (the MM just picked a file) or from the archive (they just opened a mission that + # carries one) — one notion, two sources. Keeping a *path* instead would rot: the file + # moves, the drive is unplugged, the configuration is reopened on another machine; and a + # mission has no path to offer at all. + self._sounds: dict[str, bytes] = {} # ── loaded catalogue ─────────────────────────────────────────── @property @@ -52,10 +58,17 @@ def load_path(self, path: str | Path) -> None: one has to work: otherwise last month's mission can only be reconfigured from scratch. Both storage shapes are read (see `install.read_config`), and `mission_path` remembers which mission it came from so the next install defaults to it. + + A mission also carries its **sounds**, so a customised one is recovered here — that is what + makes an installed mission reconfigurable months later, on another machine, with the + original file long gone. A `.yaml` cannot carry one (a binary has no place in the + configuration document), so opening one leaves the sounds empty and validation asks for the + file before the next install. """ path = Path(path) + self._sounds = {} if path.suffix.lower() == ".miz": - from ctld_tools.install import read_config + from ctld_tools.install import read_config, read_sounds_from_miz found = read_config(path) if found is None: @@ -64,6 +77,7 @@ def load_path(self, path: str | Path) -> None: self._path = None self._mission_path = path self._config_shape = found.shape + self._sounds = read_sounds_from_miz(path, self._catalog) return self._catalog = Catalog.load(path) self._path = path @@ -73,11 +87,29 @@ def load_path(self, path: str | Path) -> None: def load_text(self, text: str) -> None: self._catalog = Catalog.loads(text) self._path = None + self._sounds = {} def load_default(self) -> None: path = resources.default_catalog_path() self._catalog = Catalog.load(path) self._path = path + self._sounds = {} + + # ── custom beacon sounds ─────────────────────────────────────── + def set_sound(self, setting: str, data: bytes) -> None: + """Hold the bytes of a customised sound for `setting`.""" + self._sounds[setting] = data + + def sound(self, setting: str) -> bytes | None: + """The bytes held for `setting`, or None when it uses the bundled sound.""" + return self._sounds.get(setting) + + def drop_sound(self, setting: str) -> None: + self._sounds.pop(setting, None) + + def sounds(self) -> dict[str, bytes]: + """Every customised sound currently held, keyed by setting name.""" + return dict(self._sounds) # ── reference data (lazy, cached) ────────────────────────────── @property @@ -97,6 +129,9 @@ def reset(self) -> None: self._path = None self._schema = None self._default = None + self._mission_path = None + self._config_shape = "file" + self._sounds = {} session = Session() diff --git a/tools/ctld-tools/tests/test_schema.py b/tools/ctld-tools/tests/test_schema.py index aa8fa02..2850f5f 100644 --- a/tools/ctld-tools/tests/test_schema.py +++ b/tools/ctld-tools/tests/test_schema.py @@ -45,13 +45,41 @@ def test_uncovered_setting_is_none_not_error(): assert s.standard("__no_such_setting__") is False +def test_sound_settings_declare_their_editor(): + """The picker is bound to `editor: sound`, never to a setting name in a component.""" + s = sch() + assert s.editor("radioSound") == "sound" + assert s.editor("radioSoundFC3") == "sound" + assert s.editor("hoverTime") is None + assert s.editor("__no_such_setting__") is None + + +def test_original_name_labels_are_schema_only_and_hidden(): + """The original file name is a label: declared here, absent from the catalogue, never shown. + + Catalogued it would be a *parameter* under ADR 0011 Addendum 1, so the completeness rule would + demand it of every snapshot and every pre-lot configuration would report a missing setting at + mission start — FIX-TOOL-I18N-LANG's wall. And having no default is what lets "absent" mean + "this sound is the bundled one". + """ + s = sch() + c = Catalog.load(CONFIG_YAML) + for key in ("radioSoundOriginalName", "radioSoundFC3OriginalName"): + assert s.has(key), f"{key} must be declared in the schema" + assert s.hidden(key) is True, f"{key} is a label, not a setting the MM edits" + assert not c.has(key), f"{key} must stay out of the default catalogue" + assert s.default(key) is None, f"{key} must have no default" + assert s.hidden("radioSound") is False + + def test_every_schema_key_is_a_real_setting(): """No orphan schema entries: each maps to a setting present in the catalogue.""" s = sch() c = Catalog.load(CONFIG_YAML) real = set(c.keys()) # i18n_lang is a bare CTLD_i18n global (not in the defaults catalogue); tableFields is - # UI table-column metadata (lot 3), not a setting. - special = {"i18n_lang", "tableFields"} + # UI table-column metadata (lot 3), not a setting; the two *OriginalName keys are labels the + # tool writes beside a custom beacon sound, deliberately uncatalogued (ADR 0012). + special = {"i18n_lang", "tableFields", "radioSoundOriginalName", "radioSoundFC3OriginalName"} orphans = [k for k in s.keys() if k not in real and k not in special] assert orphans == [], f"schema entries with no matching setting: {orphans}" diff --git a/tools/ctld-tools/tests/test_sounds.py b/tools/ctld-tools/tests/test_sounds.py new file mode 100644 index 0000000..02b7379 --- /dev/null +++ b/tools/ctld-tools/tests/test_sounds.py @@ -0,0 +1,231 @@ +"""Custom beacon sounds — the reserved-name model of ADR 0012, end to end. + +The promise these tests exist to hold: a mission installed with a sound the Mission Maker chose can +be reopened, edited and reinstalled **on another machine, with the original file deleted**. That is +why the bytes travel in the archive and are read at selection time rather than at install time. +""" + +import shutil +import zipfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from ctld_tools import resources +from ctld_tools.catalog import Catalog +from ctld_tools.install import L10N, custom_sound_name, read_sounds_from_miz, sounds_to_write +from ctld_tools.validate import has_errors, validate +from ctld_tools.web import dialogs +from ctld_tools.web.app import app +from ctld_tools.web.state import session + +REPO = Path(__file__).resolve().parents[3] +MIZ = REPO / "missions" / "Test_CTLDNEXT_01.miz" + +pytestmark = pytest.mark.skipif(not (REPO / "CTLD.lua").is_file(), reason="CTLD.lua not built in this checkout") + +client = TestClient(app) + +#: A minimal but genuine Ogg header — enough for the signature check, and not an .mp3 in disguise. +OGG = b"OggS" + b"\x00" * 60 +NOT_OGG = b"ID3\x04" + b"\x00" * 60 + + +@pytest.fixture(autouse=True) +def _fresh_session(): + session.reset() + yield + session.reset() + + +@pytest.fixture +def sound_file(tmp_path: Path) -> Path: + path = tmp_path / "Ma Balise Été.ogg" + path.write_bytes(OGG) + return path + + +def choose(monkeypatch, path: Path | None, setting: str = "radioSound"): + monkeypatch.setattr(dialogs, "pick_sound", lambda: str(path) if path else None) + return client.post(f"/api/sounds/{setting}/custom") + + +def names(miz: Path) -> set[str]: + with zipfile.ZipFile(miz) as z: + return set(z.namelist()) + + +# ── the model ────────────────────────────────────────────────────── +def test_a_custom_sound_is_recognised_by_its_reserved_name(): + """Not by "differs from the default": a Mission Maker's own beacon.ogg is the case that breaks.""" + cat = Catalog.loads("mm_facing:\n radioSound: beacon.ogg\n") + assert custom_sound_name(cat, "radioSound") is None + + cat = Catalog.loads("mm_facing:\n radioSound: CTLD_beacon_custom.ogg\n") + assert custom_sound_name(cat, "radioSound") == "CTLD_beacon_custom.ogg" + + +def test_a_custom_sound_with_no_bytes_refuses_to_be_written(): + """Silent beacons are discovered in flight; the install must fail here instead.""" + cat = Catalog.loads("mm_facing:\n radioSound: CTLD_beacon_custom.ogg\n") + with pytest.raises(FileNotFoundError, match="choose it again"): + sounds_to_write(cat, {}) + + written = sounds_to_write(cat, {"radioSound": OGG}) + assert written["CTLD_beacon_custom.ogg"] == OGG + # the other sound is untouched and still the bundled one + assert "beaconsilent.ogg" in written + + +def test_validation_blocks_a_sound_the_tool_cannot_produce(): + cat = Catalog.loads("mm_facing:\n radioSound: CTLD_beacon_custom.ogg\n") + findings = validate(cat, session.schema, types=set(), sounds_available=set()) + assert has_errors(findings) + assert any(f.key == "validate.sound.missing" for f in findings) + + # …and says nothing once the bytes are available. + ok = validate(cat, session.schema, types=set(), sounds_available={"radioSound"}) + assert not any(f.key == "validate.sound.missing" for f in ok) + + +# ── choosing a file ──────────────────────────────────────────────── +def test_choosing_a_file_stores_it_and_records_its_original_name(monkeypatch, sound_file): + client.post("/api/catalog/load-default") + body = choose(monkeypatch, sound_file).json() + + assert body["file"] == "CTLD_beacon_custom.ogg" + assert body["originalName"] == "Ma Balise Été.ogg" + assert body["size"] == len(OGG) + + cat = session.catalog + assert cat.get("radioSound") == "CTLD_beacon_custom.ogg" + assert cat.get("radioSoundOriginalName") == "Ma Balise Été.ogg" + assert session.sound("radioSound") == OGG + + +def test_a_file_that_is_not_ogg_is_refused(monkeypatch, tmp_path): + client.post("/api/catalog/load-default") + fake = tmp_path / "music.ogg" + fake.write_bytes(NOT_OGG) + + response = choose(monkeypatch, fake) + assert response.status_code == 422 + assert "OggS" in response.json()["detail"] + # nothing changed: the configuration must not point at a file that would play nothing + assert session.catalog.get("radioSound") == "beacon.ogg" + assert session.sound("radioSound") is None + + +def test_cancelling_the_dialog_changes_nothing(monkeypatch): + client.post("/api/catalog/load-default") + assert choose(monkeypatch, None).json() == {"cancelled": True} + assert session.catalog.get("radioSound") == "beacon.ogg" + + +def test_going_back_to_the_default_drops_the_bytes_and_the_label(monkeypatch, sound_file): + client.post("/api/catalog/load-default") + choose(monkeypatch, sound_file) + + client.post("/api/sounds/radioSound/default") + cat = session.catalog + assert cat.get("radioSound") == "beacon.ogg" + assert not cat.has("radioSoundOriginalName") + assert session.sound("radioSound") is None + + +def test_the_two_sounds_are_independent(monkeypatch, sound_file): + client.post("/api/catalog/load-default") + choose(monkeypatch, sound_file, setting="radioSoundFC3") + + cat = session.catalog + assert cat.get("radioSoundFC3") == "CTLD_beaconsilent_custom.ogg" + assert cat.get("radioSound") == "beacon.ogg", "the other sound stays the bundled one" + + +# ── the round trip, which is the whole point ─────────────────────── +def test_a_custom_sound_survives_install_reopen_and_reinstall(monkeypatch, sound_file, tmp_path): + miz = tmp_path / "mission.miz" + shutil.copy(MIZ, miz) + + client.post("/api/catalog/load-default") + choose(monkeypatch, sound_file) + report = client.post("/api/inject", json={"miz": str(miz)}).json() + + assert f"{L10N}/CTLD_beacon_custom.ogg" in names(miz) + assert {"setting": "radioSound", "file": "CTLD_beacon_custom.ogg", "size": len(OGG), "custom": True} in report[ + "sounds" + ] + + # The Mission Maker's own file is gone, and so is every trace of this session. + sound_file.unlink() + session.reset() + + client.post("/api/catalog/load", json={"path": str(miz)}) + assert session.catalog.get("radioSound") == "CTLD_beacon_custom.ogg" + assert session.catalog.get("radioSoundOriginalName") == "Ma Balise Été.ogg", "the label came back too" + assert session.sound("radioSound") == OGG, "the bytes came out of the archive" + + # Reinstalling into a *different* mission still writes it. + other = tmp_path / "other.miz" + shutil.copy(MIZ, other) + assert client.post("/api/inject", json={"miz": str(other)}).status_code == 200 + with zipfile.ZipFile(other) as z: + assert z.read(f"{L10N}/CTLD_beacon_custom.ogg") == OGG + + +def test_reading_sounds_back_ignores_a_mission_that_uses_the_defaults(tmp_path): + miz = tmp_path / "plain.miz" + shutil.copy(MIZ, miz) + cat = Catalog.loads("mm_facing:\n radioSound: beacon.ogg\n") + assert read_sounds_from_miz(miz, cat) == {} + + +def test_a_yaml_cannot_carry_the_sound_so_validation_asks_for_it(monkeypatch, sound_file, tmp_path): + """The accepted limit of the model — and it must be an error, not a surprise in flight.""" + client.post("/api/catalog/load-default") + choose(monkeypatch, sound_file) + + saved = tmp_path / "config.yaml" + client.post("/api/catalog/save", json={"path": str(saved)}) + + session.reset() + client.post("/api/catalog/load", json={"path": str(saved)}) + assert session.sound("radioSound") is None + + findings = client.get("/api/validate").json() + assert findings["hasErrors"] + assert any(f["key"] == "validate.sound.missing" for f in findings["findings"]) + + # Injecting into a mission that already holds the file is fine, though: nothing is missing. + miz = tmp_path / "has-it.miz" + shutil.copy(MIZ, miz) + with zipfile.ZipFile(miz, "a") as z: + z.writestr(f"{L10N}/CTLD_beacon_custom.ogg", OGG) + assert client.post("/api/inject", json={"miz": str(miz)}).status_code == 200 + + +def test_the_sound_state_endpoint_describes_both_pickers(monkeypatch, sound_file): + client.post("/api/catalog/load-default") + choose(monkeypatch, sound_file) + + sounds = {s["setting"]: s for s in client.get("/api/sounds").json()["sounds"]} + assert sounds["radioSound"] == { + "setting": "radioSound", + "custom": True, + "file": "CTLD_beacon_custom.ogg", + "originalName": "Ma Balise Été.ogg", + "size": len(OGG), + "available": True, + } + assert sounds["radioSoundFC3"]["custom"] is False + assert sounds["radioSoundFC3"]["originalName"] is None + + +def test_owned_names_cover_the_four_files_and_nothing_else(): + assert resources.OWNED_SOUND_NAMES == { + "beacon.ogg", + "beaconsilent.ogg", + "CTLD_beacon_custom.ogg", + "CTLD_beaconsilent_custom.ogg", + } diff --git a/tools/ctld-tools/tests/test_web_app.py b/tools/ctld-tools/tests/test_web_app.py index 6131430..8f1b81e 100644 --- a/tools/ctld-tools/tests/test_web_app.py +++ b/tools/ctld-tools/tests/test_web_app.py @@ -53,6 +53,16 @@ def test_schema_endpoint_exposes_families_and_keys(): assert "tableFields" not in body["keys"] # the reserved section is not a setting +def test_schema_endpoint_exposes_the_sound_editor_and_hidden_labels(): + """The UI picks the sound picker from the schema, and knows which keys not to list.""" + keys = client.get("/api/schema").json()["keys"] + assert keys["radioSound"]["editor"] == "sound" + assert keys["radioSoundFC3"]["editor"] == "sound" + assert keys["hoverTime"]["editor"] is None + assert keys["radioSoundOriginalName"]["hidden"] is True + assert keys["radioSound"]["hidden"] is False + + def test_schema_endpoint_exposes_table_fields(): body = client.get("/api/schema").json() crates = body["tableFields"]["spawnableCrates"] diff --git a/tools/ctld-tools/web/src/App.svelte b/tools/ctld-tools/web/src/App.svelte index a511fee..87e3ee2 100644 --- a/tools/ctld-tools/web/src/App.svelte +++ b/tools/ctld-tools/web/src/App.svelte @@ -1,9 +1,11 @@ + +
+
+ + + +
+ + {#if sound.custom} +

+ {#if sound.originalName}{t('web.sound.from').replace('{name}', sound.originalName)}{/if} + {#if sound.size}{kb(sound.size)}{/if} + {sound.file} +

+ {#if !sound.available}

{t('web.sound.missing')}

{/if} + {/if} + + {#if error}

{error}

{/if} +
+ + diff --git a/tools/ctld-tools/web/src/lib/SoundPicker.test.ts b/tools/ctld-tools/web/src/lib/SoundPicker.test.ts new file mode 100644 index 0000000..e945a39 --- /dev/null +++ b/tools/ctld-tools/web/src/lib/SoundPicker.test.ts @@ -0,0 +1,108 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import SoundPicker from './SoundPicker.svelte' +import type { SoundState } from './api' + +const DEFAULT_STATE: SoundState = { + setting: 'radioSound', + custom: false, + file: 'beacon.ogg', + originalName: null, + size: null, + available: true, +} + +const CUSTOM_STATE: SoundState = { + setting: 'radioSound', + custom: true, + file: 'CTLD_beacon_custom.ogg', + originalName: 'Ma Balise Été.ogg', + size: 410_000, + available: true, +} + +function mockFetch(handler: (url: string) => unknown) { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => ({ ok: true, json: async () => handler(url) })), + ) +} + +beforeEach(() => mockFetch(() => ({}))) +afterEach(() => vi.unstubAllGlobals()) + +describe('SoundPicker', () => { + it('offers both sources, with the bundled one selected by default', () => { + render(SoundPicker, { props: { sound: DEFAULT_STATE, onchange: () => {} } }) + const [dflt, custom] = screen.getAllByRole('radio') as HTMLInputElement[] + expect(dflt.checked).toBe(true) + expect(custom.checked).toBe(false) + }) + + it('shows the name the Mission Maker knows, not the reserved one, and still names the file', () => { + render(SoundPicker, { props: { sound: CUSTOM_STATE, onchange: () => {} } }) + // ADR 0012: `CTLD_beacon_custom.ogg` means nothing to whoever picked `Ma Balise Été.ogg`. + expect(screen.getByText(/Ma Balise Été\.ogg/)).toBeTruthy() + expect(screen.getByText('CTLD_beacon_custom.ogg')).toBeTruthy() + expect(screen.getByText('400 kB')).toBeTruthy() + }) + + it('warns when the file is no longer loaded — the case that would give silent beacons', () => { + render(SoundPicker, { props: { sound: { ...CUSTOM_STATE, available: false }, onchange: () => {} } }) + expect(screen.getByText(/choose it again/i)).toBeTruthy() + }) + + it('asks the backend for a file and reports the change', async () => { + const calls: string[] = [] + mockFetch((url) => { + calls.push(url) + return { setting: 'radioSound', file: 'CTLD_beacon_custom.ogg', originalName: 'x.ogg', size: 12 } + }) + const onchange = vi.fn() + render(SoundPicker, { props: { sound: DEFAULT_STATE, onchange } }) + + await fireEvent.click(screen.getByRole('button')) + await waitFor(() => expect(onchange).toHaveBeenCalled()) + expect(calls).toEqual(['/api/sounds/radioSound/custom']) + }) + + it('does not report a change when the Mission Maker cancels the dialog', async () => { + mockFetch(() => ({ cancelled: true })) + const onchange = vi.fn() + render(SoundPicker, { props: { sound: DEFAULT_STATE, onchange } }) + + await fireEvent.click(screen.getByRole('button')) + await waitFor(() => expect(screen.getByRole('button')).not.toHaveProperty('disabled', true)) + expect(onchange).not.toHaveBeenCalled() + }) + + it('goes back to the bundled sound through its own endpoint', async () => { + const calls: string[] = [] + mockFetch((url) => { + calls.push(url) + return { setting: 'radioSound', file: 'beacon.ogg' } + }) + const onchange = vi.fn() + render(SoundPicker, { props: { sound: CUSTOM_STATE, onchange } }) + + const [dflt] = screen.getAllByRole('radio') + await fireEvent.click(dflt) + await waitFor(() => expect(onchange).toHaveBeenCalled()) + expect(calls).toEqual(['/api/sounds/radioSound/default']) + }) + + it('surfaces a refusal from the backend instead of failing silently', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + statusText: 'Unprocessable', + json: async () => ({ detail: 'music.ogg is not an Ogg file (no OggS signature)' }), + })), + ) + render(SoundPicker, { props: { sound: DEFAULT_STATE, onchange: () => {} } }) + + await fireEvent.click(screen.getByRole('button')) + await waitFor(() => expect(screen.getByText(/not an Ogg file/)).toBeTruthy()) + }) +}) diff --git a/tools/ctld-tools/web/src/lib/api.ts b/tools/ctld-tools/web/src/lib/api.ts index ac02f47..7157885 100644 --- a/tools/ctld-tools/web/src/lib/api.ts +++ b/tools/ctld-tools/web/src/lib/api.ts @@ -10,6 +10,11 @@ export interface SchemaKey { group: string | null standard: boolean choices: unknown[] | null + /** Named control when the value type is not enough to pick one (`sound` = beacon-sound picker). + * Optional like the rest of the schema's metadata: an entry that declares none is normal. */ + editor?: string | null + /** Written by the tool beside another setting — never listed as a setting of its own. */ + hidden?: boolean /** Authored display name in the active language; null → derive one from the key. */ label: string | null /** Authored unit symbol ("m", "s", "kg", …); null → fall back to reading the description. */ @@ -120,8 +125,38 @@ export type InstallResult = { triggers: string[] replacedPrevious: boolean changedSettings: number + sounds: InstalledSound[] } +export type InstalledSound = { + setting: string + file: string + size: number + custom: boolean +} + +/** What one beacon-sound picker shows: which source, which file, and whether it can be installed. */ +export type SoundState = { + setting: string + custom: boolean + file: string + /** The name the file had on the MM's disk — the reserved in-mission name would lose it. */ + originalName: string | null + size: number | null + available: boolean +} + +export const getSounds = () => fetch('/api/sounds').then((r) => json<{ sounds: SoundState[] }>(r)) + +/** Open the native picker and adopt the chosen file; `{cancelled:true}` when the MM backs out. */ +export const chooseSound = (setting: string) => + post(`/api/sounds/${setting}/custom`).then((r) => + json<{ cancelled?: boolean; setting?: string; file?: string; originalName?: string; size?: number }>(r), + ) + +export const resetSound = (setting: string) => + post(`/api/sounds/${setting}/default`).then((r) => json<{ setting: string; file: string }>(r)) + export const injectMiz = (miz: string) => post('/api/inject', { miz }).then((r) => json(r)) /** The CTLD version this build belongs to, and the docs version to link to (`dev` for an rc). */ diff --git a/tools/ctld-tools/web/src/lib/model.test.ts b/tools/ctld-tools/web/src/lib/model.test.ts index d9336e9..08edaca 100644 --- a/tools/ctld-tools/web/src/lib/model.test.ts +++ b/tools/ctld-tools/web/src/lib/model.test.ts @@ -16,8 +16,9 @@ const schema: SchemaInfo = { families: ['aa', 'troops', 'jtac'], familyMeta: {}, keys: { - numberOfTroops: { group: 'troops', standard: true, choices: null, label: null, unit: null, description: null }, - aaRearmDistance: { group: 'aa', standard: false, choices: null, label: null, unit: null, description: null }, + numberOfTroops: { group: 'troops', standard: true, choices: null, editor: null, hidden: false, label: null, unit: null, description: null }, + aaRearmDistance: { group: 'aa', standard: false, choices: null, editor: null, hidden: false, label: null, unit: null, description: null }, + radioSoundOriginalName: { group: 'general', standard: false, choices: null, editor: null, hidden: true, label: null, unit: null, description: null }, // hoverTime has no schema entry and no prefix rule → OTHER_FAMILY }, tableFields: {}, @@ -26,11 +27,12 @@ const schema: SchemaInfo = { const snap: Snapshot = { path: null, - keys: ['numberOfTroops', 'aaRearmDistance', 'hoverTime', 'spawnableCrates', 'transportPilotNames'], + keys: ['numberOfTroops', 'aaRearmDistance', 'hoverTime', 'radioSoundOriginalName', 'spawnableCrates', 'transportPilotNames'], values: { numberOfTroops: 10, aaRearmDistance: 300, hoverTime: 10, + radioSoundOriginalName: 'Ma Balise Été.ogg', spawnableCrates: { Support: [] }, transportPilotNames: ['Pilot #1'], }, @@ -58,7 +60,10 @@ describe('classify', () => { const placed = classify(snap, schema) .flatMap((f) => [...f.standard, ...f.advanced, ...f.data]) .sort() - expect(placed).toEqual([...snap.keys].sort()) + // Totality still holds, over the settings: the only exclusion is a key the schema marks + // `hidden`, i.e. one the tool writes beside another setting rather than one the MM edits. + const editable = snap.keys.filter((k) => !schema.keys[k]?.hidden).sort() + expect(placed).toEqual(editable) }) it('puts an unplaceable setting in `other` rather than dropping it', () => { @@ -68,12 +73,25 @@ describe('classify', () => { describe('settingKeys', () => { it('lists the scalar keys only — what search covers', () => { - expect(settingKeys(snap)).toEqual(['numberOfTroops', 'aaRearmDistance', 'hoverTime']) + expect(settingKeys(snap, schema)).toEqual(['numberOfTroops', 'aaRearmDistance', 'hoverTime']) + }) + + it('leaves out the tool-written labels, so search cannot offer one to edit', () => { + expect(settingKeys(snap, schema)).not.toContain('radioSoundOriginalName') + }) +}) + +describe('hidden keys', () => { + it('never land in a family — they are labels, not settings (ADR 0012)', () => { + const everywhere = classify(snap, schema).flatMap((f) => [...f.standard, ...f.advanced, ...f.data]) + expect(everywhere).not.toContain('radioSoundOriginalName') + // …and the setting they describe is still listed as usual + expect(snap.keys).toContain('radioSoundOriginalName') }) }) describe('editorType', () => { - const enumMeta: SchemaKey = { group: null, standard: false, choices: ['a', 'b'], label: null, unit: null, description: null } + const enumMeta: SchemaKey = { group: null, standard: false, choices: ['a', 'b'], editor: null, hidden: false, label: null, unit: null, description: null } it('resolves each scalar type', () => { expect(editorType(enumMeta, 'a')).toBe('enum') diff --git a/tools/ctld-tools/web/src/lib/model.ts b/tools/ctld-tools/web/src/lib/model.ts index 9e7d7de..2b66d75 100644 --- a/tools/ctld-tools/web/src/lib/model.ts +++ b/tools/ctld-tools/web/src/lib/model.ts @@ -41,6 +41,10 @@ export function classify(snap: Snapshot, schema: SchemaInfo): Family[] { } for (const key of snap.keys) { + // A `hidden` key is written by the tool beside another setting (the original name of a custom + // beacon sound, ADR 0012). It lives in the configuration but is not a setting: listing it would + // invite hand-editing a label into disagreeing with the sound it describes. + if (schema.keys[key]?.hidden) continue if (isStructured(snap.values[key])) { family(DATA_FAMILY[key] ?? OTHER_FAMILY).data.push(key) } else { @@ -58,9 +62,14 @@ export function classify(snap: Snapshot, schema: SchemaInfo): Family[] { return orderFamilies(byKey.keys(), schema.familyMeta).map((name) => byKey.get(name)!) } -/** Every scalar setting key in the catalogue, used by search. */ -export function settingKeys(snap: Snapshot): string[] { - return snap.keys.filter((k) => !isStructured(snap.values[k])) +/** + * Every scalar setting key in the catalogue, used by search. + * + * `schema` is optional only so old call sites keep working; pass it, or search will offer the + * hidden tool-written keys that `classify` deliberately leaves out of the families. + */ +export function settingKeys(snap: Snapshot, schema?: SchemaInfo): string[] { + return snap.keys.filter((k) => !isStructured(snap.values[k]) && !schema?.keys[k]?.hidden) } // ── scalar editors ──────────────────────────────────────────────── diff --git a/tools/ctld-tools/web/src/lib/strings.ts b/tools/ctld-tools/web/src/lib/strings.ts index c640eb6..97caf00 100644 --- a/tools/ctld-tools/web/src/lib/strings.ts +++ b/tools/ctld-tools/web/src/lib/strings.ts @@ -199,4 +199,13 @@ export const EN_STRINGS: Record = { 'web.slot.colour_b': "Blue", 'web.slot.colour_a': "Opacity", 'web.slot.colour_hint': "0.0 to 1.0", + // ── beacon sounds ─────────────────────────────────────────────── + 'web.sound.default': "Default", + 'web.sound.custom': "Custom", + 'web.sound.choose': "Choose a file…", + 'web.sound.replace': "Choose another file…", + 'web.sound.from': "from {name}", + 'web.sound.in_mission': "in the mission: {name}", + 'web.sound.missing': "The file is no longer loaded — choose it again, or switch back to the default.", + 'web.sound.not_ogg': "That file is not an Ogg sound, so DCS would play nothing.", } From c2f1d8f1902fd397ca67892ff11676cb509613f3 Mon Sep 17 00:00:00 2001 From: David Pierron Date: Sun, 9 Aug 2026 10:57:25 +0200 Subject: [PATCH 3/3] chore(backlog): mark FEAT-CUSTOM-BEACON-SOUNDS merged (PR #112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set in the lot's own PR, per the convention — not left for a post-merge commit. Co-Authored-By: Claude Opus 5 --- .backlog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.backlog/README.md b/.backlog/README.md index 2afea02..6989105 100644 --- a/.backlog/README.md +++ b/.backlog/README.md @@ -15,7 +15,7 @@ authored **per lot, when the lot is started** (not in batch). | Lot | Status | Description | Branch | |-----|--------|-------------|--------| | `CHORE-UNTRACK-BUILT-ENGINE` | merged (PR #110) | `CTLD.lua` is generated and committed anyway. `.gitignore` line 5 calls it deliberate — *"available at repo root for DCS missions"* — a bootstrap-era reason that no longer holds: nothing points at it, and **VMCT, the assumed consumer, does not** (its `vendored.yaml` pins `2.0.0-rc3` with *"re-download the CTLD.lua asset from the matching release"* and watches `github-release`). The cost is paid every time: **26 of the 28 merges touching `src/` over 30 days carried the regenerated file** — a one-megabyte generated diff nobody reviews, and a guaranteed conflict between parallel PRs. The trap, measured rather than assumed: `python-quality` runs on ubuntu and never builds the engine, so deleting the file alone would drop the suite from **262 passed** to **234 passed / 27 skipped / 1 failed** while CI stayed green. So: build the engine in that job first (`merge_CTLD.ps1` made portable — two `\` paths), fix `test_inject_into_miz` (it crashes instead of skipping), then untrack. Depends on `FEAT-DEV-BUILD-CHANNEL`, which is what keeps the engine downloadable. No history rewriting (471 blobs = 2.8 MiB packed). | `chore/untrack-built-engine` | -| `FEAT-CUSTOM-BEACON-SOUNDS` | in progress | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` | +| `FEAT-CUSTOM-BEACON-SOUNDS` | merged (PR #112) | A beacon sound the Mission Maker chooses, instead of a text box naming a file the tool never installs. Grilled with Zip on 2026-08-08: custom is **derived** from `radioSound` (no second key that could disagree with the engine); a chosen file enters the mission under a **reserved name** (**ADR 0012**) because a Mission Maker whose own file is called `beacon.ogg` would otherwise see it silently overwritten; the original name survives as a schema-only label (`FIX-TOOL-I18N-LANG`'s lesson — a catalogue key would make every pre-lot configuration report a missing setting at mission start); the bytes are read at selection and live in the session, so reopening a `.miz` reinstalls them **on another machine with the original file gone**. `OggS` checked, no size cap, nothing deleted from the archive. | `feature/custom-beacon-sounds` | | `FEAT-DEV-BUILD-CHANNEL` | merged (PR #109) | An exe to hand a tester between two releases. Zip's first idea — the exe grafting an arbitrary `CTLD.lua` into a copy of itself — **works** (verified: rc6 + 1.17 MB appended still runs) and was dropped anyway: it pairs a new engine with the exe's older schema and interface, an unsigned exe altered after the build reads as tampered, and `--version` would keep lying. The `build-exe` job already produces a complete exe from a commit in **2 min 06 s** on free public-repo runners; it only lacked a trigger. Built on every merge into `develop`, published as an artifact **and** a floating `dev` pre-release (an artifact answers `401` to an anonymous download), versioned `-`. | `feature/dev-build-channel` | | [`FIX-FIELD-EXTRACT-CASUALTIES`](FIX-FIELD-EXTRACT-CASUALTIES/PRD.md) | merged (PR #111) | Field extraction (`embarkFromField`) returns the troop count frozen at deploy time instead of the survivor count — an undeclared legacy-parity deviation. Fix counts live DCS units (excluding `SVNT_*` servants) at extraction time; adds troop counts to the "Extract from field" menu labels; auto-despawns an orphaned mortar servant when its operator dies leaving zero real troops. Also fixed a pre-existing bug where `onUnitDead` never fired in-game. | `fix/field-extract-casualties` | | [`FIX-MENU-DOUBLE-MULTICREW`](FIX-MENU-DOUBLE-MULTICREW/PRD.md) | merged (PR #106) | F10 menu duplication on multi-crew aircraft (CH-47 pilot + copilot); menu loss when one crew member leaves a shared group. | — |