0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

teensyでSFCスーパースコープの信号を模倣する

0
Last updated at Posted at 2026-02-22

スーパースコープは仕組み上、ブラウン管でないと動作しないが、SFCの現在の描画位置を取得できれば模倣できる。
SFCのAV出力端子にはRBG用のH/V-sync端子があり、ここから描画位置が分かる。
このあたりを実装してやれば、液晶画面でもスーパースコープで遊ぶことができるようになる。Switchにはポインターデバイスが無く、Nintendo Classicsにスーパースコープ対応ソフトが出る望みは薄いので、かなりレアな体験ができる。

前提知識 - スーパースコープの仕組み

スーパースコープのカーソル位置検出は、当時主流であったブラウン管の走査特性(※)を利用したものである。ブラウン管の描画は画面左上から右下にかけて改行しながら順番に行われるため、スコープの向けている点の描画時間を取得すれば、画面描画を始めた時間を用いて位置が算出できる。

(※電子が照射されてから次の周期までには消えているという性質も)

・スーパースコープ本体:
受信機と赤外線通信し、受光部が検知した&各ボタンが押された瞬間、受信機にそれを伝える。
・受信機:
スコープの赤外線信号を受け取り、
受光部検知は即時に専用ピンに、
ボタン入力はSFCのlatch要求に従ってSerialに送る。
・SFC本体:
受信機から専用ピンの入力があった時間を記録し、カーソルの位置を算出する。
また、受信機にlatch要求,clockを送り、ボタン入力を受け取る。

H/Vsyncから現在位置の推定

先述のように、SFCのマルチAVコネクタにはコンポジット,RGB,RCA用の端子が備わっており、そのうちコンポジット用H/V端子からはページ送りと描画位置改行タイミングが出力されている。V-syncがフレーム送り、H-syncが改行。ピンアウトは上図。

↑マルチアウトケーブルの外装を剥した様子。使われていない端子には金具もないので注意。ニコイチすればいい。

V-syncのパルスはH-syncのパルスよりも長いため、信号線の下りにトリガーをかけて時間を見ることでそれぞれ解釈できる。
水平方向については一つ前のH-syncからの経過時間で推測する。
映像信号処理なんて普通はマイコンでやるようなことでもないが、これくらいならある程度のクロックを持ったマイコンで対応できる。他の処理が挟まらないよう気を遣う必要はあるが...。

SFCscope.ino
void sync_isr() {
    uint32_t now = ARM_DWT_CYCCNT;
    
    if (digitalReadFast(SYNC_PIN) == LOW) {
        h_sync_start_tick = now;
    } else {
        uint32_t low_duration = now - h_sync_start_tick;
        
        if (low_duration > 12000) {
            static uint32_t last_v_sync = 0;
            if (now - last_v_sync > 3000000) { 
              v_pos = 0;
              frame_count++;
              last_v_sync = now;
                
            }
        } else {
            v_pos++;
            
        }
    }
}

void setup(){
  //...hogehoge
  attachInterrupt(digitalPinToInterrupt(SYNC_PIN), sync_isr, CHANGE);
  //hogehoge...
}

H-syncは1回でなく3回下がるので、適当な不感時間を設けておく。もし自前で実装して60x3 FPSなど出ていればそのせい。

↑信号の様子

スーパースコープの通信内容

コントローラー端子そのものの仕様や一般の通信仕様については記事下部の資料や、以下記事を参照(ループ)。

ポインター

スーパースコープでは、通常使用しない6番ピンをポインター位置取得のために用いる。
6番ピンは下り入力時点での描画位置を記録する機能がSFC内部でハード実装されている。ほとんどスーパースコープ専用機能。
やることは、画面描画開始時間からポインター座標分の遅延ののち、6番ピンに入力するだけ。

