diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..979e990 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 9799d95..b103c0d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/migration.md b/docs/migration.md index aa5fcf5..83d3bf1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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. diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 988463e..72f1ff2 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -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 diff --git a/docs/testing.md b/docs/testing.md index 3bfa8f8..79baeb2 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/known_issues/runtime_known_issues.json b/known_issues/runtime_known_issues.json index 2659b49..7d7e04b 100644 --- a/known_issues/runtime_known_issues.json +++ b/known_issues/runtime_known_issues.json @@ -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" ] } diff --git a/scripts/validate_audio_scenarios.py b/scripts/validate_audio_scenarios.py new file mode 100755 index 0000000..2ade219 --- /dev/null +++ b/scripts/validate_audio_scenarios.py @@ -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()) diff --git a/scripts/validate_runtime_gate.py b/scripts/validate_runtime_gate.py index c4757f1..0e1e78a 100755 --- a/scripts/validate_runtime_gate.py +++ b/scripts/validate_runtime_gate.py @@ -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 = {