Add audio runtime validation and CI workflow
Some checks failed
ci / headless-e2e (push) Has been cancelled
Some checks failed
ci / headless-e2e (push) Has been cancelled
This commit is contained in:
29
.gitea/workflows/ci.yml
Normal file
29
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
headless-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build g++ pkg-config libsodium-dev libzstd-dev
|
||||
|
||||
- name: Configure
|
||||
run: cmake -S . -B build -G Ninja
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build -j2
|
||||
|
||||
- name: Headless E2E
|
||||
run: python3 scripts/headless_e2e.py
|
||||
@@ -111,6 +111,13 @@ python3 scripts/validate_runtime_gate.py \
|
||||
--runtime-root /tmp/m2dev-client-runtime-http
|
||||
```
|
||||
|
||||
Audio scenario validator:
|
||||
|
||||
```bash
|
||||
python3 scripts/validate_audio_scenarios.py \
|
||||
--runtime-root /tmp/m2dev-client-runtime-http
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
|
||||
@@ -195,10 +195,21 @@ effect files and reports 12 concrete missing references:
|
||||
- `effect/background/turtle_statue_tree_roof_light01.mse` missing `turtle_statue_tree_roof_light01.mde`
|
||||
- `effect/etc/compete/ready.mse` missing `ready.DDS`
|
||||
|
||||
It also now includes an audio scenario validator:
|
||||
|
||||
- `scripts/validate_audio_scenarios.py`
|
||||
|
||||
On the current real client runtime, the audio validator checks the full `*.mss`
|
||||
runtime script layer and reports 41 concrete missing audio references. These
|
||||
are mostly old cross-pack or wrong-path sound references rather than `.m2p`
|
||||
loader regressions.
|
||||
|
||||
Those current actor and effect findings are also recorded in:
|
||||
|
||||
- `known_issues/runtime_known_issues.json`
|
||||
|
||||
The current audio findings are recorded there as well.
|
||||
|
||||
That file is now the shared runtime baseline used by the validators and the
|
||||
aggregated release gate.
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ delivery material.
|
||||
- Treat `master.key` as release secret material, not as source code.
|
||||
- Archive and retain `verify` and `diff` outputs for each release.
|
||||
- Fail the pipeline if `verify` fails or `diff` reports unexpected changes.
|
||||
- Run the built-in Linux workflow in `.gitea/workflows/ci.yml` on every push.
|
||||
- Run the real client runtime gate separately on a machine that has the client runtime assets available.
|
||||
|
||||
## Production posture
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ The gate runs these validators together:
|
||||
- `scripts/validate_runtime_scenarios.py`
|
||||
- `scripts/validate_actor_scenarios.py`
|
||||
- `scripts/validate_effect_scenarios.py`
|
||||
- `scripts/validate_audio_scenarios.py`
|
||||
|
||||
By default they load the shared baseline:
|
||||
|
||||
@@ -276,3 +277,29 @@ Current baseline on the real runtime:
|
||||
- `world`: `0`
|
||||
- `actor`: `5`
|
||||
- `effect`: `12`
|
||||
- `audio`: `41`
|
||||
|
||||
Audio scenario validator:
|
||||
|
||||
```bash
|
||||
python3 scripts/validate_audio_scenarios.py \
|
||||
--runtime-root /tmp/m2dev-client-runtime-http \
|
||||
--json
|
||||
```
|
||||
|
||||
This validator checks the runtime audio script layer:
|
||||
|
||||
- all `*.mss` files under the real client runtime
|
||||
- every `SoundDataNN` reference to `wav/mp3`
|
||||
- resolution against the effective virtual audio namespace used by the client
|
||||
|
||||
Current real-runtime findings show 41 historical audio content issues. These
|
||||
are now recorded in the shared runtime baseline and do not fail the gate unless
|
||||
they change.
|
||||
|
||||
Built-in CI:
|
||||
|
||||
- `.gitea/workflows/ci.yml` builds `m2pack` on Linux
|
||||
- the CI workflow runs `scripts/headless_e2e.py`
|
||||
- the real client runtime gate remains a separate step because it depends on
|
||||
external client assets not stored in this repository
|
||||
|
||||
@@ -20,5 +20,48 @@
|
||||
"effect:reference:ymir work/effect/background/smh_gatetower01.mse:ymir work/effect/monster2/smoke_dust.dds",
|
||||
"effect:reference:ymir work/effect/background/turtle_statue_tree_roof_light01.mse:ymir work/effect/background/turtle_statue_tree_roof_light01.mde",
|
||||
"effect:reference:ymir work/effect/etc/compete/ready.mse:ymir work/effect/etc/compete/ready.dds"
|
||||
],
|
||||
"audio": [
|
||||
"audio:reference:metin2_patch_eu3/sound/monster2/zombie_diseased_boss/30.mss:sound/monster/misterious_diseased_boss/damage_1.wav",
|
||||
"audio:reference:metin2_patch_eu3/sound/monster2/zombie_diseased_boss/34.mss:sound/monster/misterious_diseased_boss/damage_1.wav",
|
||||
"audio:reference:metin2_patch_eu3/sound/monster2/zombie_diseased_boss/34_1.mss:sound/monster/misterious_diseased_boss/damage_1.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_commander/back_dead.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_commander/back_knockdown.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_commander/front_dead.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_commander/front_knockdown.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_mage/back_dead.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_mage/back_knockdown.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_mage/front_knockdown.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_warrior/back_dead.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_warrior/back_knockdown.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_warrior/front_dead.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:metin2_patch_w20_sound/sound/monster2/gnoll_warrior/front_knockdown.mss:sound/monster2/troll_mage/common_fall_3.wav",
|
||||
"audio:reference:sound2/sound/monster2/outlaw/35.mss:sound/monster2/outlaw/fall.wav",
|
||||
"audio:reference:sound2/sound/pc2/assassin/bow/attack.mss:sound/pc2/assassin/bow/attack1.wav",
|
||||
"audio:reference:sound2/sound/pc2/assassin/bow/attack_1.mss:sound/pc2/assassin/bow/attack1.wav",
|
||||
"audio:reference:sound2/sound/pc2/assassin/bow/attack_2.mss:sound/pc2/assassin/bow/attack1.wav",
|
||||
"audio:reference:sound2/sound/pc2/assassin/dualhand_sword/combo_07.mss:sound/pc2/assassin/dualhand_sword/combo7.wav",
|
||||
"audio:reference:sound_m/sound/effect/etc/start/start.mss:sound/monster/chuhen/club_attack.wav",
|
||||
"audio:reference:sound_m/sound/monster/bou/20-1.mss:sound/monster/bou/bou_swing1.wav",
|
||||
"audio:reference:sound_m/sound/monster/fox_ninetail/03.mss:sound/common/walk_grass_n.wav",
|
||||
"audio:reference:sound_m/sound/monster/gupae/20-1.mss:sound/monster/gupae/gup_swing2.wav",
|
||||
"audio:reference:sound_m/sound/monster/gupae/20-2.mss:sound/monster/gupae/gup_swing3.wav",
|
||||
"audio:reference:sound_m/sound/monster/maenghwan/20-1.mss:sound/monster/maenghwan/mah_swing1.wav",
|
||||
"audio:reference:sound_m/sound/monster/thief1/20-1.mss:sound/monster/thief1/th1_act2.wav",
|
||||
"audio:reference:sound_m/sound/monster/thief1/20-1.mss:sound/monster/thief1/th1_swing2.wav",
|
||||
"audio:reference:sound_m/sound/monster/thief1/20-2.mss:sound/monster/thief1/th1_swing3.wav",
|
||||
"audio:reference:sound_m/sound/monster/thief2/20-1.mss:sound/monster/thief2/th2_swing2.wav",
|
||||
"audio:reference:sound_m/sound/monster/thief2/20-2.mss:sound/monster/thief2/th2_swing3.wav",
|
||||
"audio:reference:sound_m/sound/monster/thiefboss1/20-1.mss:sound/monster/thiefboss1/thb1_swing2.wav",
|
||||
"audio:reference:sound_m/sound/monster/thiefboss2/20-1.mss:sound/monster/thiefboss2/thb2_swing2.wav",
|
||||
"audio:reference:sound_m/sound/monster/thiefboss2/20-2.mss:sound/monster/thiefboss2/thb2_swing3.wav",
|
||||
"audio:reference:sound_m/sound/monster/thiefboss3/20-1.mss:sound/monster/thiefboss3/thb3_swing2.wav",
|
||||
"audio:reference:sound_m/sound/pc/assassin/horse/skill_charge.mss:sound/pc/sura/skill/horse_splash.wav",
|
||||
"audio:reference:sound_m/sound/pc/shaman/horse/skill_charge.mss:sound/pc/sura/skill/horse_splash.wav",
|
||||
"audio:reference:sound_m/sound/pc/sura/general/combo_01.mss:sound/pc/sura/general/attack_1.wav",
|
||||
"audio:reference:sound_m/sound/pc/sura/general/combo_02.mss:sound/pc/sura/general/attack_2.wav",
|
||||
"audio:reference:sound_m/sound/pc/sura/general/combo_03.mss:sound/pc/sura/general/attack_3.wav",
|
||||
"audio:reference:sound_m/sound/pc/sura/general/combo_03.mss:sound/pc/sura/general/swing_3.wav",
|
||||
"audio:reference:sound_m/sound/pc/sura/horse/skill_charge.mss:sound/pc/sura/skill/horse_splash.wav"
|
||||
]
|
||||
}
|
||||
|
||||
163
scripts/validate_audio_scenarios.py
Executable file
163
scripts/validate_audio_scenarios.py
Executable file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from known_issues import classify_issue_ids, load_known_issue_ids
|
||||
|
||||
|
||||
SOUND_DATA_RE = re.compile(r'^SoundData\d+\s+[0-9.]+\s+"([^"]+)"', re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioCheck:
|
||||
file: str
|
||||
references: list[str]
|
||||
missing_references: list[str]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate .mss audio script references against the runtime asset set."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--runtime-root",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Client runtime root containing assets/.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Emit JSON output.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--known-issues",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Optional known issues baseline JSON. Defaults to repo known_issues/runtime_known_issues.json if present.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--strict-known-issues",
|
||||
action="store_true",
|
||||
help="Also fail when the known-issues baseline contains stale entries not observed anymore.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def normalize_virtual_path(value: str) -> str:
|
||||
value = value.strip().strip('"').replace("\\", "/").lower()
|
||||
for prefix in ("d:/", "d:"):
|
||||
if value.startswith(prefix):
|
||||
value = value[len(prefix):]
|
||||
break
|
||||
return value.lstrip("/")
|
||||
|
||||
|
||||
def build_asset_index(runtime_assets: Path) -> set[str]:
|
||||
index: set[str] = set()
|
||||
for path in runtime_assets.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
rel = path.relative_to(runtime_assets)
|
||||
except ValueError:
|
||||
continue
|
||||
rel_posix = rel.as_posix().lower()
|
||||
index.add(rel_posix)
|
||||
if len(rel.parts) >= 2:
|
||||
index.add(Path(*rel.parts[1:]).as_posix().lower())
|
||||
return index
|
||||
|
||||
|
||||
def parse_mss_references(path: Path) -> list[str]:
|
||||
references: list[str] = []
|
||||
for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = raw_line.strip()
|
||||
match = SOUND_DATA_RE.match(line)
|
||||
if match:
|
||||
references.append(match.group(1))
|
||||
return references
|
||||
|
||||
|
||||
def validate_mss(path: Path, runtime_assets: Path, asset_index: set[str]) -> AudioCheck:
|
||||
rel = path.relative_to(runtime_assets).as_posix()
|
||||
missing: list[str] = []
|
||||
references = parse_mss_references(path)
|
||||
|
||||
for reference in references:
|
||||
normalized = normalize_virtual_path(reference)
|
||||
if normalized and normalized not in asset_index:
|
||||
missing.append(reference)
|
||||
|
||||
return AudioCheck(file=rel, references=references, missing_references=sorted(set(missing)))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
runtime_root = args.runtime_root.resolve()
|
||||
runtime_assets = runtime_root / "assets"
|
||||
if not runtime_assets.is_dir():
|
||||
raise SystemExit(f"assets dir not found: {runtime_assets}")
|
||||
|
||||
asset_index = build_asset_index(runtime_assets)
|
||||
checks: list[AudioCheck] = []
|
||||
failures: list[str] = []
|
||||
issue_map: dict[str, str] = {}
|
||||
referenced_audio_files: set[str] = set()
|
||||
|
||||
for path in sorted(runtime_assets.rglob("*.mss")):
|
||||
check = validate_mss(path, runtime_assets, asset_index)
|
||||
checks.append(check)
|
||||
for missing in check.missing_references:
|
||||
normalized = normalize_virtual_path(missing)
|
||||
issue_id = f"audio:reference:{check.file}:{normalized}"
|
||||
message = f"{check.file}: missing reference {missing}"
|
||||
failures.append(message)
|
||||
issue_map[issue_id] = message
|
||||
for reference in check.references:
|
||||
normalized = normalize_virtual_path(reference)
|
||||
if normalized:
|
||||
referenced_audio_files.add(normalized)
|
||||
|
||||
observed_issue_ids = set(issue_map.keys())
|
||||
known_path, known_issue_ids = load_known_issue_ids(__file__, "audio", args.known_issues)
|
||||
known_observed, unexpected_issue_ids, stale_known_issue_ids = classify_issue_ids(observed_issue_ids, known_issue_ids)
|
||||
|
||||
result = {
|
||||
"ok": not unexpected_issue_ids and (not args.strict_known_issues or not stale_known_issue_ids),
|
||||
"checked_files": len(checks),
|
||||
"referenced_audio_files": len(referenced_audio_files),
|
||||
"failures": failures,
|
||||
"issue_ids": sorted(observed_issue_ids),
|
||||
"known_issue_ids": sorted(known_observed),
|
||||
"unexpected_issue_ids": sorted(unexpected_issue_ids),
|
||||
"stale_known_issue_ids": sorted(stale_known_issue_ids),
|
||||
"unexpected_failures": [issue_map[issue_id] for issue_id in sorted(unexpected_issue_ids)],
|
||||
"stale_known_failures": sorted(stale_known_issue_ids),
|
||||
"known_issues_path": str(known_path) if known_path else None,
|
||||
"checks": [asdict(check) for check in checks],
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(
|
||||
f"ok={result['ok']} checked_files={result['checked_files']} "
|
||||
f"referenced_audio_files={result['referenced_audio_files']}"
|
||||
)
|
||||
for failure in result["unexpected_failures"]:
|
||||
print(f"FAIL: {failure}")
|
||||
for issue_id in result["stale_known_issue_ids"]:
|
||||
print(f"STALE: {issue_id}")
|
||||
|
||||
return 0 if result["ok"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -69,6 +69,7 @@ def main() -> int:
|
||||
"world": script_dir / "validate_runtime_scenarios.py",
|
||||
"actor": script_dir / "validate_actor_scenarios.py",
|
||||
"effect": script_dir / "validate_effect_scenarios.py",
|
||||
"audio": script_dir / "validate_audio_scenarios.py",
|
||||
}
|
||||
|
||||
results = {
|
||||
|
||||
Reference in New Issue
Block a user