Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/GUITAR_HERO_V5_LIVE_FIX.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion src/ps2_autopilot/profiles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -88,6 +89,7 @@
"GuitarHeroV2Profile",
"GuitarHeroV3Profile",
"GuitarHeroV4Profile",
"GuitarHeroV5Profile",
"GuitarHeroPhase",
"GuitarHeroScreen",
"JakAndDaxterProfile",
Expand Down
119 changes: 119 additions & 0 deletions src/ps2_autopilot/profiles/guitar_hero_v5.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions src/ps2_autopilot/profiles/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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,
Expand Down
115 changes: 115 additions & 0 deletions tests/test_guitar_hero_v5.py
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 7 additions & 5 deletions tests/test_profile_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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"
Expand Down