diff --git a/docs/GUITAR_HERO_V5_LIVE_FIX.md b/docs/GUITAR_HERO_V5_LIVE_FIX.md new file mode 100644 index 0000000..575118a --- /dev/null +++ b/docs/GUITAR_HERO_V5_LIVE_FIX.md @@ -0,0 +1,15 @@ +# Guitar Hero V5 live input fix + +The retained GH1 first-run capture proved an important distinction between **song note +mapping** and **menu confirmation** on the current PCSX2 virtual-controller path. + +- Gameplay lanes remain: Green=L2, Red=L1, Yellow=R1, Blue=R2, Orange=Cross. +- Normal AutoPilot menu confirmation is `confirm`, which the virtual-gamepad backend maps + to Xbox A / PS2 Cross. +- The `USING THE GUITAR CONTROLLER` card prints `Continue (Green button)`, but repeated + virtual L2 attempts did not advance the live game. V5 therefore sends `confirm`/Cross + first, then retains L2 as a bounded fallback if the frame does not change. + +This is intentionally evidence-driven. The profile does **not** change the five gameplay +lane bindings based on this menu behavior. Live note-highway acceptance will independently +verify that PCSX2 receives L2/L1/R1/R2/Cross correctly during a song. diff --git a/src/ps2_autopilot/profiles/__init__.py b/src/ps2_autopilot/profiles/__init__.py index 3883baf..e1665bd 100644 --- a/src/ps2_autopilot/profiles/__init__.py +++ b/src/ps2_autopilot/profiles/__init__.py @@ -2,6 +2,7 @@ from .guitar_hero_v2 import GuitarHeroPhase, GuitarHeroScreen, GuitarHeroV2Profile from .guitar_hero_v3 import GuitarHeroV3Profile from .guitar_hero_v4 import GuitarHeroV4Profile +from .guitar_hero_v5 import GuitarHeroV5Profile from .jak_and_daxter import JakAndDaxterProfile as JakAndDaxterV1Profile, JakPhase from .jak_and_daxter_v2 import JakAndDaxterV2Profile from .jak_and_daxter_v3 import JakAndDaxterV3Profile @@ -69,7 +70,7 @@ from .nfs_hot_pursuit_2_v13 import NfsHotPursuit2V13Profile from .nfs_hot_pursuit_2_v14 import NfsHotPursuit2V14Profile -GuitarHeroProfile = GuitarHeroV4Profile +GuitarHeroProfile = GuitarHeroV5Profile JakAndDaxterProfile = JakAndDaxterV22Profile Madden2005Profile = Madden2005V32Profile NfsHotPursuit2Profile = NfsHotPursuit2V14Profile @@ -88,6 +89,7 @@ "GuitarHeroV2Profile", "GuitarHeroV3Profile", "GuitarHeroV4Profile", + "GuitarHeroV5Profile", "GuitarHeroPhase", "GuitarHeroScreen", "JakAndDaxterProfile", diff --git a/src/ps2_autopilot/profiles/guitar_hero_v5.py b/src/ps2_autopilot/profiles/guitar_hero_v5.py new file mode 100644 index 0000000..a1bb4e5 --- /dev/null +++ b/src/ps2_autopilot/profiles/guitar_hero_v5.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from ps2_autopilot.controllers.base import Controller + +from .base import ProfileContext +from .guitar_hero_types import GuitarHeroScreen +from .guitar_hero_v4 import GuitarHeroV4Profile + + +class GuitarHeroV5Profile(GuitarHeroV4Profile): + """V5: use the live-proven menu-confirm path on GH1's controller tutorial. + + V3/V4 correctly recognized the first-run ``USING THE GUITAR CONTROLLER`` card, + but treated its printed ``Continue (Green button)`` literally and sent PS2 L2. + On the current PCSX2 virtual-controller path that trigger was accepted by the + backend yet the card did not advance, exhausting the retry budget. + + Normal Guitar Hero menus in this profile already use ``confirm`` (virtual Xbox A + -> PS2 Cross). V5 therefore tries that menu-confirm path first and keeps L2 only + as a bounded fallback. Gameplay note mapping remains unchanged: L2/L1/R1/R2/X + still represent green/red/yellow/blue/orange while a song is active. + """ + + policy_version = 5 + + def __init__(self, cfg: dict) -> None: + super().__init__(cfg) + configured = self.cfg.get("controller_tutorial_continue_actions") + if configured is None: + actions = ("confirm", "l2", "confirm") + elif isinstance(configured, str): + actions = tuple(part.strip().lower() for part in configured.split(",") if part.strip()) + else: + actions = tuple(str(part).strip().lower() for part in configured if str(part).strip()) + if not actions: + actions = ("confirm",) + allowed = {"confirm", "cross", "l2"} + invalid = [action for action in actions if action not in allowed] + if invalid: + raise ValueError( + "Guitar Hero controller tutorial actions must be confirm, cross, or l2; " + f"got {invalid!r}" + ) + self.controller_tutorial_continue_actions = actions + # The action sequence is the retry budget. Respect an explicitly smaller + # configured max-attempt count, otherwise allow the complete sequence. + explicit_max = self.cfg.get("controller_tutorial_max_attempts") + if explicit_max is None: + self.controller_tutorial_max_attempts = len(actions) + else: + self.controller_tutorial_max_attempts = max( + 1, min(int(explicit_max), len(actions)) + ) + self._controller_tutorial_last_action = "" + + def _tutorial_action_for_attempt(self) -> str: + index = min( + self._controller_tutorial_attempts, + len(self.controller_tutorial_continue_actions) - 1, + ) + return self.controller_tutorial_continue_actions[index] + + def _tutorial_tick(self, controller: Controller, ctx: ProfileContext, score: float) -> str: + now = ctx.now + if not self._controller_tutorial_active: + self._controller_tutorial_active = True + self._controller_tutorial_since = now + self._controller_tutorial_attempts = 0 + self.controller_tutorial_episodes += 1 + + self._controller_tutorial_last_seen = now + self._controller_tutorial_score = score + + self._update_screen(GuitarHeroScreen.PRESENTATION, now) + self._release_gameplay_inputs(controller) + + stable_for = now - self._controller_tutorial_since + since_input = now - self._controller_tutorial_last_input + ready = stable_for >= self.controller_tutorial_settle_seconds + retry_ready = ( + self._controller_tutorial_attempts == 0 + or since_input >= self.controller_tutorial_retry_seconds + ) + if ( + ready + and retry_ready + and self._controller_tutorial_attempts < self.controller_tutorial_max_attempts + ): + action = self._tutorial_action_for_attempt() + controller.release_all() + controller.neutral_sticks() + controller.tap(action, 0.08 if action in {"confirm", "cross"} else 0.06) + self.menu_inputs += 1 + self.controller_tutorial_inputs += 1 + self._controller_tutorial_attempts += 1 + self._controller_tutorial_last_input = now + self._controller_tutorial_last_action = action + label = "confirm/Cross" if action in {"confirm", "cross"} else "green/L2 fallback" + return ( + f"controller tutorial: Continue with {label} " + f"({self._controller_tutorial_attempts}/{self.controller_tutorial_max_attempts})" + ) + + if self._controller_tutorial_attempts >= self.controller_tutorial_max_attempts: + return "controller tutorial: confirm fallback budget exhausted; wait for visual progress" + return "controller tutorial settling; wait before Continue input" + + def telemetry(self, ctx: ProfileContext) -> dict: + state = super().telemetry(ctx) + state.update( + { + "gh_policy_version": self.policy_version, + "gh_controller_tutorial_last_action": self._controller_tutorial_last_action, + "gh_controller_tutorial_continue_actions": list( + self.controller_tutorial_continue_actions + ), + } + ) + return state diff --git a/src/ps2_autopilot/profiles/registry.py b/src/ps2_autopilot/profiles/registry.py index e438193..fd73634 100644 --- a/src/ps2_autopilot/profiles/registry.py +++ b/src/ps2_autopilot/profiles/registry.py @@ -5,7 +5,7 @@ from .base import GameProfile from .generic_chaos import GenericChaosProfile -from .guitar_hero_v4 import GuitarHeroV4Profile +from .guitar_hero_v5 import GuitarHeroV5Profile from .jak_and_daxter_v22_hardened import JakAndDaxterV22Profile from .madden2005_v32 import Madden2005V32Profile from .nfs_hot_pursuit_2_v14 import NfsHotPursuit2V14Profile @@ -25,7 +25,7 @@ def _generic_factory(cfg: dict) -> GameProfile: def _guitar_hero_factory(cfg: dict) -> GameProfile: - return GuitarHeroV4Profile(dict(cfg)) + return GuitarHeroV5Profile(dict(cfg)) def _madden_factory(cfg: dict) -> GameProfile: @@ -52,8 +52,8 @@ def _nfs_hp2_factory(cfg: dict) -> GameProfile: name="guitar_hero", display_name="Guitar Hero (2005)", template_namespace="guitar_hero", - # V4 keeps V3's first-run tutorial ownership and hardens the live GH1 title - # card against false MAIN_MENU/GAMEPLAY claims from the colored logo artwork. + # V5 keeps V4's hardened title/tutorial ownership and changes the first-run + # controller card to use menu Confirm/Cross before a bounded L2 fallback. # Keep diagnostic until the complete live lifecycle earns retained evidence. maturity="diagnostic", factory=_guitar_hero_factory, diff --git a/tests/test_guitar_hero_v5.py b/tests/test_guitar_hero_v5.py new file mode 100644 index 0000000..0228701 --- /dev/null +++ b/tests/test_guitar_hero_v5.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import cv2 +import numpy as np + +from ps2_autopilot.controllers.base import Controller +from ps2_autopilot.profiles.base import ProfileContext +from ps2_autopilot.profiles.guitar_hero_v5 import GuitarHeroV5Profile + + +class FakeController(Controller): + def __init__(self) -> None: + self.events: list[tuple] = [] + + def tap(self, action: str, duration: float = 0.08) -> None: + self.events.append(("tap", action, duration)) + + def hold(self, action: str) -> None: + self.events.append(("hold", action)) + + def release(self, action: str) -> None: + self.events.append(("release", action)) + + def release_all(self) -> None: + self.events.append(("release_all",)) + + def set_left_stick(self, x: float, y: float) -> None: + self.events.append(("left_stick", x, y)) + + def set_right_stick(self, x: float, y: float) -> None: + self.events.append(("right_stick", x, y)) + + +def _ctx(frame: np.ndarray, now: float) -> ProfileContext: + return ProfileContext(frame=frame, motion=0.0, template=None, now=now) + + +def _controller_instruction_card() -> np.ndarray: + frame = np.zeros((480, 640, 3), dtype=np.uint8) + cv2.rectangle(frame, (80, 60), (560, 440), (125, 180, 110), -1) + cv2.rectangle(frame, (90, 40), (550, 170), (220, 235, 220), -1) + for y in (70, 95, 120): + cv2.rectangle(frame, (130, y), (500, y + 12), (20, 20, 20), -1) + cv2.rectangle(frame, (20, 410), (210, 470), (30, 145, 35), -1) + cv2.rectangle(frame, (55, 430), (180, 445), (225, 225, 225), -1) + colors = ((0, 255, 0), (0, 0, 255), (0, 255, 255), (255, 0, 0), (0, 128, 255)) + for index, color in enumerate(colors): + cv2.circle(frame, (355 + index * 42, 385), 12, color, 4) + return frame + + +def test_tutorial_uses_cross_confirm_before_l2_fallback(): + profile = GuitarHeroV5Profile( + { + "controller_tutorial_threshold": 0.70, + "controller_tutorial_settle_seconds": 0.0, + "controller_tutorial_retry_seconds": 2.0, + } + ) + controller = FakeController() + frame = _controller_instruction_card() + + first = profile.tick(controller, _ctx(frame, 10.0)) + second = profile.tick(controller, _ctx(frame, 12.1)) + third = profile.tick(controller, _ctx(frame, 14.2)) + + taps = [event for event in controller.events if event[0] == "tap"] + assert [event[1] for event in taps] == ["confirm", "l2", "confirm"] + assert taps[0] == ("tap", "confirm", 0.08) + assert "confirm/Cross" in first + assert "green/L2 fallback" in second + assert "confirm/Cross" in third + assert profile.controller_tutorial_inputs == 3 + + +def test_tutorial_custom_action_sequence_is_validated_and_bounded(): + profile = GuitarHeroV5Profile( + { + "controller_tutorial_continue_actions": ["cross", "l2"], + "controller_tutorial_max_attempts": 9, + "controller_tutorial_threshold": 0.70, + "controller_tutorial_settle_seconds": 0.0, + "controller_tutorial_retry_seconds": 1.0, + } + ) + controller = FakeController() + frame = _controller_instruction_card() + + profile.tick(controller, _ctx(frame, 20.0)) + profile.tick(controller, _ctx(frame, 21.1)) + final = profile.tick(controller, _ctx(frame, 22.2)) + + taps = [event[1] for event in controller.events if event[0] == "tap"] + assert taps == ["cross", "l2"] + assert profile.controller_tutorial_max_attempts == 2 + assert "budget exhausted" in final + + +def test_tutorial_telemetry_reports_actual_last_action(): + profile = GuitarHeroV5Profile( + { + "controller_tutorial_threshold": 0.70, + "controller_tutorial_settle_seconds": 0.0, + } + ) + controller = FakeController() + frame = _controller_instruction_card() + ctx = _ctx(frame, 30.0) + + profile.tick(controller, ctx) + state = profile.telemetry(ctx) + + assert state["gh_policy_version"] == 5 + assert state["gh_controller_tutorial_last_action"] == "confirm" + assert state["gh_controller_tutorial_continue_actions"][0] == "confirm" diff --git a/tests/test_profile_registry.py b/tests/test_profile_registry.py index 04835c1..81c07f5 100644 --- a/tests/test_profile_registry.py +++ b/tests/test_profile_registry.py @@ -4,6 +4,7 @@ from ps2_autopilot.profiles.guitar_hero_v2 import GuitarHeroV2Profile from ps2_autopilot.profiles.guitar_hero_v3 import GuitarHeroV3Profile from ps2_autopilot.profiles.guitar_hero_v4 import GuitarHeroV4Profile +from ps2_autopilot.profiles.guitar_hero_v5 import GuitarHeroV5Profile from ps2_autopilot.profiles.jak_and_daxter_v22_hardened import JakAndDaxterV22Profile from ps2_autopilot.profiles.madden2005_v32 import Madden2005V32Profile from ps2_autopilot.profiles.registry import ( @@ -21,12 +22,13 @@ def test_registry_selects_exact_active_madden_v32_factory(): assert get_profile_spec("madden2005").maturity == "production-candidate" -def test_registry_selects_guitar_hero_v4_and_aliases(): +def test_registry_selects_guitar_hero_v5_and_aliases(): profile = build_profile({"name": "gh1", "difficulty": "easy"}) - assert type(profile) is GuitarHeroV4Profile - assert GuitarHeroProfile is GuitarHeroV4Profile - assert GuitarHeroV2Profile is not GuitarHeroV4Profile - assert GuitarHeroV3Profile is not GuitarHeroV4Profile + assert type(profile) is GuitarHeroV5Profile + assert GuitarHeroProfile is GuitarHeroV5Profile + assert GuitarHeroV2Profile is not GuitarHeroV5Profile + assert GuitarHeroV3Profile is not GuitarHeroV5Profile + assert GuitarHeroV4Profile is not GuitarHeroV5Profile assert get_profile_spec("guitarhero").name == "guitar_hero" assert get_profile_spec("gh").maturity == "diagnostic" assert canonical_profile_name("guitar-hero-1") == "guitar_hero"