Skip to content
35 changes: 21 additions & 14 deletions config/guitar_hero.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
88 changes: 88 additions & 0 deletions src/ps2_autopilot/guitar_hero_vision_v11.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion src/ps2_autopilot/profiles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -100,6 +101,7 @@
"GuitarHeroV8Profile",
"GuitarHeroV9Profile",
"GuitarHeroV10Profile",
"GuitarHeroV11Profile",
"GuitarHeroPhase",
"GuitarHeroScreen",
"JakAndDaxterProfile",
Expand Down
112 changes: 112 additions & 0 deletions src/ps2_autopilot/profiles/guitar_hero_v11.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +49 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Suppress both image-only title detectors

When a protected route is in PRESENTATION—for example, if the failed-to-setlist animation outlasts the five-second guard—the inherited V2 classifier can still return TITLE from obs.title_score. Raising only title_splash_threshold disables V4's detector but not this second image-only path, after which V4 resets the route to BOOT and recreates the unattended-stream failure this change is intended to fix. Reject a returned image-only TITLE here unless _explicit_title_template(ctx) is true.

Useful? React with 👍 / 👎.

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
12 changes: 6 additions & 6 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_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
Expand All @@ -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:
Expand All @@ -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,
),
Expand Down
Loading