SFCscope.ino
void loop(){
  uint32_t current_tick = ARM_DWT_CYCCNT;
  uint32_t elapsed = current_tick - h_sync_start_tick;
  
  int x = (int)((elapsed - H_OFFSET) / CYCLES_PER_DOT);
  int y = v_pos;
  
  if (x >= 0 && x < 300 && y >= 0 && y < 260) {
      if (x >= px-1 && x <= px+1 && y == py){  
        pinMode(SIX_PIN,OUTPUT);
        digitalWriteFast(SIX_PIN,LOW);
        asm volatile("nop"); asm volatile("nop");
        pinMode(SIX_PIN,INPUT);
      }
      
  }
}

px,pyが目標位置。適当な範囲で制限しているが、特に必要ではない。本来の画面サイズは256x224らしい。
asm volatile("nop");はteensyの1クロック分だけなにもしないということらしい。

成功すると、このような感じ。
橙がSFCのコントローラー通信クロック(≒フレーム開始)、青が生成した6番ピンの信号。

(※もっと精緻な話、teensyのクロックでも {x方向の1カウント行進、すなわち1dot分の描画時間} < {1loopがやってくる時間} となる。コード内でxに+-1の幅を付けているのは逃したくないからで、実際理論上3回出るはずのパルスも1回しか出ていない(0回のフレームもあるのだろう)。loopを回してxの到達を待つよりも、その行でdelayをかけた方が正確に出力できる...。(...正味誤差。パルスを送りそびれるのは避けたいが。))

その他のボタン

スーパースコープにはFIRE,CIRSOR,PAUSE,TURBOのボタンがある。
これらのボタン状態は通常コントローラーと同じような形式で送られる。
具体的には以下。

クロックサイクル 中身
1 TRIGGER
2 CURSOR
3 TURBO
4 PAUSE
5 不使用、LOW
6 不使用、LOW
7 NULL →正常HIGH
8 NOISE →正常HIGH
9-12 不使用、全てLOW
13-16 識別子、スーパースコープでは全てLOW

1~4はスーパースコープ本体にあるボタン。押されている時にLOW。
(TRIGGERが主用ボタン、タイトル画面の移動に必須。CURSORはゲーム中キャリブレーションができる。TURBOは連射モードの切り替え?PAUSEはポーズ。)

7、NULLは画面内に照準が向けられていない時にLOW、照準が画面内のときにHIGH。
8、NOISEはスーパースコープ本体と受信機間の赤外線通信が失敗したと判定された際にLOW。
SFCは、これら異常フラグが建った状態では、1-4の入力を無視してしまう。つまり正常時は両者HIGHであればいい。

13-16の識別子について、「スーパースコープ6」ではチェックを行わないようで、全てHIGH(=通常コントローラーと同じ)でも動作した。
しかし、「ヨッシーのロードハンティング」,「ZONE X」ではこの識別子が正しくないと無視してしまう。...不思議だが少なくとも私はそうなった。
余談、「星のカービィスーパーデラックス」,「マリオRPG」など一部タイトルでは通常コントローラー以外が接続された時のアラート画面が出る。しかし自分で検証した結果、マリオRPGではマウス,スーパースコープどちらも特別な画面表示はなかった。マルチタップに反応するという情報はあり、それだけなのかもしれない。

サンプルコード

コード全体。PC-teensy間でシリアル通信し、PCマウスをスコープのポインターに対応させる。ボタンは適当。

teensy側コード
sfcScope.ino
#include <Arduino.h>

// ピン設定
const int PIN_LATCH = 3;
const int PIN_CLOCK = 2;
const int PIN_DATA  = 4;
const int PIN_SYNC = 5;
const int PIN_SIX = 6;
const int PIN_DEBUG = 12;


#define BTN_FIRE   15
#define BTN_CURSOR 14
#define BTN_PAUSE  12
#define BTN_TURBO  13


volatile uint16_t buttonStates = 0xFFFF;
volatile int bitCounter = 0;

void latchISR() {
  bitCounter = 0;
  //1サイクル目だけlatch時点で出力しておく
  digitalWriteFast(PIN_DATA, (buttonStates & (1 << bitCounter)) ? HIGH : LOW);
  bitCounter++;
}

