diff --git a/config/guitar_hero.yaml b/config/guitar_hero.yaml index 1e65130..937e7e1 100644 --- a/config/guitar_hero.yaml +++ b/config/guitar_hero.yaml @@ -62,14 +62,15 @@ profile: # cannot silently override `difficulty: easy`. difficulty_home_presses: 3 - # Failed songs return to New Song during calibration. V10 reads the actual yellow - # RETRY/NEW SONG/QUIT selection and moves it directly instead of relying on generic - # frame-signature progress, which could strand an unattended stream on this card. + # Failed songs return to New Song during calibration. V11 preserves explicit setlist + # ownership through the animated failure->setlist transition so V4's broad title + # detector cannot reset the route to BOOT and strand the stream on Start probes. failed_song_action: new_song failed_card_threshold: 0.72 failed_menu_settle_seconds: 0.18 failed_confirm_retry_seconds: 1.0 failed_selection_tolerance: 0.052 + failure_transition_seconds: 5.0 # Menu/presentation arbitration. Unknown moving screens are always preserved as # presentation; static loading after a launch is also input-silent. @@ -86,26 +87,33 @@ profile: post_song_settle_seconds: 1.25 post_song_confirm_limit: 4 - # Joint receptor geometry from V8 remains the spatial foundation. V10 adds a - # temporal layer: a colored component must demonstrably approach the strike line - # over multiple frames before it may fire. Observed approach velocity supplies a - # bounded sub-frame lead to compensate capture/controller latency. - vision_width: 640 - vision_height: 480 + # The retained V9 soak was nowhere near its 16.67 ms loop budget: capture was ~30 ms + # and policy ~47 ms. V11 therefore reduces the normalized vision raster and candidate + # fan-out, then uses local receptor support instead of a 5-lane Cartesian search once + # the highway is locked. Full geometry is reacquired immediately if support weakens. + vision_width: 480 + vision_height: 360 gameplay_threshold: 0.64 - receptor_candidate_limit: 6 + receptor_candidate_limit: 4 receptor_joint_min_span: 0.18 receptor_joint_max_span: 0.58 receptor_joint_max_y_spread: 0.055 receptor_lock_threshold: 0.80 receptor_lock_frames: 3 receptor_lock_match_radius: 0.045 + fast_lock_min_ratio: 0.035 + fast_lock_half_width: 0.022 + fast_lock_half_height: 0.026 note_lookahead: 0.18 note_trigger_gap: 0.030 note_receptor_exclusion: 0.024 note_lane_half_width: 0.032 - note_min_area: 7 - note_max_area: 1700 + note_min_area: 5 + note_max_area: 1100 + + # V10's temporal validation remains in place: a candidate must move toward the + # strike line before it may fire, and measured per-frame approach supplies a bounded + # latency lead. V11 skips menu morphology while launch/gameplay owns the frame. approach_history_frames: 4 approach_min_delta: 0.0015 approach_backtrack_tolerance: 0.006 @@ -114,8 +122,7 @@ profile: timing_trigger_max: 0.055 # Easy only uses green/red/yellow; the active profile suppresses impossible lanes. - # A slightly longer non-blocking hold gives PCSX2 two or three 60 Hz frames to - # register each valid fret without turning taps into sustains. + # Keep taps modest while we establish a stable perception/timing baseline. hit_threshold: 0.50 hit_reset_threshold: 0.10 tap_hold_seconds: 0.040 diff --git a/src/ps2_autopilot/guitar_hero_vision_v11.py b/src/ps2_autopilot/guitar_hero_vision_v11.py new file mode 100644 index 0000000..51622ed --- /dev/null +++ b/src/ps2_autopilot/guitar_hero_vision_v11.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import numpy as np + +from .guitar_hero_vision import LANE_NAMES +from .guitar_hero_vision_v10 import GuitarHeroVisionV10 + + +class GuitarHeroVisionV11(GuitarHeroVisionV10): + """V11 performance layer for the Guitar Hero highway tracker. + + The V9 live bundle showed the runtime spending roughly 45-50 ms in policy code + while targeting a 16.7 ms loop. V8/V10 recompute a combinatorial five-lane + receptor layout every frame even after a stable highway has already been locked, + and they continue running menu morphology while actively playing. + + V11 keeps the same geometry and temporal note semantics but adds two safe fast + paths: + + * once a receptor layout is locked, validate it with tiny local color windows at + the known receptor centers instead of re-running the full candidate product; + * allow the active profile to suppress menu scoring during song launch/gameplay. + + Any weak local support falls straight back to V8's full reacquisition path, so the + optimization cannot keep stale highway ownership across a results/menu screen. + """ + + def __init__(self, cfg: dict) -> None: + super().__init__(cfg) + self.fast_lock_min_ratio = float(cfg.get("fast_lock_min_ratio", 0.035)) + self.fast_lock_half_width = float(cfg.get("fast_lock_half_width", 0.022)) + self.fast_lock_half_height = float(cfg.get("fast_lock_half_height", 0.026)) + self.skip_menu_scores = False + self.fast_layout_frames = 0 + self.full_layout_frames = 0 + self.menu_score_frames_skipped = 0 + self._fast_layout_used = False + + def _locked_pixel_support(self, masks: dict[str, np.ndarray]) -> int: + centers = self._locked_centers + if centers is None: + return 0 + support = 0 + for lane_name, center in zip(LANE_NAMES, centers, strict=True): + mask = masks[lane_name] + h, w = mask.shape + cx = int(center[0] * w) + cy = int(center[1] * h) + xhalf = max(4, int(w * self.fast_lock_half_width)) + yhalf = max(4, int(h * self.fast_lock_half_height)) + x0 = max(0, cx - xhalf) + x1 = min(w, cx + xhalf + 1) + y0 = max(0, cy - yhalf) + y1 = min(h, cy + yhalf + 1) + roi = mask[y0:y1, x0:x1] + if roi.size == 0: + continue + ratio = float(np.count_nonzero(roi) / roi.size) + if ratio >= self.fast_lock_min_ratio: + support += 1 + return support + + def _joint_layout(self, masks): + if self._locked_centers is not None: + support = self._locked_pixel_support(masks) + if support >= 3: + self._fast_layout_used = True + self.layout_locked = True + self.lock_support = support + self.fast_layout_frames += 1 + confidence = min(1.0, 0.64 + support * 0.07) + return self._locked_centers, confidence + + self._fast_layout_used = False + self.full_layout_frames += 1 + return super()._joint_layout(masks) + + def _lock_or_current(self, masks, centers, confidence): + if self._fast_layout_used and self._locked_centers is not None: + self.layout_locked = True + return self._locked_centers, confidence + return super()._lock_or_current(masks, centers, confidence) + + def _menu_scores(self, frame): + if self.skip_menu_scores: + self.menu_score_frames_skipped += 1 + return 0.0, 0.0, 0.0, 0.0, 0.0, None, None + return super()._menu_scores(frame) diff --git a/src/ps2_autopilot/profiles/__init__.py b/src/ps2_autopilot/profiles/__init__.py index 26e81d7..5a43f89 100644 --- a/src/ps2_autopilot/profiles/__init__.py +++ b/src/ps2_autopilot/profiles/__init__.py @@ -8,6 +8,7 @@ from .guitar_hero_v8 import GuitarHeroV8Profile from .guitar_hero_v9 import GuitarHeroV9Profile from .guitar_hero_v10 import GuitarHeroV10Profile +from .guitar_hero_v11 import GuitarHeroV11Profile from .jak_and_daxter import JakAndDaxterProfile as JakAndDaxterV1Profile, JakPhase from .jak_and_daxter_v2 import JakAndDaxterV2Profile from .jak_and_daxter_v3 import JakAndDaxterV3Profile @@ -75,7 +76,7 @@ from .nfs_hot_pursuit_2_v13 import NfsHotPursuit2V13Profile from .nfs_hot_pursuit_2_v14 import NfsHotPursuit2V14Profile -GuitarHeroProfile = GuitarHeroV10Profile +GuitarHeroProfile = GuitarHeroV11Profile JakAndDaxterProfile = JakAndDaxterV22Profile Madden2005Profile = Madden2005V32Profile NfsHotPursuit2Profile = NfsHotPursuit2V14Profile @@ -100,6 +101,7 @@ "GuitarHeroV8Profile", "GuitarHeroV9Profile", "GuitarHeroV10Profile", + "GuitarHeroV11Profile", "GuitarHeroPhase", "GuitarHeroScreen", "JakAndDaxterProfile", diff --git a/src/ps2_autopilot/profiles/guitar_hero_v11.py b/src/ps2_autopilot/profiles/guitar_hero_v11.py new file mode 100644 index 0000000..e4d5a88 --- /dev/null +++ b/src/ps2_autopilot/profiles/guitar_hero_v11.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from ps2_autopilot.guitar_hero_vision_v11 import GuitarHeroVisionV11 + +from .base import ProfileContext +from .guitar_hero_types import GuitarHeroPhase, GuitarHeroScreen +from .guitar_hero_v10 import GuitarHeroV10Profile + + +class GuitarHeroV11Profile(GuitarHeroV10Profile): + """V11: protect failure->setlist routing and cut gameplay vision latency. + + The uploaded V9 soak showed the failure transaction itself firing Down+Confirm, + immediately followed by a false TITLE classification that reset the route to BOOT. + It then spent the rest of the run waiting/probing Start. The same bundle also + showed a critically over-budget loop, so V11 pairs lifecycle hardening with a + locked-highway fast path rather than adding another note threshold. + """ + + policy_version = 11 + + def __init__(self, cfg: dict) -> None: + super().__init__(cfg) + self.vision = GuitarHeroVisionV11(self.cfg) + self.failure_transition_seconds = float( + self.cfg.get("failure_transition_seconds", 5.0) + ) + self._failure_transition_until = -1e9 + self.title_guard_suppressions = 0 + self.failure_transition_wait_ticks = 0 + + def _explicit_title_template(self, ctx: ProfileContext) -> bool: + if ctx.template is None or ctx.template.score < self.template_threshold: + return False + return self._template_screen(ctx.template.name) is GuitarHeroScreen.TITLE + + def _classify(self, ctx: ProfileContext, obs): + # V4's image-only title splash detector intentionally has high priority during + # boot, but the live failure soak proved that it can collide with the animated + # setlist/presentation reached after NEW SONG. Once we own a non-boot route, + # only an explicit title template may reset the whole state machine to BOOT. + protected_route = self.route_stage in { + "setlist", + "difficulty", + "song", + "gameplay", + "post_song", + } + if protected_route and self.phase is not GuitarHeroPhase.BOOT and not self._explicit_title_template(ctx): + old_threshold = self.title_splash_threshold + self.title_splash_threshold = 1.01 + try: + screen = super()._classify(ctx, obs) + finally: + self.title_splash_threshold = old_threshold + if self._title_splash_score_value >= old_threshold: + self.title_guard_suppressions += 1 + return screen + return super()._classify(ctx, obs) + + def _tick_failed_card(self, controller, ctx: ProfileContext, obs) -> str: + action = super()._tick_failed_card(controller, ctx, obs) + if action == "song failed: confirm new_song": + # Establish destination ownership immediately. Do not wait for the next + # screen classifier to infer route state from an animated transition. + self.phase = GuitarHeroPhase.MENU + self.route_stage = "setlist" + self._failure_transition_until = ctx.now + self.failure_transition_seconds + return action + + def _tick_non_gameplay(self, controller, ctx: ProfileContext, obs) -> str: + if ctx.now < self._failure_transition_until and not self._failed_card_active: + if self.screen is GuitarHeroScreen.SETLIST: + self._failure_transition_until = -1e9 + elif self.screen in { + GuitarHeroScreen.TITLE, + GuitarHeroScreen.UNKNOWN, + GuitarHeroScreen.PRESENTATION, + }: + self._release_gameplay_inputs(controller) + self.phase = GuitarHeroPhase.MENU + self.route_stage = "setlist" + self.failure_transition_wait_ticks += 1 + return "wait for failed->setlist transition" + return super()._tick_non_gameplay(controller, ctx, obs) + + def tick(self, controller, ctx: ProfileContext) -> str: + # Menu morphology is useful while routing, but it is wasted work once a song + # is launching/playing. On the first frame where gameplay disappears the + # runtime leaves PLAYING; the following frame automatically re-enables full + # menu scoring for results/failure reacquisition. + self.vision.skip_menu_scores = self.phase in { + GuitarHeroPhase.AWAIT_GAMEPLAY, + GuitarHeroPhase.PLAYING, + } + return super().tick(controller, ctx) + + def telemetry(self, ctx: ProfileContext) -> dict: + state = super().telemetry(ctx) + vision = self.vision + state.update( + { + "gh_policy_version": self.policy_version, + "gh_failure_transition_active": ctx.now < self._failure_transition_until, + "gh_failure_transition_wait_ticks": self.failure_transition_wait_ticks, + "gh_title_guard_suppressions": self.title_guard_suppressions, + "gh_fast_layout_frames": vision.fast_layout_frames, + "gh_full_layout_frames": vision.full_layout_frames, + "gh_menu_score_frames_skipped": vision.menu_score_frames_skipped, + } + ) + return state diff --git a/src/ps2_autopilot/profiles/registry.py b/src/ps2_autopilot/profiles/registry.py index 982fc2b..367fd17 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_v10 import GuitarHeroV10Profile +from .guitar_hero_v11 import GuitarHeroV11Profile 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 GuitarHeroV10Profile(dict(cfg)) + return GuitarHeroV11Profile(dict(cfg)) def _madden_factory(cfg: dict) -> GameProfile: @@ -52,10 +52,10 @@ def _nfs_hp2_factory(cfg: dict) -> GameProfile: name="guitar_hero", display_name="Guitar Hero (2005)", template_namespace="guitar_hero", - # V10 keeps V9's deterministic Easy route and failure ownership, replaces - # failure-card signature transactions with selected-row control, and requires - # note components to demonstrate real approach motion before a fret can fire. - # Keep diagnostic until the temporal timing model survives a complete song. + # V11 keeps V10's temporal note filter, protects the failed->setlist route + # from image-only title false positives, and adds a locked-highway fast path + # plus gameplay menu-score suppression to reduce the critically over-budget + # vision loop seen in the retained V9 soak. maturity="diagnostic", factory=_guitar_hero_factory, ), diff --git a/tests/test_guitar_hero_v11.py b/tests/test_guitar_hero_v11.py new file mode 100644 index 0000000..6d9f6d9 --- /dev/null +++ b/tests/test_guitar_hero_v11.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import cv2 +import numpy as np + +from ps2_autopilot.controllers.base import Controller +from ps2_autopilot.guitar_hero_vision_v11 import GuitarHeroVisionV11 +from ps2_autopilot.profiles.base import ProfileContext +from ps2_autopilot.profiles.guitar_hero_types import GuitarHeroPhase, GuitarHeroScreen +from ps2_autopilot.profiles.guitar_hero_v11 import GuitarHeroV11Profile + + +COLORS = ( + (0, 255, 0), + (0, 0, 255), + (0, 255, 255), + (255, 0, 0), + (0, 128, 255), +) +RECEPTOR_X = (200, 260, 320, 380, 440) +RECEPTOR_Y = 400 + + +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, motion: float = 0.0) -> ProfileContext: + return ProfileContext(frame=frame, motion=motion, template=None, now=now) + + +def _cfg(**overrides) -> dict: + cfg = { + "difficulty": "easy", + "menu_stable_seconds": 0.0, + "failed_menu_settle_seconds": 0.0, + "failed_confirm_retry_seconds": 0.5, + "failure_transition_seconds": 5.0, + "receptor_x_min": 0.20, + "receptor_x_max": 0.85, + "receptor_lock_threshold": 0.78, + "receptor_lock_frames": 3, + "receptor_candidate_limit": 4, + "note_trigger_gap": 0.030, + "note_receptor_exclusion": 0.024, + "note_lane_half_width": 0.032, + "hit_threshold": 0.50, + "hit_reset_threshold": 0.10, + } + cfg.update(overrides) + return cfg + + +def _highway() -> np.ndarray: + frame = np.zeros((480, 640, 3), dtype=np.uint8) + frame[:] = (28, 24, 22) + for x, color in zip(RECEPTOR_X, COLORS, strict=True): + cv2.circle(frame, (x, RECEPTOR_Y), 11, color, -1) + cv2.circle(frame, (x, RECEPTOR_Y), 5, (35, 35, 35), -1) + return frame + + +def _failed_card(selected: int = 1) -> np.ndarray: + frame = np.zeros((480, 640, 3), dtype=np.uint8) + frame[:] = (38, 22, 26) + cv2.rectangle(frame, (120, 55), (520, 400), (22, 28, 31), -1) + for y, x0, x1 in ( + (95, 185, 455), + (130, 170, 470), + (165, 225, 415), + (205, 225, 415), + (245, 225, 415), + ): + cv2.rectangle(frame, (x0, y), (x1, y + 22), (238, 238, 238), -1) + rows = ((282, 210, 440), (326, 220, 430), (360, 220, 430)) + for index, (y, x0, x1) in enumerate(rows): + color = (0, 235, 245) if index == selected else (238, 238, 238) + cv2.rectangle(frame, (x0, y), (x1, y + 24), color, -1) + cv2.rectangle(frame, (70, 430), (185, 478), (30, 145, 35), -1) + cv2.rectangle(frame, (390, 430), (520, 478), (145, 145, 145), -1) + return frame + + +def _title_like_transition() -> np.ndarray: + frame = np.zeros((480, 640, 3), dtype=np.uint8) + frame[:] = (30, 25, 35) + # Deliberately satisfy V4's broad image-only title topology. The old policy would + # reset to BOOT here; V11 may instead conservatively wait or legitimately claim a + # downstream route, but it must never treat this image-only collision as TITLE. + cv2.rectangle(frame, (150, 80), (490, 320), (210, 210, 210), -1) + cv2.rectangle(frame, (180, 355), (460, 390), (225, 225, 225), -1) + return frame + + +def test_locked_highway_uses_fast_local_support_after_initial_geometry_lock(): + vision = GuitarHeroVisionV11(_cfg()) + for _ in range(3): + vision.analyze(_highway()) + assert vision.layout_locked is True + full_before = vision.full_layout_frames + + obs = vision.analyze(_highway()) + + assert obs.gameplay_confidence >= 0.64 + assert vision.fast_layout_frames >= 1 + assert vision.full_layout_frames == full_before + assert vision.lock_support >= 3 + + +def test_gameplay_mode_can_skip_unneeded_menu_morphology(): + vision = GuitarHeroVisionV11(_cfg()) + vision.skip_menu_scores = True + obs = vision.analyze(_highway()) + + assert vision.menu_score_frames_skipped == 1 + assert obs.main_menu_score == 0.0 + assert obs.setlist_score == 0.0 + assert obs.difficulty_score == 0.0 + + +def test_failed_new_song_transition_cannot_be_reset_to_boot_by_title_false_positive(): + profile = GuitarHeroV11Profile(_cfg(failed_song_action="new_song")) + controller = FakeController() + + first = profile.tick(controller, _ctx(_failed_card(1), 10.0)) + assert first == "song failed: confirm new_song" + assert profile.route_stage == "setlist" + assert profile.phase is GuitarHeroPhase.MENU + + transition = _title_like_transition() + assert profile._title_splash_score(transition) >= profile.title_splash_threshold + profile.tick(controller, _ctx(transition, 10.2, motion=0.0)) + + # The retained bug was specifically an image-only TITLE claim resetting the whole + # lifecycle to BOOT. A frame with strong setlist evidence is allowed to advance to + # difficulty, but the route must stay downstream and must never emit Start. + assert profile.route_stage in {"setlist", "difficulty"} + assert profile.phase is GuitarHeroPhase.MENU + assert profile.screen is not GuitarHeroScreen.TITLE + taps = [event[1] for event in controller.events if event[0] == "tap"] + assert "start" not in taps + + +def test_v11_telemetry_exposes_route_guard_and_fast_path_counters(): + profile = GuitarHeroV11Profile(_cfg()) + controller = FakeController() + ctx = _ctx(_highway(), 1.0, motion=0.02) + profile.tick(controller, ctx) + state = profile.telemetry(ctx) + + assert state["gh_policy_version"] == 11 + assert "gh_fast_layout_frames" in state + assert "gh_full_layout_frames" in state + assert "gh_menu_score_frames_skipped" in state + assert "gh_title_guard_suppressions" in state diff --git a/tests/test_profile_registry.py b/tests/test_profile_registry.py index 4243d85..7f91cc9 100644 --- a/tests/test_profile_registry.py +++ b/tests/test_profile_registry.py @@ -10,6 +10,7 @@ from ps2_autopilot.profiles.guitar_hero_v8 import GuitarHeroV8Profile from ps2_autopilot.profiles.guitar_hero_v9 import GuitarHeroV9Profile from ps2_autopilot.profiles.guitar_hero_v10 import GuitarHeroV10Profile +from ps2_autopilot.profiles.guitar_hero_v11 import GuitarHeroV11Profile 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 ( @@ -27,18 +28,19 @@ def test_registry_selects_exact_active_madden_v32_factory(): assert get_profile_spec("madden2005").maturity == "production-candidate" -def test_registry_selects_guitar_hero_v10_and_aliases(): +def test_registry_selects_guitar_hero_v11_and_aliases(): profile = build_profile({"name": "gh1", "difficulty": "easy"}) - assert type(profile) is GuitarHeroV10Profile - assert GuitarHeroProfile is GuitarHeroV10Profile - assert GuitarHeroV2Profile is not GuitarHeroV10Profile - assert GuitarHeroV3Profile is not GuitarHeroV10Profile - assert GuitarHeroV4Profile is not GuitarHeroV10Profile - assert GuitarHeroV5Profile is not GuitarHeroV10Profile - assert GuitarHeroV6Profile is not GuitarHeroV10Profile - assert GuitarHeroV7Profile is not GuitarHeroV10Profile - assert GuitarHeroV8Profile is not GuitarHeroV10Profile - assert GuitarHeroV9Profile is not GuitarHeroV10Profile + assert type(profile) is GuitarHeroV11Profile + assert GuitarHeroProfile is GuitarHeroV11Profile + assert GuitarHeroV2Profile is not GuitarHeroV11Profile + assert GuitarHeroV3Profile is not GuitarHeroV11Profile + assert GuitarHeroV4Profile is not GuitarHeroV11Profile + assert GuitarHeroV5Profile is not GuitarHeroV11Profile + assert GuitarHeroV6Profile is not GuitarHeroV11Profile + assert GuitarHeroV7Profile is not GuitarHeroV11Profile + assert GuitarHeroV8Profile is not GuitarHeroV11Profile + assert GuitarHeroV9Profile is not GuitarHeroV11Profile + assert GuitarHeroV10Profile is not GuitarHeroV11Profile assert get_profile_spec("guitarhero").name == "guitar_hero" assert get_profile_spec("gh").maturity == "diagnostic" assert canonical_profile_name("guitar-hero-1") == "guitar_hero"