diff --git a/.github/workflows/build-v1.4-kfps-differential-decoder-test.yml b/.github/workflows/build-v1.4-kfps-differential-decoder-test.yml new file mode 100644 index 0000000..3b12893 --- /dev/null +++ b/.github/workflows/build-v1.4-kfps-differential-decoder-test.yml @@ -0,0 +1,130 @@ +name: Build FH6 Assistant v1.4 Decoder Differential Test + +on: + workflow_dispatch: + push: + branches: + - v1.4-kfps-differential-decoder-test + paths: + - ".github/workflows/build-v1.4-kfps-differential-decoder-test.yml" + - "source-v1.2/**" + pull_request: + branches: + - v1.4-kfps-3.1.31-clean-decoder-test + paths: + - ".github/workflows/build-v1.4-kfps-differential-decoder-test.yml" + - "source-v1.2/**" + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: windows-latest + + steps: + - name: Checkout differential branch + uses: actions/checkout@v6 + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Vendor KFPS 3.1.31 current backend + shell: pwsh + run: | + $expected = "004b3b61a57d901e65957b6099805835f91e32f6" + $vendor = Join-Path $PWD "source-v1.2\vendor\kfps" + if (Test-Path $vendor) { Remove-Item $vendor -Recurse -Force } + New-Item -ItemType Directory -Force -Path (Split-Path $vendor) | Out-Null + + git clone --filter=blob:none --no-checkout https://github.com/heyitshestia/kloudys-forza-painter-suite.git $vendor + git -C $vendor sparse-checkout init --no-cone + @' + /json_preview_renderer.py + /geometry_json.py + /kfps_shapes/ + /LICENSE + /tools/cgroup/ + /tools/livery/ + /tools/fabric-editor/shape-words.json + /tools/fabric-editor/shape-names.json + /tools/fabric-editor/Resources/Vinyls/ + '@ | Set-Content -Path (Join-Path $vendor ".git\info\sparse-checkout") -Encoding ascii + git -C $vendor fetch --depth 1 origin $expected + git -C $vendor checkout --detach FETCH_HEAD + $actual = (git -C $vendor rev-parse HEAD).Trim() + if ($actual -ne $expected) { throw "Unexpected current KFPS commit: $actual" } + Write-Host "Current KFPS backend: $actual" + + - name: Vendor KFPS 3.1.27 legacy decoder + shell: pwsh + run: | + $expected = "8965780b8966e09d2f2a17e4d0684cdd44d7437c" + $vendor = Join-Path $PWD "source-v1.2\vendor\kfps_legacy" + if (Test-Path $vendor) { Remove-Item $vendor -Recurse -Force } + New-Item -ItemType Directory -Force -Path (Split-Path $vendor) | Out-Null + + git clone --filter=blob:none --no-checkout https://github.com/heyitshestia/kloudys-forza-painter-suite.git $vendor + git -C $vendor sparse-checkout init --no-cone + @' + /LICENSE + /tools/cgroup/forza_source_decoder.py + /tools/cgroup/shape_identity.py + '@ | Set-Content -Path (Join-Path $vendor ".git\info\sparse-checkout") -Encoding ascii + git -C $vendor fetch --depth 1 origin $expected + git -C $vendor checkout --detach FETCH_HEAD + $actual = (git -C $vendor rev-parse HEAD).Trim() + if ($actual -ne $expected) { throw "Unexpected legacy KFPS commit: $actual" } + Write-Host "Legacy KFPS decoder: $actual" + + - name: Install dependencies + shell: pwsh + run: | + Set-Location source-v1.2 + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install --upgrade pyinstaller + + - name: Compile and run regression suite + shell: pwsh + env: + QT_QPA_PLATFORM: offscreen + run: | + Set-Location source-v1.2 + python -m compileall -q . + python -m unittest discover -s tests -v + + - name: Build differential EXE + shell: pwsh + run: | + Set-Location source-v1.2 + python -m PyInstaller --clean --noconfirm .\FH6_Assistant_v1.4.spec + $exe = Join-Path $PWD "dist\FH6 Assistant v1.4.exe" + if (-not (Test-Path $exe)) { throw "FH6 Assistant v1.4.exe was not created." } + $size = (Get-Item $exe).Length + if ($size -lt 10000000) { throw "Built EXE is unexpectedly small: $size bytes" } + Write-Host "Built EXE bytes: $size" + + - name: Prepare differential artifact + shell: pwsh + run: | + $source = Resolve-Path "source-v1.2\dist\FH6 Assistant v1.4.exe" + $name = "FH6 Assistant v1.4 Decoder Differential Test.exe" + New-Item -ItemType Directory -Force -Path artifact | Out-Null + $target = Join-Path $PWD "artifact\$name" + Copy-Item $source $target -Force + $hash = (Get-FileHash $target -Algorithm SHA256).Hash.ToLower() + "$hash $name" | Set-Content -Path "artifact\$name.sha256" -Encoding ascii + Write-Host "SHA-256: $hash" + + - name: Upload differential build + uses: actions/upload-artifact@v6 + with: + name: FH6_Assistant_v1.4_Decoder_Differential_Test + path: | + artifact/FH6 Assistant v1.4 Decoder Differential Test.exe + artifact/FH6 Assistant v1.4 Decoder Differential Test.exe.sha256 + if-no-files-found: error + compression-level: 0 diff --git a/source-v1.2/FH6_Assistant_v1.4.spec b/source-v1.2/FH6_Assistant_v1.4.spec index 974b1f7..0d146a9 100644 --- a/source-v1.2/FH6_Assistant_v1.4.spec +++ b/source-v1.2/FH6_Assistant_v1.4.spec @@ -3,12 +3,18 @@ from pathlib import Path project_root = Path(SPECPATH) vendor_root = project_root / 'vendor' / 'kfps' +legacy_root = project_root / 'vendor' / 'kfps_legacy' if not vendor_root.is_dir(): raise SystemExit( 'Pinned KFPS livery preview backend is missing. ' 'Run the v1.4 build workflow or populate source-v1.2/vendor/kfps first.' ) +if not legacy_root.is_dir(): + raise SystemExit( + 'Legacy KFPS 3.1.27 decoder bundle is missing. ' + 'Run the differential build workflow or populate source-v1.2/vendor/kfps_legacy first.' + ) required_vendor_files = [ vendor_root / 'json_preview_renderer.py', @@ -27,9 +33,14 @@ required_vendor_files = [ vendor_root / 'tools' / 'fabric-editor' / 'Resources' / 'Vinyls', vendor_root / 'LICENSE', ] -missing = [str(path) for path in required_vendor_files if not path.exists()] +required_legacy_files = [ + legacy_root / 'tools' / 'cgroup' / 'forza_source_decoder.py', + legacy_root / 'tools' / 'cgroup' / 'shape_identity.py', + legacy_root / 'LICENSE', +] +missing = [str(path) for path in [*required_vendor_files, *required_legacy_files] if not path.exists()] if missing: - raise SystemExit('Incomplete KFPS preview backend:\n' + '\n'.join(missing)) + raise SystemExit('Incomplete KFPS differential backend:\n' + '\n'.join(missing)) datas = [ (str(project_root / 'data' / 'car_names.json'), 'data'), @@ -37,6 +48,9 @@ datas = [ (str(vendor_root / 'tools' / 'fabric-editor' / 'shape-words.json'), 'tools/fabric-editor'), (str(vendor_root / 'tools' / 'fabric-editor' / 'Resources' / 'Vinyls'), 'tools/fabric-editor/Resources/Vinyls'), (str(vendor_root / 'LICENSE'), 'vendor/kfps'), + (str(legacy_root / 'tools' / 'cgroup' / 'forza_source_decoder.py'), 'vendor/kfps_legacy/tools/cgroup'), + (str(legacy_root / 'tools' / 'cgroup' / 'shape_identity.py'), 'vendor/kfps_legacy/tools/cgroup'), + (str(legacy_root / 'LICENSE'), 'vendor/kfps_legacy'), ] shape_names = vendor_root / 'tools' / 'fabric-editor' / 'shape-names.json' if shape_names.is_file(): diff --git a/source-v1.2/app.py b/source-v1.2/app.py index f1a3063..8111192 100644 --- a/source-v1.2/app.py +++ b/source-v1.2/app.py @@ -38,6 +38,7 @@ from fh6garage.livery_render_integrity_patch import apply_livery_render_integrity_patch from fh6garage.livery_preview_ui_polish import apply_livery_preview_ui_polish from fh6garage.livery_kfps_3_1_31_clean_baseline import apply_kfps_3_1_31_clean_baseline +from fh6garage.livery_decoder_differential_test import apply_livery_decoder_differential_test def resource_root() -> Path: @@ -57,9 +58,9 @@ def main() -> int: settings = QSettings() set_language(settings.value("language", DEFAULT_LANGUAGE, str)) - # Clean-decoder A/B test: rely on KFPS 3.1.31's upstream C_livery grammar. - # Do not install any FH6 Assistant decoder recovery, transform, section, - # compact-shape, structural-audit, or source-offset-order patch here. + # The visible preview stays on clean KFPS 3.1.31. The differential test + # separately loads the previous 3.1.27 decoder plus the proven FH6 Assistant + # decoder rules and compares both results without switching runtime decoder. apply_v1_3_ui_patches(MainWindow) apply_v1_3_1_patches(MainWindow) @@ -70,9 +71,6 @@ def main() -> int: apply_v1_4_quality_pipeline_patch(MainWindow) apply_v1_4_preview_final_ui_patch(MainWindow) - # Keep the established v1.4 rendering/UI pipeline. Only the C_livery decoder - # source changes in this test; warning-only integrity and persistent quality - # scale remain enabled without rewriting decoded layer order. apply_livery_tiled_runtime_patch() apply_livery_render_acceleration_patch() apply_livery_raster_runtime_patch() @@ -80,6 +78,10 @@ def main() -> int: apply_livery_preview_ui_polish() apply_kfps_3_1_31_clean_baseline() + # Diagnostic-only wrapper. Opening a livery preview emits a JSON comparison + # under LocalAppData and then opens the normal clean 3.1.31 preview. + apply_livery_decoder_differential_test(MainWindow) + root = resource_root() icon_path = root / "icons" / "FH6_Assistant.ico" if icon_path.is_file(): diff --git a/source-v1.2/fh6garage/livery_decoder_differential_test.py b/source-v1.2/fh6garage/livery_decoder_differential_test.py new file mode 100644 index 0000000..3b4669e --- /dev/null +++ b/source-v1.2/fh6garage/livery_decoder_differential_test.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import sys +import threading +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + +from PySide6.QtWidgets import QMessageBox + + +LEGACY_KFPS_COMMIT = "8965780b8966e09d2f2a17e4d0684cdd44d7437c" +CURRENT_KFPS_COMMIT = "004b3b61a57d901e65957b6099805835f91e32f6" +_PATCH_FLAG = "_fh6assistant_decoder_differential_test_v1" +_LEGACY_LOCK = threading.RLock() +_LEGACY_DECODER = None +_SECTION_NAMES = ( + "Front", + "Back", + "Top", + "Left", + "Right", + "Spoiler", + "FrontWindshield", + "BackWindshield", + "TopWindow", + "LeftWindow", + "RightWindow", +) + + +def _runtime_roots() -> tuple[Path, ...]: + roots = [Path(__file__).resolve().parents[1]] + frozen = getattr(sys, "_MEIPASS", None) + if frozen: + roots.insert(0, Path(frozen)) + return tuple(dict.fromkeys(roots)) + + +def _legacy_cgroup_dir() -> Path: + for root in _runtime_roots(): + candidate = root / "vendor" / "kfps_legacy" / "tools" / "cgroup" + if (candidate / "forza_source_decoder.py").is_file() and (candidate / "shape_identity.py").is_file(): + return candidate + raise RuntimeError("KFPS 3.1.27 legacy decoder bundle is missing.") + + +def _load_legacy_decoder_module(): + global _LEGACY_DECODER + with _LEGACY_LOCK: + if _LEGACY_DECODER is not None: + return _LEGACY_DECODER + + cgroup_dir = _legacy_cgroup_dir() + decoder_path = cgroup_dir / "forza_source_decoder.py" + module_name = "fh6assistant_kfps_3127_decoder" + spec = importlib.util.spec_from_file_location(module_name, decoder_path) + if spec is None or spec.loader is None: + raise RuntimeError("KFPS 3.1.27 decoder module could not be loaded.") + + previous_path = list(sys.path) + try: + sys.path.insert(0, str(cgroup_dir)) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + finally: + sys.path[:] = previous_path + + _apply_legacy_fh6assistant_decoder_rules(module) + _LEGACY_DECODER = module + return module + + +def _apply_legacy_fh6assistant_decoder_rules(decoder: Any) -> None: + """Attach the last proven FH6 Assistant decoder rules only to KFPS 3.1.27.""" + from . import livery_preview + from . import livery_preview_quality_pipeline as quality_pipeline + from . import livery_preview_tiled_quality as tiled_quality + from .livery_bare_parent_transform_fix import apply_livery_bare_parent_transform_fix + from .livery_compact_shape_guard_patch import apply_livery_compact_shape_guard_patch + from .livery_consecutive_transform_pair_fix import apply_livery_consecutive_transform_pair_fix + from .livery_decoder_recovery_patch import apply_livery_decoder_recovery_patch + from .livery_section_boundary_fix_patch import apply_livery_section_boundary_fix_patch + + real_load_backend = livery_preview._load_backend + _current_decoder, renderer = real_load_backend() + quality_version = getattr(quality_pipeline, "CACHE_VERSION", None) + tiled_version = getattr(tiled_quality, "CACHE_VERSION", None) + + def legacy_backend(): + return decoder, renderer + + livery_preview._load_backend = legacy_backend + try: + apply_livery_compact_shape_guard_patch() + apply_livery_section_boundary_fix_patch() + apply_livery_decoder_recovery_patch() + apply_livery_bare_parent_transform_fix() + apply_livery_consecutive_transform_pair_fix() + finally: + livery_preview._load_backend = real_load_backend + if quality_version is not None: + quality_pipeline.CACHE_VERSION = quality_version + if tiled_version is not None: + tiled_quality.CACHE_VERSION = tiled_version + + +def _jsonable(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, bytes): + return value.hex() + if isinstance(value, dict): + return {str(key): _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_jsonable(item) for item in value] + return repr(value) + + +def _int_or_none(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _layer_snapshot(layer: dict[str, Any], renderer: Any | None = None) -> dict[str, Any]: + snapshot = {str(key): _jsonable(value) for key, value in layer.items()} + type_code = _int_or_none(layer.get("type")) + if renderer is not None and type_code is not None and not bool(layer.get("is_raster_logo")): + resolver = getattr(renderer, "_resolve_vinyl_resource", None) + if callable(resolver): + try: + resource = resolver(type_code, layer) + except Exception: + resource = None + snapshot["resolved_resource_current_renderer"] = _jsonable(resource) + return snapshot + + +def _semantic_key(layer: dict[str, Any]) -> tuple[Any, ...]: + return ( + str(layer.get("source_section") or ""), + _int_or_none(layer.get("source_offset")), + _int_or_none(layer.get("type")), + _int_or_none(layer.get("type_word")), + _int_or_none(layer.get("shape_word")), + bool(layer.get("mask")), + bool(layer.get("is_raster_logo")), + _int_or_none(layer.get("raster_id")), + tuple(_jsonable(layer.get("data") or ())), + tuple(_jsonable(layer.get("color") or ())), + ) + + +def _section_layers(decoded: Any) -> dict[str, list[dict[str, Any]]]: + result = {name: [] for name in _SECTION_NAMES} + for layer in list(getattr(decoded, "layers", ()) or ()): + if not isinstance(layer, dict): + continue + section = str(layer.get("source_section") or "") + result.setdefault(section, []).append(layer) + return result + + +def _first_order_divergence(left: list[dict[str, Any]], right: list[dict[str, Any]]) -> dict[str, Any] | None: + left_offsets = [_int_or_none(item.get("source_offset")) for item in left] + right_offsets = [_int_or_none(item.get("source_offset")) for item in right] + limit = min(len(left_offsets), len(right_offsets)) + for index in range(limit): + if left_offsets[index] != right_offsets[index]: + return { + "index": index, + "legacy_source_offset": left_offsets[index], + "current_source_offset": right_offsets[index], + "legacy_window": left_offsets[max(0, index - 3) : index + 4], + "current_window": right_offsets[max(0, index - 3) : index + 4], + } + if len(left_offsets) != len(right_offsets): + return { + "index": limit, + "legacy_source_offset": left_offsets[limit] if limit < len(left_offsets) else None, + "current_source_offset": right_offsets[limit] if limit < len(right_offsets) else None, + "legacy_window": left_offsets[max(0, limit - 3) : limit + 4], + "current_window": right_offsets[max(0, limit - 3) : limit + 4], + } + return None + + +def _offset_buckets(layers: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: + buckets: dict[int, list[dict[str, Any]]] = defaultdict(list) + for layer in layers: + offset = _int_or_none(layer.get("source_offset")) + if offset is not None: + buckets[offset].append(layer) + return dict(buckets) + + +def compare_decoded_sources(legacy: Any, current: Any, renderer: Any | None = None) -> dict[str, Any]: + legacy_sections = _section_layers(legacy) + current_sections = _section_layers(current) + + section_summary: dict[str, Any] = {} + for name in _SECTION_NAMES: + old_items = legacy_sections.get(name, []) + new_items = current_sections.get(name, []) + section_summary[name] = { + "legacy_count": len(old_items), + "current_count": len(new_items), + "count_delta": len(new_items) - len(old_items), + "first_order_divergence": _first_order_divergence(old_items, new_items), + } + + legacy_layers = [item for items in legacy_sections.values() for item in items] + current_layers = [item for items in current_sections.values() for item in items] + legacy_by_offset = _offset_buckets(legacy_layers) + current_by_offset = _offset_buckets(current_layers) + all_offsets = sorted(set(legacy_by_offset) | set(current_by_offset)) + + differences: list[dict[str, Any]] = [] + semantic_difference_count = 0 + missing_in_current = 0 + new_in_current = 0 + duplicate_offset_cases = 0 + + for offset in all_offsets: + old_bucket = legacy_by_offset.get(offset, []) + new_bucket = current_by_offset.get(offset, []) + if old_bucket and new_bucket and (len(old_bucket) != 1 or len(new_bucket) != 1): + duplicate_offset_cases += 1 + if not old_bucket: + new_in_current += len(new_bucket) + differences.append({ + "kind": "only_current", + "source_offset": offset, + "current": [_layer_snapshot(item, renderer) for item in new_bucket], + }) + continue + if not new_bucket: + missing_in_current += len(old_bucket) + differences.append({ + "kind": "only_legacy", + "source_offset": offset, + "legacy": [_layer_snapshot(item, renderer) for item in old_bucket], + }) + continue + + pair_count = min(len(old_bucket), len(new_bucket)) + for index in range(pair_count): + old_layer = old_bucket[index] + new_layer = new_bucket[index] + if _semantic_key(old_layer) != _semantic_key(new_layer): + semantic_difference_count += 1 + differences.append({ + "kind": "semantic_difference", + "source_offset": offset, + "occurrence": index, + "legacy": _layer_snapshot(old_layer, renderer), + "current": _layer_snapshot(new_layer, renderer), + }) + for item in old_bucket[pair_count:]: + missing_in_current += 1 + differences.append({ + "kind": "only_legacy_duplicate", + "source_offset": offset, + "legacy": _layer_snapshot(item, renderer), + }) + for item in new_bucket[pair_count:]: + new_in_current += 1 + differences.append({ + "kind": "only_current_duplicate", + "source_offset": offset, + "current": _layer_snapshot(item, renderer), + }) + + max_differences = 1200 + truncated = max(0, len(differences) - max_differences) + if truncated: + differences = differences[:max_differences] + + return { + "summary": { + "legacy_total_layers": len(list(getattr(legacy, "layers", ()) or ())), + "current_total_layers": len(list(getattr(current, "layers", ()) or ())), + "semantic_difference_count": semantic_difference_count, + "only_legacy_count": missing_in_current, + "only_current_count": new_in_current, + "duplicate_offset_cases": duplicate_offset_cases, + "reported_differences": len(differences), + "truncated_difference_count": truncated, + }, + "sections": section_summary, + "differences": differences, + "legacy_report": _jsonable(getattr(legacy, "report", {}) or {}), + "current_report": _jsonable(getattr(current, "report", {}) or {}), + } + + +def _decode_legacy(path: Path): + from .livery_baseline_behavior_patch import normalize_decoded_layer_order + + decoder = _load_legacy_decoder_module() + decoded = decoder.decode_forza_source(path, allow_locked=True, game="fh6") + if str(getattr(decoded, "source_kind", "")).casefold() == "clivery": + decoded, _changed = normalize_decoded_layer_order(decoded, tuple(decoder.LIVERY_SECTION_NAMES)) + return decoded + + +def _decode_current(path: Path): + from .livery_preview import _load_backend + + decoder, renderer = _load_backend() + decoded = decoder.decode_forza_source(path, allow_locked=True, game="fh6") + return decoded, renderer + + +def _diagnostic_dir() -> Path: + base = os.environ.get("LOCALAPPDATA") + root = Path(base) / "FH6GarageAnalyzer" if base else Path.home() / ".fh6garage" + target = root / "decoder_differential" + target.mkdir(parents=True, exist_ok=True) + return target + + +def _report_path(source: Path) -> Path: + key = hashlib.sha1(str(source.resolve()).encode("utf-8", errors="replace")).hexdigest()[:12] + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + parent = source.parent.name or source.stem + safe_parent = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in parent)[:48] + return _diagnostic_dir() / f"decoder-diff-{safe_parent}-{key}-{stamp}.json" + + +def generate_decoder_differential_report(path: Path | str) -> Path: + source = Path(path) + if not source.is_file(): + raise FileNotFoundError(source) + + legacy = _decode_legacy(source) + current, renderer = _decode_current(source) + comparison = compare_decoded_sources(legacy, current, renderer) + payload = { + "format": "FH6 Assistant decoder differential v1", + "source": str(source), + "legacy": { + "kfps_commit": LEGACY_KFPS_COMMIT, + "fh6assistant_rules": [ + "compact_shape_guard", + "section_boundary_fix", + "decoder_recovery", + "bare_parent_transform_fix", + "consecutive_transform_pair_fix", + "source_offset_order_normalization", + ], + }, + "current": { + "kfps_commit": CURRENT_KFPS_COMMIT, + "fh6assistant_decoder_rules": [], + }, + **comparison, + } + target = _report_path(source) + target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return target + + +def apply_livery_decoder_differential_test(MainWindow: type) -> None: + """Generate a decoder A/B JSON whenever a livery preview is opened.""" + if bool(getattr(MainWindow, _PATCH_FLAG, False)): + return + original = MainWindow._show_livery_image + + def show_with_decoder_diff(self, record): + report_path: Path | None = None + report_error: Exception | None = None + livery_path = getattr(record, "livery_path", None) + if livery_path: + try: + report_path = generate_decoder_differential_report(livery_path) + except Exception as exc: + report_error = exc + + result = original(self, record) + + if report_path is not None: + QMessageBox.information( + self, + "Decoder Differential", + "3.1.27 patched vs 3.1.31 clean 비교 JSON을 저장했습니다.\n\n" + str(report_path), + ) + elif report_error is not None: + QMessageBox.warning( + self, + "Decoder Differential", + "비교 JSON 생성에 실패했습니다. 미리보기 자체에는 영향을 주지 않습니다.\n\n" + + str(report_error), + ) + return result + + MainWindow._show_livery_image = show_with_decoder_diff + setattr(MainWindow, _PATCH_FLAG, True) diff --git a/source-v1.2/fh6garage/livery_preview.py b/source-v1.2/fh6garage/livery_preview.py index badd46f..a3b08c3 100644 --- a/source-v1.2/fh6garage/livery_preview.py +++ b/source-v1.2/fh6garage/livery_preview.py @@ -18,7 +18,7 @@ from .livery_analysis import LiveryAnalysisError, analyze_livery_file -KFPS_VENDOR_COMMIT = "8965780b8966e09d2f2a17e4d0684cdd44d7437c" +KFPS_VENDOR_COMMIT = "004b3b61a57d901e65957b6099805835f91e32f6" class LiveryPreviewError(RuntimeError): @@ -345,10 +345,6 @@ def _render_section_cached( ) try: - # Native resources were already validated above. Use the renderer's - # non-fatal visibility path so legitimate zero-alpha/off-canvas no-op - # placements do not abort an otherwise exact section reconstruction. - # Missing resources can never reach this call because preflight fails. rendered = renderer.render_typecode_layers_canvas( prepared_layers, width=2048, diff --git a/source-v1.2/tests/test_livery_decoder_differential_test.py b/source-v1.2/tests/test_livery_decoder_differential_test.py new file mode 100644 index 0000000..abacef1 --- /dev/null +++ b/source-v1.2/tests/test_livery_decoder_differential_test.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import unittest +from pathlib import Path +from types import SimpleNamespace + +from fh6garage.livery_decoder_differential_test import ( + CURRENT_KFPS_COMMIT, + LEGACY_KFPS_COMMIT, + _legacy_cgroup_dir, + compare_decoded_sources, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +class DecoderDifferentialTests(unittest.TestCase): + def test_both_decoder_versions_are_pinned(self): + self.assertEqual(LEGACY_KFPS_COMMIT, "8965780b8966e09d2f2a17e4d0684cdd44d7437c") + self.assertEqual(CURRENT_KFPS_COMMIT, "004b3b61a57d901e65957b6099805835f91e32f6") + cgroup = _legacy_cgroup_dir() + self.assertTrue((cgroup / "forza_source_decoder.py").is_file()) + self.assertTrue((cgroup / "shape_identity.py").is_file()) + + def test_compare_reports_count_order_and_semantic_differences(self): + legacy = SimpleNamespace( + layers=[ + {"source_section": "Left", "source_offset": 10, "type": 1048677, "data": [0, 0, 1, 1, 0, 0, 0], "color": [0, 0, 0, 255], "mask": False}, + {"source_section": "Left", "source_offset": 20, "type": 1048678, "data": [1, 0, 1, 1, 0, 0, 0], "color": [0, 0, 0, 255], "mask": False}, + ], + report={"warnings": []}, + ) + current = SimpleNamespace( + layers=[ + {"source_section": "Left", "source_offset": 20, "type": 1048678, "data": [2, 0, 1, 1, 0, 0, 0], "color": [0, 0, 0, 255], "mask": False}, + {"source_section": "Left", "source_offset": 30, "type": 1048679, "data": [0, 0, 1, 1, 0, 0, 0], "color": [0, 0, 0, 255], "mask": False}, + ], + report={"warnings": []}, + ) + result = compare_decoded_sources(legacy, current) + self.assertEqual(result["summary"]["only_legacy_count"], 1) + self.assertEqual(result["summary"]["only_current_count"], 1) + self.assertEqual(result["summary"]["semantic_difference_count"], 1) + self.assertEqual(result["sections"]["Left"]["legacy_count"], 2) + self.assertEqual(result["sections"]["Left"]["current_count"], 2) + self.assertEqual(result["sections"]["Left"]["first_order_divergence"]["index"], 0) + + def test_app_installs_differential_after_clean_baseline(self): + app = (ROOT / "app.py").read_text(encoding="utf-8") + clean = app.index("apply_kfps_3_1_31_clean_baseline()") + diff = app.index("apply_livery_decoder_differential_test(MainWindow)") + self.assertGreater(diff, clean) + + def test_spec_bundles_legacy_decoder_as_data_not_import_path(self): + spec = (ROOT / "FH6_Assistant_v1.4.spec").read_text(encoding="utf-8") + self.assertIn("vendor/kfps_legacy/tools/cgroup", spec) + self.assertIn("forza_source_decoder.py", spec) + self.assertNotIn("str(legacy_root), str(project_root)", spec) + + +if __name__ == "__main__": + unittest.main()