void clockISR() {
  if (bitCounter < 16) {//クロック上りごとにサイクルを回して出力
    digitalWriteFast(PIN_DATA, (buttonStates & (1 << bitCounter)) ? HIGH : LOW);
    bitCounter++;
  } else {
    digitalWriteFast(PIN_DATA, HIGH);
  }
}


// 定数(600MHz動作時を想定)
const uint32_t CYCLES_PER_DOT = 112;      // 186.2ns / (1/600MHz)
const uint32_t V_SYNC_THRESHOLD = 12000;   // 20us以上LowならV-Sync
const uint32_t H_OFFSET = 6240;           // H-Sync開始から描画開始(X=0)までのサイクル数 正直わからない

volatile uint32_t v_pos = 0;              // 現在の垂直ライン (Y)
volatile uint32_t h_sync_start_tick = 0;  // H-Syncが始まった瞬間のサイクル数

volatile uint32_t frame_count = 0;//測りたければ(未使用)

// Sync変化で割り込み
void sync_isr() {
    uint32_t now = ARM_DWT_CYCCNT;
    if (digitalReadFast(PIN_SYNC) == LOW) {//下った
        h_sync_start_tick = now;
    } else {//上った
        uint32_t low_duration = now - h_sync_start_tick;
        if (low_duration > V_SYNC_THRESHOLD) { // Vsyncの信号=長いlow時間かどうか
            static uint32_t last_v_sync = 0;
            // 前回のV-Syncから5ms(1/60秒より十分短い)経っていなければ無視
            // Hsyncは一度に連続して3回送られてくるため:見逃し用なのかな?
            if (now - last_v_sync > 3000000) { 
              v_pos = 0;
              frame_count++;
              last_v_sync = now;
            }
        } else {//短い変化=Hsync
            v_pos++;
        }
    }
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_LATCH, INPUT);
  pinMode(PIN_CLOCK, INPUT);
  pinMode(PIN_DATA, OUTPUT);
  digitalWrite(PIN_DATA, HIGH);
  pinMode(PIN_SYNC, INPUT);
  pinMode(PIN_SIX,INPUT);
  pinMode(PIN_DEBUG,OUTPUT);

  // CPUサイクルカウンタを有効化
  ARM_DEMCR |= ARM_DEMCR_TRCENA;
  ARM_DWT_CTRL |= ARM_DWT_CTRL_CYCCNTENA;
  attachInterrupt(digitalPinToInterrupt(PIN_LATCH), latchISR, RISING);
  attachInterrupt(digitalPinToInterrupt(PIN_CLOCK), clockISR, RISING);
  attachInterrupt(digitalPinToInterrupt(PIN_SYNC), sync_isr, CHANGE);
  // 割り込み優先度を最高に設定 (0が最高)
  NVIC_SET_PRIORITY(IRQ_GPIO6789, 0); 
  Serial.setTimeout(1);
}



#define RECEIVELEN 4
String input;
int values[RECEIVELEN];
bool v_vlank_sent=false;//serial送信済フラグ

void loop() {

  //SerialはH/V解析にかぶらない時間に
  if(v_pos>=230 && !v_vlank_sent){
    Serial.print('G');//要求
    v_vlank_sent=true;
    digitalWrite(PIN_DEBUG,HIGH);//Serial通信が占有する時間を記録
    delayMicroseconds(500); 

    while (Serial.available() > 0) {
      input = Serial.readStringUntil('\n');
      int lastIndex = 0;
      int commaIndex = 0;
      int i = 0;
      while ((commaIndex = input.indexOf(',', lastIndex)) != -1 && i < RECEIVELEN-1) {
        values[i] = input.substring(lastIndex, commaIndex).toInt();
        lastIndex = commaIndex + 1;
        i++;
      }
      if (i == RECEIVELEN-1) {
        values[RECEIVELEN-1] = input.substring(lastIndex).toInt();
      }
    }
    digitalWrite(PIN_DEBUG,LOW);//Serial通信が占有する時間を記録

  }
  if(v_pos==0){
    v_vlank_sent=false;
  }
  
  //送信データ更新
  //7,8サイクル目、NULL,NOISEフラグは正常時H
  //13~16サイクル目、識別子がLLLL
  //↑これらはタイトルごとに違っても許す/許さないなどあるがこうしてれば問題ない
  buttonStates = 0x00FF; 
  if(bool(values[3]))buttonStates &= ~0x0001; //FIRE 主用ボタン
  if(bool(values[2]))buttonStates &= ~0x0008; //PAUSE ポーズが出る(一部シーンのみ)
  //buttonStates &= ~0x0002;//CURSOR カーソル位置補正をやり直せる
  buttonStates &= ~0x0004;//TURBO なんのボタン?

  //6ピン処理
  int px=values[0];
  int py=values[1];
  // 現在の座標を計算
  uint32_t current_tick = ARM_DWT_CYCCNT;
  uint32_t elapsed = current_tick - h_sync_start_tick;
  int x = (int)((elapsed - H_OFFSET) / CYCLES_PER_DOT);
  int y = v_pos;
  
  if (x >= 0 && x < 300 && y >= 0 && y < 260) {//この制限は特にイミないかも
    //teensyではXが1カウント進むのにloopが間に合わないので、幅を持たせている
    //おそらくloopが飛ばしてしまう幅がこの幅の範囲を超えてしまい送信しない時がある
    //確実に、正確に送信したい場合はYがそろった時点でdelayで他の処理を止めて送信
    if (x >= px-1 && x <= px+1 && y == py){
      pinMode(PIN_SIX,OUTPUT);
      digitalWriteFast(PIN_SIX,LOW);
      asm volatile("nop"); asm volatile("nop");
      pinMode(PIN_SIX,INPUT);
    }
      
  }
    
}
PC側コード
sfcScope.py
import pygame
import serial
import sys
import time

COM_PORT = "COM5"
BAUD_RATE = 115200

try:
    ser = serial.Serial(COM_PORT, BAUD_RATE, timeout=0)
    print(f"[o] serial com {COM_PORT} begin({BAUD_RATE}bps)")
except Exception as e:
    print(f"[x]Serial error: {e}")
    sys.exit(1)

pygame.init()
screen = pygame.display.set_mode((800, 600))
clock = pygame.time.Clock()
x,y,dx,dy,btnR,btnL=0,0,0,0,0,0

FPScount = 0
last_time = time.time()
while True:

    dx,dy=0,0
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit()
        if event.type == pygame.MOUSEMOTION:
            x, y = event.pos
            dx, dy = event.rel
        if event.type == pygame.MOUSEBUTTONDOWN:
            if event.button == 1:
                btnL=1
            if event.button == 3:
                btnR=1
        if event.type == pygame.MOUSEBUTTONUP:
            if event.button == 1:
                btnL=0
            if event.button == 3:
                btnR=0
    
    rc,lc=btnR,btnL

    send_data = f"{x},{y},{rc},{lc}\n"
    
    n = ser.in_waiting
    if n > 0: 
        chunk = ser.read(n) # バッファを空に
        # 要求サインである'G'があるとき送信
        if b'G' in chunk:
            ser.write(send_data.encode('utf-8'))
            FPScount+=1
            print(f"Sent: {send_data.strip()}") # 確認用


    if time.time() - last_time > 1.0:
        #print(f"FPS: {FPScount} | Last Data: {send_data.strip()}")
        FPScount = 0
        last_time = time.time()

資料

↑SFC開発ドキュメント。たぶん全て載ってる。
これらの資料では0/1=HIGH/LOWと対応する。SFCはActive:LOWである。

↑SFCのマルチアウト端子,コントローラー端子のピンアウト、通常コントローラー,マウスのプロトコルについて記載されている。

↑開発ドキュメントからスーパースコープについて抜き出したもの。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?