かつかれーのメモ帳

実験ノートか勉強記録

CY7C68013Aボードで簡易USBロジアナ

Cypress社のマイコン、CY7C68013Aが乗ったボード(いわゆるEZ-USB FX2LPボード)を簡単にロジアナ化できるということなのでやってみました。
具体的な手順は先人達が良くまとめて下さっているのですが、古い情報も混在しているので補足と共にリンク集的に書き留めておきます。

環境構築

そもそもこのボードはSaleae社のLogicというロジアナのクローンを作るために利用されていたようで、同社の公式ドライバと公式アプリを組み合わせて使う方法について書かれた記事も多く見つかりますが、現在は対策されていて出来なくなっているようです。
したがって、PC側のソフトはSigrok一択と思われます。

PulseView・Sigrok-cli

Sigrokのクライアントです。PulseViewはGUIで、Sigrok-cliCLI。本記事の最後に書いたPython連携なんかが不要ならCLIは入れなくてもOKです。インストールは以下のリンクから。
Windows - sigrok

マイコンの開発環境

以下のリンクにもある通り、cy3684kit_RC8をhttp://www.cypress.com/file/135301から入手してインストールします。適当に進めていたらCドライブ直下に複数のツールが入ってしまい、サブPCにインストールすれば良かったという気持ちに…
EZ-USB開発環境設定メモ - neko Java Home Page

ボード

下のリンクに記載のある、Geeetech版のボードが良さそうです。
Lcsoft Mini Board - sigrok
CY7C68013AでAmazonを検索するといくつかヒットしますが、例えばこれとかはトップの画像が同じもののようです。
Amazon.co.jp: EZ-USB FX2LP CY7C68013A USB 開発コアボードのロジックアナライザ: 産業・研究開発用品
ジャンパ2つとスイッチ2つを備えたボードです。

ボードの説明

回路図が見つからなかったのでざっと触って調べた程度ですが、簡単にボードの解説を入れておきます。論理が逆になっているボードなども普通に流通していそうなので、以下の情報を使う際は自己責任で。

J1

LEDを使用するか否か決めるもののようですね。PA0とPA1にはLED(D1,D2)のカソードが繋がっているのですが、アノード側はJ1経由でVCCから電流が供給されているため、J1を抜くと光らなくなります。
Sigrokは、当ボードがロジアナとしての動作している間LEDを点滅させてお知らせしてくれるみたいなので、刺したままにしておくのが良さそうです。

J2

AVRで言うところのHWBボタンみたいなものですね。普段は抜いたままにしておきます。差し込んだ状態で電源を供給すると素のCY7C68013Aとして認識され(マイコンからEEPROMが見えなくなるためROMを読みに行ってブートすることがなくなる)、開発ツールから各種書き込みができます。当然、開発ツールからEEPROMの内容を書き換えるときはジャンパを抜いてEEPROMにアクセス可能にする必要があることに注意です。マイコンを起動したらすぐジャンパを抜きましょう。

青い大きなスイッチ

USBのバスパワーを基板に供給するか決めるためのもので、どうしてもセルフパワーで使いたいというのでない限り供給するようにしておけばいいと思います(USBケーブルを刺した状態で「POWER LEDが光る側」にしておけば良いです)。

表面実装のタクトスイッチ

マイコンのリセットボタンです。J2刺して再起動するときとかに使えます。

VID/PIDの書き換え

J2を抜いた状態でUSBケーブルを刺すと、素のCY7C68013Aボードとして認識されると思います。ドライバが当たっていなければ、C:\Cypress\USB\CY3684_EZ-USB_FX2LP_DVK\1.1\Drivers以下を指定して読み込みます。
あとはEZ-USBロジックアナライザー - neko Java Home Pageの通り、

  • C:\Cypress\USB\CY3684_EZ-USB_FX2LP_DVK\1.1\Windows Applications\Application Source files\c_sharp\controlcenter\bin\Release\CyControl.exe を使って、VID/PIDを1D50/608Cに書き換え。EEPROM容量は16kBあるので、Req codeは0xA9です。
  • PulseView同梱のZadigを使って、VID/PID=1D50/608CのデバイスにWinUSBドライバを割り当て

の手順を済ませれば、デバイスとプリンターメニューでは"Unknown Device #1"という名前で見えるようになるはず(ドライバは当たっている)。
この状態でPulseViewを起動すればfx2lafwというデバイス名で認識され、無事波形が取れるようになります。
PB0~PB7がロジアナのch0~7にそれぞれ対応します。

長時間測定とPython連携

C:\Program Files\sigrok\sigrok-cliにパスが通っている前提で、

sigrok-cli --config samplerate=4M --driver=fx2lafw --continuous

のようにすると標準出力にデータが出てきます。リダイレクトによってテキストで保存するとこんな感じ。

f:id:DSKK:20210509042950p:plain
連続測定の結果

samplerateは4Mまで安定していました。PulseViewだと24Mまで動くのにSigrok-cliだと割とすぐダメになるっぽい?
これをPythonで受けるにはsubprocessモジュールを使えばよくて、基本のコードは大体以下のような感じ。

import subprocess
cmd=[
"C:/Program Files/sigrok/sigrok-cli/sigrok-cli",
"--config",
"samplerate=4M",
"--driver=fx2lafw",
"--continuous"
]
proc = subprocess.Popen(cmd,stdout=subprocess.PIPE,text=True)
proc.stdout.readline() #useless line
proc.stdout.readline() #useless line
l=[]
try:
    while True:
        out = proc.stdout.readline()
        if out=="": #通信がダメになると空文字列が返ってくるので測定終了
            proc.kill()
            break
        l.append(out)
except KeyboardInterrupt: #手動で止める際の処理
    proc.kill()

lの各要素は

"D0:11111111 11111111 11111111 11111111 11111111 11111111 11111111 11111111\n"

みたいな感じなので、適宜成型して解釈すれば良いでしょう。
ちなみに、readline()はブロッキング実行で、USBのFIFOが空の間は待ちになります。実測してみたら16ms周期で5000~10000行くらいドバっと送られてきているみたい。timeモジュールで時間を実測しても意味ないので、samplerateが正しいと信じてlistのindexから時間を割り出してプロットなどするのが良いです。1MSpsを指定して5分くらい測ってみましたが、99.9914%くらいの精度で時間は正しかったです(=データが上がってくる周期が1 usよりもほんのわずかに短い)。
ここまで書いて気付いたのですが、これPulseViewでサンプル数を1Tとかすごく大きな数にして測定すればよくない…?

Windowsクリーンインストール+環境構築の手順

自分用。まっさらな環境で何をすればいいかよくわからなくなるので、必要なものを列挙しておく。
Twitterに細切れで投稿するより、後で見返したりアップデートしたりしやすいブログにまとめるべきだってやっと気づいた…

クリーンインストールの前に

FastCopyってツールがタイムスタンプ残しつつ高速なファイルコピーをしてくれるので別のHDDなどに今のデータを全部移す。書き込み対象がHDDであっても半日あれば1TBくらいいける。

必須系

ウイルス対策を初手でやったらドライバ先に入れたほうがいいかも

研究関連

  • Ovito
  • VESTA
  • IGOR (公式サイトからDL、メールで貰ってるアクティベーションキーを入れて使用開始)
  • Rlogin (ポータブルアプリなので同フォルダ内に鍵入れておくこと)
  • TexLive

開発関連

ドライバ

  • NVIDIAグラフィックスドライバーとGeForce Experience
  • Logicool Options
  • プリンタ(LBP6300とEP-811A)
  • ペンタブ
  • オーディオI/F
  • CUDA

ユーティリティ

  • W10Wheel
  • PowerToys (FancyZonesで縦画面を分割するため)
  • クリップボード系のアプリもそのうち入れる

Neovim

https://github.com/dskk/nvim
README通り、前提条件を満たしたあとXDG_CONFIG_HOMEでこのリポジトリをgit clone。
あとはNeovimの実行ファイル落として実行すればプラグインのダウンロード等勝手にやってくれる。

実行ファイルは公式サイトから普通に落とせる。
右クリックメニューへの追加方法は→GVim/neovimの右クリックメニュー登録方法 [Windows 10] | Vim入門

cd ~/bin
wget https://github.com/neovim/neovim/releases/latest/download/nvim.appimage
chmod u+x nvim.appimage
echo alias vi=\"~/bin/nvim.appimage\" >> ~/.bashrc
source ~/.bashrc

でいつもの感じになる。場合によっては以下のような対応も必要になるかもしれない。
dskk.hatenablog.com

雑記
  • W10Wheelの設定ファイルは~/.W10Wheel.properties。あとタスクスケジューラから「ログインのたびに管理者として実行する」ようにしておくと良い。
  • W10Wheelのスクロール切り替えにはScrollLockキーを設定しているが、Logicool Optionsから単体のキーストロークは設定できないのでAlt+ScrollLockを設定する。

sudo出来ない環境でModuleNotFoundError: No module named '_ctypes'を解決する

要約

とあるスパコンで前回の記事(Python環境が訳わからなくならないための自分用メモ - かつかれーのメモ帳)に従ってPython環境を作った後、Neovimの環境構築をしていたところ、

python -m pip install neovim

の段階で表題のエラーが発生。
調べてみるとlibffiが無いのがいけないらしい。そのせいで「_ctypesが使えないpython」としてビルドされてしまった感じ。

pyenv uninstall 3.9.2

して、以下の手順の通りlibffiを入れてからpythonを入れ直すことで解決できた。

手順

普通にローカルにダウンロードして解凍、ビルド、インストール。もちろん$HOME配下である必要がある。

wget https://github.com/libffi/libffi/releases/download/v3.3/libffi-3.3.tar.gz
tar xvfz libffi-3.3.tar.gz
cd libffi-3.3
./configure --prefix=$HOME/local/libffi/3_3
make
make install

すると~/local内にlibffi/3_3/が出来る。

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$HOME/local/libffi/3_3/lib64
export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:$HOME/local/libffi/3_3/lib/pkgconfig

をbashrcに追記、sourceしてから

LDFLAGS="-L $HOME/local/libffi/3_3/lib64" CPPFLAGS="-I $HOME/lo
cal/libffi/3_3/include" pyenv install 3.9.2

python 3.9.2を入れ直せば完了。pipも正常に通った。

Python環境が訳わからなくならないための自分用メモ

WindowsLinux(CentOSUbuntu)でPython環境を壊さないためのメモをしておく。自分の使い方の場合、現時点ではこのやり方が一番良さそう。

まずpipについて

例えば普段pyコマンドでpythonを起動しているとする。この環境にモジュールを入れたいときは

py -m pip install numpy

のような記法が強く推奨される。pip, pip3, pip3.9など、どれがどれだかそのうちわからなくなるので。
所望のバージョンのpythonが正しく起動できている限り、これでモジュールが入れられるはず。

Windowsにインストール

Python標準のインストーラーを使ってインストール。新しいバージョンが出たら都度インストーラーを落としてきてインストールするので問題ない。
このときAdd Python 3.x to PATHすると混乱のもとになるのでしないように。
ストアアプリ版も変に競合するので入れない方が良い。
py.exeというランチャーがついてくるので、pyコマンドでpythonを起動する運用にする。バージョンは引数で選べるし、省略すれば自動的に最新のものが起動される。
今まではパスが通っていると思って使っていたモジュールたちは、以下のようにpyランチャー経由で呼べば良い。

py -m IPython
py -m jupyter lab

参考:
gammasoft.jp

Linuxにインストール

スパコンなど、自分がsudoersに入っていない場合の手順。pyenvを使うのが良い。
pyenvのgithubページにある通り、

git clone https://github.com/pyenv/pyenv.git ~/.pyenv
cd ~/.pyenv && src/configure && make -C src
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bash_profile
echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bash_profile
echo -e 'if command -v pyenv 1>/dev/null 2>&1; then\n  eval "$(pyenv init -)"\nfi' >> ~/.bash_profile
source ~/.bash_profile

すればgit cloneしかしないのでsudo aptが不要で嬉しい。なお2行目はpyenvの高速化のためのもので、コケたら別にそれはそれでいいらしい。
さらに

pyenv install 3.9.2
pyenv global 3.9.2

これでpython3.9.2がインストールされ、pythonコマンドに紐付けられる。
他に何がinstall出来るか知りたいときは

pyenv install -l

で一覧が出せる。
現在何が入っていて、そのうちどれがデフォルトなのか知りたいときは

pyenv version

すれば良い。

機械学習を用いた音ゲーのクリア難度推定 (その1)

音ゲーのクリア難度推定なんかデータサイエンスの入門にいいんじゃないかな?とふと思い立ってやることにしました。3記事くらいに分けて公開します。初回はデータ収集のためのWebスクレイピングについて。

やること

  • LR2IRのデータをもとに、BMSの譜面のクリア難度を推定します。
  • 譜面とプレイヤーの相性が要因として大きいので、Eloレーティングのような1軸評価ではない手法について検討します。

データの準備

LR2IRにはプレー人数の非常に少ない譜面や、アクティブでないアカウントが多く存在します。
そこで、まず集計対象プレイヤーについて「BMS発狂難易度表の収録曲のうち125曲以上スコア登録済み」という基準を設けます。これはStairwayの基準を引用したものです。
そして、「集計対象プレイヤー(現在12000人くらい)のうち、100人以上がスコアをつけている」曲を集計対象曲とします。

import re
from urllib.request import urlopen
from bs4 import BeautifulSoup
#stairwayの集計対象プレイヤーのid一覧を取得
player_ids=[]
bs = BeautifulSoup(urlopen("http://stairway.sakura.ne.jp/bms/LunaticRave2/?contents=player&p=1"))
for player_link in bs.find_all("a", {"href" : re.compile("/?contents=player&page=\d")}):
    player_id = int(player_link["href"].split("=")[-1])
    player_ids.append(player_id)
#ファイルに保存
with open("playerid","w") as f:
    f.write(str(player_ids[0]))
    for player_id in player_ids[1:]:
        f.write(",")
        f.write(str(player_id))

全ユーザーに対してプレー履歴の全取得をするとちょっとサーバーに負荷をかけてしまいそうなので、ユーザーリストはStairwayから拝借しました。
aタグのうち、プレーヤーのmypageへのリンクの形になっているものを選び出し、splitすることでplayer idを得ています。
これは単一ページのパースなのですぐできると思います。
次に、譜面ごとにプレー人数を集計します。譜面ID(bmsid)をkey、プレー人数をvalにした辞書で数えていきます。keyがないとき勝手に0を入れてくれるdefaultdict(int)が便利。負荷をかけないようsleepを入れながら、丸一日くらいで取得しました。
このうち、100人以上がプレーした譜面を残します。意外なことに、10000譜面ちょっとありました。

import re
from collections import defaultdict
from urllib.request import urlopen
from bs4 import BeautifulSoup
#各playerのプレーしたことあるbmsidを全取得、bmsidごとにプレーした人数を数える
num_player = defaultdict(int) #key:val=(bmsid, プレー人数)
for player_id in player_ids:
    for i in range(1, 10000):
        bs=BeautifulSoup(urlopen(f"http://www.dream-pro.info/~lavalse/LR2IR/search.cgi?mode=mylist&playerid={player_id}&sort=recent&filter=7&page={i}"))
        findresult=bs.find_all("a", {"href" : re.compile("search.cgi\?mode=ranking&bmsid=\d")})
        if not findresult:
            break #空のページに至ったらbreak
        for bms_link in findresult:
            bms_id = int(bms_link["href"].split("=")[-1])
            num_player[bms_id]+=1
        time.sleep(0.01)
    print(f"player{player_id} done")
#プレー人数がmin_playcountより多い譜面だけ残してbms_idsにidを集計
bms_ids=[]
for k in num_player.keys():
    if num_player[k]<min_playcount:
        continue:
    else
        bms_ids.append(k)
#ファイルに保存
with open("bmsid","w") as f:
    f.write(str(bms_ids[0]))
    for bms_id in bms_ids[1:]:
        f.write(",")
        f.write(str(bms_id))

最後に、この集計対象譜面について全プレーヤーのスコアを取得し、集計対象ユーザーのものだけ残してファイルに保存します。1譜面1ファイルで書き出しています。
丸二日くらいかかりました。ファイルサイズは合計で770 MBでした。

import re
import time
import os
from collections import defaultdict
from urllib.request import urlopen
from bs4 import BeautifulSoup

with open("playerid","r") as f:
    player_ids=set([int(i) for i in f.read().split(",")])
with open("bmsid","r") as f:
    bms_ids=set([int(i) for i in f.read().split(",")])

for bms_id in bms_ids: #集計対象譜面についてループ
    if os.path.exists(str(bms_id)):
        print(f"bmsid {bms_id} ... skipped")
        continue
    with open(str(bms_id), "w") as f:
        print(f"bmsid {bms_id} ... ",end="")
        for i in range(1, 10000):
            bs=BeautifulSoup(urlopen(f"http://www.dream-pro.info/~lavalse/LR2IR/search.cgi?mode=ranking&page={i}&bmsid={bms_id}"))
            find_result=bs.find_all("a", {"href" : re.compile("search.cgi\?mode=mypage&playerid=\d")})
            if not find_result:
                break #空のページに至ったらbreak
            for player_link in find_result:
                player_id = int(player_link["href"].split("=")[-1])
                if player_id not in player_ids:
                    continue
                playdata = player_link.parent.parent.contents[3:]
                f.write(str(player_id))
                for item in playdata:
                    f.write(",")
                    f.write(item.get_text())
                f.write("\n")
            time.sleep(0.01)
        print("done")

例えばBMS界のAAこと星の器(bmsid=15)に対する出力ファイルの冒頭はこんな感じ。各行で一番左の列はplayer id、残りがプレーデータです。

37188,FULLCOMBO,AAA,4012/4012(100%),2006/2006,0,2006,0,0,0,2,難,乱,KB,LR2
83367,FULLCOMBO,AAA,4011/4012(99.97%),2006/2006,0,2005,1,0,0,0,難,乱,BM,LR2
202,★FULLCOMBO,AAA,4010/4012(99.95%),2006/2006,0,2004,2,0,0,0,難,乱,KB,LR2
105711,FULLCOMBO,AAA,4010/4012(99.95%),2006/2006,0,2004,2,0,0,0,易,乱,KB,LR2
120831,FULLCOMBO,AAA,4010/4012(99.95%),2006/2006,0,2004,2,0,0,0,難,乱,KB,LR2
160637,FULLCOMBO,AAA,4009/4012(99.92%),2006/2006,0,2003,3,0,0,2,難,乱,BM,LR2
15499,FULLCOMBO,AAA,4008/4012(99.9%),2006/2006,2,2002,4,0,0,4,難,乱,BM,LR2
72359,FULLCOMBO,AAA,4008/4012(99.9%),2006/2006,0,2002,4,0,0,0,易,乱,KB,LR2
94774,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2001,5,0,0,0,難,乱,BM,LR2
3906,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2001,5,0,0,0,難,乱,BM,LR2
36488,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2001,5,0,0,0,難,乱,KB,LR2
2725,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2002,3,0,0,1,難,乱,BM,LR2
4314,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2002,3,0,0,1,難,乱,BM,LR2
95222,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2001,5,0,0,0,難,乱,BM,LR2
139053,FULLCOMBO,AAA,4006/4012(99.85%),2006/2006,0,2000,6,0,0,1,難,乱,BM,LR2
22923,FULLCOMBO,AAA,4006/4012(99.85%),2006/2006,0,2000,6,0,0,0,難,乱,KB,LR2
4812,★FULLCOMBO,AAA,4006/4012(99.85%),2006/2006,0,2000,6,0,0,13,難,乱,BM,LR2
51433,FULLCOMBO,AAA,4006/4012(99.85%),2006/2006,0,2000,6,0,0,8,普,乱,BM,LR2
25546,FULLCOMBO,AAA,4005/4012(99.82%),2006/2006,0,2000,5,0,0,4,普,乱,BM,LR2
...

次回はMarix Factorizationという方法を用いて、これらのデータから難度推定を行っていきます。
→すでに同等の成果が公表されていたのでこの件は保留とします。難度の他に推定したい量が思いついたら続けます。

複数のUSBファームウエアを自作コントローラーに乗せる

IIDX自作コントローラー記事のソフト編(その3)です。
以前の記事(その1, その2)で、2つの異なるUSB HIDゲームパッドの仕様を決めました。今回はこれらを1つのコントローラーに収め、切り替えながら動作させるための具体的な実装についてまとめます。当記事で一旦ソフト編は完結する予定です。

大雑把な基板の仕様

詳細は回路編に譲りますが、ファームウエア設計に関わる部分だけここで決めておきます。

  • ICとして、USBインターフェースの乗ったAVRを用いる。
  • ボタン・スクラッチ等の入力、LED等の出力も同じICで扱う。これにより、主要部をワンチップで完結させる*1
  • 同じ基板をIIDXだけでなくSDVXにも利用できる程度の汎用性を持たせる。

こんな感じ。ある程度多ピンのAVRを採用することになります。
要件を満たす物の中で入手性が良いのはATmega32U4ですかね。Arduinoとして実装済みの基板(Leonardo, Micro)が手に入る点もgoodです。しかし、値段がほぼ変わらないのに入出力ピンが多く取れて、遊ぶ余地があるAT90USB646の方が個人的には好みです*2
…こんな調子なので基板の仕様がきっちり固まりません。基板仕様のブレはファームウエア側で吸収するのが良さそうです。

ファームウエアの設計

まずは簡単な図から。
f:id:DSKK:20200603194916p:plain
ファームウエア仕様の概略。画像出典はこのブログとWikipedia
図からもわかる通り、コントローラーの構成要素は大きく分けて以下の二つです(左右に振り分けています)。後述のstate_structがこれらをつないでいます。

  • ハードウエア
    • 主に筐体+基板をハードウエアと呼んでいます。左側、IIDXの囲いの中の2つのコントローラー画像がそれぞれ異なるハードウエアに対応します。
    • 大雑把に機種ごとに括ることができます。
  • 動作モード
    • 画像の一番右の列です。PC等から見てどんな仕様のコントローラーに見えるのか、色々と異なっています。各動作モードは個別のHIDレポートディスクリプタを持つため、動作モードに応じて異なった形のHIDレポートを送出する必要があります。
    • 例えばPS3コントローラーのモードがIIDXとpop'nの家庭版に共通して用いられるように、機種をまたがって同じモードが使用される場合があります。

ハードウエア仕様は作るうちに変化する幅が広く(場合によっては採用するチップから変えてしまうこともあります)、どの程度の汎用性を持たせるべきかかなり判断しづらいです。そこで今回は、ハードウエア仕様1つにつき1つのファームウエアを作成することにします(画像にもあるように、個別のmain.cおよびmakefileを持つようにします)。コーディングのコストを最小限にするために、使うチップを変えない限りmakefileを使い回せるようにし、main.cも最低限の内容になるようにします。

EEPROMを用いた動作モード切替

明らかにこのままではハードウエアと動作モードの組み合わせが多すぎてしまい実装が大変です。まず、ハードウエア側は上述の通り、ファームウエアを個別に分けることにより個々の実装を単純にします。動作モード側は「起動後の動作モードの切り替えを考慮しなくてよい*3」ことに着目し、イニシャライズ時に一つの動作モードに確定してしまいます。すると上記の図はイニシャライズ後の時点で
f:id:DSKK:20200604102453p:plain
ここまで単純になります。また、破線を含めたループがメインループになります。

struct states_str_t states_str;
int main(void) {
    io_init(); //ピンアサインを決める。PORTx, DDRxの設定など
    usb_init(); //USB関連のイニシャライズ(後述)
    while (usb_configuration==0); // configuredになるまで待つ
    _delay_ms(1000);
    while (1) {
        io_task(); //=「キー入力を読んで更新」
        (*usb_task_ptr)(); //=「データを成形して出力」
    }
}

以上のコードに対応します。どんなハードウエアであってもmain.cの内容はこれ+io_init+io_taskになっていればOKです。io_initは入出力レジスタの設定と(必要なら)動作モード変更のためのEEPROMの書き換え、io_taskは入力ピンの読み込み・states_strの更新・出力ピンの設定を行います。詳細はgithubに今日(2020/06/04)上げたソースコードを参照してください。
次に、usb_init関数について解説します。実装は以下の通り。

void usb_init(void) {
    uint8_t eep_val = eeprom_read_byte(EEP_ADDR); // read EEPROM
    eeprom_busy_wait();
    switch(eep_val){
    case 1: //PS3
        ep_list = (const uint8_t *) ep_list_PS3;
        desc_list = (const uint8_t *) desc_list_PS3;
        ep_list_len = 2;
        desc_list_len = 7;
        is_ps3 = 1;
        usb_task_ptr=&usb_task_PS3;
        break;
    case 2: //INFINITAS
        ep_list = (const uint8_t *) ep_list_INFINITAS;
        desc_list = (const uint8_t *) desc_list_INFINITAS;
        ep_list_len = 2;
        desc_list_len = 7;
        is_ps3 = 0;
        usb_task_ptr=&usb_task_INFINITAS;
        break;
    default:
        for(;;);
    }
    //ここから下はレジスタの設定
    HW_CONFIG();  // UHWCON = 0x81 USB device mode && enable the USB pad regulator
    USB_FREEZE(); // enable the USB controller && disable the clock inputs
    PLL_CONFIG(); // (Clock division factor)=8 (External XTAL)=16MHz enable PLL
    while (!(PLLCSR & (1<<PLOCK))); // wait for PLL lock (it takes about 100 ms)
    USB_CONFIG(); // start USB clock
    UDCON = 0; // clear bit0 - DETACH (Set to physically detach
               // the device by disconnecting internal pull-up on D+ or D-).
    usb_configuration = 0; // zero when we are not configured, non-zero when enumerated
    UDIEN = (1<<EORSTE)|(1<<SOFE); //enable the EORSTI interrupt and SOFI interrput
    // EORSTI - End Of Reset Interrupt flag.
    // Set by hardware when an “End Of Reset” has been detected by the USB controller.
    // SOFI - Start Of Frame Interrupt flag.
    // Set by hardware when an USB “Start Of Frame” PID (SOF) has been detected (every 1ms).
    sei();
}

まず、eep_valにEEPROMの内容を読み込んできます。読み込むアドレス=EEP_ADDRの値はとりあえず0にしてあります。EEPROMが腐って読み書きできなくなったら別のアドレスに変えればいいと思います。
読み込んだeep_valの値に応じて各動作モードのイニシャライズをswitch文により行っています(原理上最大256種の動作モードを切り替えられます)。セットするのは以下の変数です。

const uint8_t* ep_list;
const uint8_t* desc_list;
uint8_t ep_list_len;
uint8_t desc_list_len;
uint8_t is_ps3;

特にdesc_listがわかりやすいのですが、これはUSBの各種リクエストに応じて返すべきディスクリプタ情報を構造体の配列として持ったものです。例えばPS3の場合

const struct desc_list_struct desc_list_PS3[] PROGMEM = {
    {0x0100, 0x0000, (const uint8_t*)device_desc_PS3, DEVICE_DESC_SIZE_PS3},
    {0x0200, 0x0000, (const uint8_t*)config_desc_PS3, CONFIG_DESC_SIZE_PS3},
    {0x2100, 0x0000, (const uint8_t*)config_desc_PS3+DEVICE_DESC_SIZE_PS3, 9},
    {0x2200, 0x0000, (const uint8_t*)report_desc_PS3, REPORT_DESC_SIZE_PS3},
    {0x0300, 0x0000, (const uint8_t*)&str_desc_langID, 4},
    {0x0301, 0x0409, (const uint8_t*)&str_desc_manufacturer, sizeof(STR_MANUFACTURER)},
    {0x0302, 0x0409, (const uint8_t*)&str_desc_product_PS3, sizeof(STR_PRODUCT_PS3)}
};

です。usb.cの実装を見ていただけるとわかるのですが、この中からリクエストに合うものを探して返すような動作になっています。ここで一つポイントなのが、各動作モードのディスクリプタをすべてconst PROGMEMキーワード付きでハードコードしている点と、動作モード固有のサフィックス付きで命名している点です。ディスクリプタは結構大きい構造体ですが、これによりRAMを圧迫せずに済みます。またこれらが別々の名前を持っていることから、上記desc_listなどで配列の先頭アドレスが必要になったときにも(const uint8_t*)report_desc_PS3のように問題なく記述できます。
ep_list, desc_listのような抽象化のおかげで、usb.cはハードウエア仕様によらずスタンドアロンで動作します。

各動作モード固有の実装

動作モードごとに

が必要です。これらをcommon/modes/*.cに実装します。ただしストリングディスクリプタの一部は共通なのでcommon_desc.cに実装してあります。
もし動作モードを追加したくなったら以下の手順を踏めば良いです。states_structについては説明がまだですが、後述します。また、ファイルの配置は最後の方に説明があります。

  1. 新たにcommon/modes/内にhoge.cを作る。既存のfuga.cを複製→サフィックスを置換→内容を適宜修正などでOK。
  2. common/modes/modes.hの末尾をコピペで増やしてサフィックスを置換。
  3. common/usb.cのusb_init関数内のswitch文にcaseを追加。
  4. states_struct.cにレポートを送る関数を実装、states_struct.hに関数のプロトタイプ宣言を追加。

簡単ですね。各種ディスクリプタが用意できていれば単純作業で動作モードを増やせるのが良いです。
この実装はハードウエア仕様には依存しないようになっていて、外からは「HIDレポートの送受信用関数」を呼べば良いです。この関数はいくつかのuint8_t型変数*4を受け取り、HIDレポートディスクリプタの要求に沿うように送信用レジスタにセットします。

states_struct

ハードウエア固有のパートはmain.cに押し込めておくことを先に述べました。そして、動作モード固有のパートはcommon/modes/*.cに押し込めてあります。これらを繋ぐための内容がstates_struct.cに実装されています。
states_structはマイコンレジスタよりは高級な、ボタン等の入力状態を保持する構造体です。この構造体は機種ごとに1種類定義されます。IIDXの実装例は以下の通りです。

struct states_str_t{
    uint16_t  button_state; //pressed=1 else=0
    uint8_t   button_is_locked[16];
    int32_t   button_locked_time[16];
    uint8_t   scratch_state; //neutral=0 up=1 down=2
    uint16_t  scratch_position;
    uint8_t   scratch_is_locked;
    int32_t   scratch_locked_time;
    int32_t   scratch_kept_time;
};

16bitのbutton_state(bit0がボタン0番、bit15がボタン15番に対応)、scratch_state(無操作=0, 左回り=1, 右回り=2)など。チャタリング対策のため、前の操作タイミングを控えておく変数などもあります*5
この構造体のインスタンスはexternキーワード付きで1つ作成され、main.cのio_task関数で内容が随時更新されます。そしてstates_struct.cに実装されたusb_task_PS3関数やusb_task_INFINITAS関数などによって読まれ、成形ののち送信されます(これらのusb_task_*関数は前段落で言うところの「HIDレポートの送受信用関数」を内部で呼んでいます)。
usb_task_hogeの形をした関数のうちどれを呼ぶべきかは動作モードを切り替えるたびに変わります。そのため、main.cでは関数ポインタusb_task_ptrの指す関数を呼ぶということにしてあり(上記コード参照)、その指す先はusb_init関数内のswitch文でセットしています。これもちょっとした工夫ポイントですね。

フォルダ構成

これまでの動作の依存関係を踏まえて、以下の通りファイルを配置するときれいにまとまります。

┌─common
│  │  common.h
│  │  usb.h
│  │  usb.c
│  │
│  └─modes
│          modes.h
│          common_desc.c
│          INFINITAS.c
│          PS3.c
│
└─IIDX
    │  states_struct.c
    │  states_struct.h
    │
    └─dao_red_usb_replacement
            main.c
            Makefile

[機種名]/[基板名]/main.cが多数存在していて、それらが1階層上で定義される機種固有のstates_structを更新。states_structはcommon/modes/[該当する動作モード].c内のHIDレポート送信用関数を呼ぶといった流れです。
ハードウエア仕様に依存しない部分はcommonフォルダに分離してあり、特にusb周りはusb.cとusb.hに分けてあります。usb_init関数もここ。

おわりに

以上のソースはこちら。
github.com
ブログを書きながら設計直したら最後のコミットで†Restructured everything†してしまった。gitヘタクソか?
ソフトの実装編はこの辺で完結ですかね。設計こだわったおかげでSDVX版もすぐ出来そう。
自分用の備忘録的な書き方にどうしてもなってしまったので、何か気になる点があったらコメントか
しありす (@cialis438) | Twitter
へのリプをお気軽にどうぞ。感想やコントローラー自作に関する雑談・質問なども歓迎です。
下のリンクからシェアやツイートなどしてもらえると大変執筆モチベが上がりありがたいです。どうぞよろしく。

*1:AVR以外にはトランジスタアレイ(LEDドライブ用)を用いる程度で留めることを想定。

*2:makefileを書き換えてやれば、AT90USBシリーズにもATmega32Uシリーズと同じソースコードが使えます

*3:例えば、繋げる相手がPS3からPCに変わるなら一度ケーブルを抜くことになります。したがってコントローラーの起動時、すなわちUSBケーブルを差し込むタイミングで動作モードを選択できれば十分です。

*4:もちろん、関数の実装によってはこれに限りませんが。

*5:構造体内に定義するのが適切か?と言われると結構微妙。グローバル変数でもよかったかも。

beatmania IIDX INFINITASを自作コントローラーで遊ぶ

IIDX自作コントローラー記事のソフト編(その2)です。
beatmania IIDX INFINITAS(beatmania IIDXのPC移植版。以下、略してINFと呼ぶ。)をプレーできる自作コントローラー仕様について調べてまとめました。

使えるコントローラーについて

とりあえず、INFの公式サイトはこちら
動作環境のページにもあるように、INFにおいてUSB HIDゲームパッドとしては公式コントローラーのみ動作することになっています。サードパーティーの作成したUSB HIDゲームパッド(有名どころだとDAOコンとか)の動作はサポートされておらず、JoyToKeyなどによってキーボード入力に変換して使用するのが一般的です*1。キーボードによる操作はサポートされているためです。
まあアプリをいちいち立ち上げて変換するのが面倒だったり、変換の際の遅延が心配だったり、なによりキーボードには出来ない動作が仕様上存在していて*2不便だったりといった問題があるため、今回は公式コン準拠の動作をするゲームパッドを作っていきましょう。今のところ発売されている公式コンは

の2種類。今回はフル機能を有するプレミアムモデルの再現をしていきます。
公式コンは、つないでゲームを起動するだけで普通に使えるみたいです。一方、ほかのコントローラーは一切反応しません。やはりここにも何かしらの認証プロセスが存在しているようです。

コントローラーが満たすべき仕様

いきなり結論なのですが、INFはコントローラーのベンダーID/プロダクトIDしか見ていません。これらの値が正しくてHIDレポートが同じ形式になっていれば、とりあえず動きます。細かいところは違っていて大丈夫です。
ベンダーIDは公開情報なのでweb上のデータベースを引けばよくて、例えば以下のようなサイトを使うことで0x1CCFだとわかります。
USB ID Database::Vendor ID and Product ID list - the sz development
プロダクトIDは某掲示板で0x1CCFを手掛かりにちょっと調べると分かります。あるいは、DJ DAOの配布しているファームウエアをエディタで開き、0x1CCFに隣接する2バイトを読んできても良いです*3
f:id:DSKK:20200529184459p:plain
まあ、見ての通りです。リトルエンディアン(上位バイトが後)であることに注意。CF1C→1CCFと同じように、4880を2文字+2文字に区切ってからひっくり返して読んでください。
コントローラー仕様はDJ DAOが詳細に公開しちゃってます。怒られないのかな…
youtu.be
まず、デバイス仕様は「2軸16ボタン」ですね。
キーアサインは

1鍵: 1
2鍵: 2
3鍵: 3
4鍵: 4
5鍵: 5
6鍵: 6
7鍵: 7
E1:  9
E2: 10
E3: 11
E4: 12
ターンテーブル: X軸循環

のようになっています。
各種ディスクリプターとして用いることができるものの例です。

/* Device Descriptor */
    18,                // bLength
    1,                 // bDescriptorType
    0x00, 0x02,        // bcdUSB
    0,                 // bDeviceClass
    0,                 // bDeviceSubClass
    0,                 // bDeviceProtocol
    64,                // bMaxPacketSize0
    0xcf,              // idVendor[0]
    0x1c,              // idVendor[1]
    0x48,              // idProduct[0]
    0x80,              // idProduct[1]
    0x00, 0x10,        // bcdDevice (=version number)
    1,                 // iManufacturer
    2,                 // iProduct
    0,                 // iSerialNumber
    1                  // bNumConfigurations

/* Configuration Descriptor */
    9,                              // bLength;
    2,                              // bDescriptorType;
    34,
    0,                              // wTotalLength
    1,                              // bNumInterfaces
    1,                              // bConfigurationValue
    0,                              // iConfiguration
    0x80,                           // bmAttributes
    50,                             // bMaxPower
    // interface descriptor
    9,                              // bLength
    4,                              // bDescriptorType
    0,                              // bInterfaceNumber
    0,                              // bAlternateSetting
    1,                              // bNumEndpoints (is 2 if the device has INTR_IN)
    0x03,                           // bInterfaceClass (0x03 = HID)
    0x00,                           // bInterfaceSubClass (0x00 = No Boot)
    0x00,                           // bInterfaceProtocol (0x00 = No Protocol)
    0,                              // iInterface
    // HID interface descriptor
    9,                              // bLength
    0x21,                           // bDescriptorType
    0x11, 0x01,                     // bcdHID
    0,                              // bCountryCode
    1,                              // bNumDescriptors
    0x22,                           // bDescriptorType
    50,
    0,                              // wDescriptorLength
    // endpoint descriptor
    7,                              // bLength
    5,                              // bDescriptorType
    1 | 0x80,                       // bEndpointAddress (|0x80=in)
    0x03,                           // bmAttributes (0x03=intr)
    64, 0,                          // wMaxPacketSize
    4                               // bInterval

/* Report Descriptor */
    0x05, 0x01,        // Usage Page (Generic Desktop Ctrls)
    0x09, 0x04,        // Usage (Joystick)
    0xA1, 0x01,        // Collection (Application)
    0x09, 0x01,        //   Usage (Pointer)
    0xA1, 0x00,        //   Collection (Physical)
    0x05, 0x01,        //     Usage Page (Generic Desktop Ctrls)
    0x09, 0x30,        //     Usage (X)
    0x09, 0x31,        //     Usage (Y)
    0x15, 0x81,        //     Logical Minimum (-127)
    0x25, 0x7F,        //     Logical Maximum (127)
    0x75, 0x08,        //     Report Size (8)
    0x95, 0x02,        //     Report Count (2)
    0x81, 0x02,        //     Input (Data,Var,Abs)
    0x05, 0x09,        //     Usage Page (Button)
    0x19, 0x01,        //     Usage Minimum (0x01)
    0x29, 0x10,        //     Usage Maximum (0x10)
    0x15, 0x00,        //     Logical Minimum (0)
    0x25, 0x01,        //     Logical Maximum (1)
    0x75, 0x01,        //     Report Size (1)
    0x95, 0x10,        //     Report Count (16)
    0x81, 0x02,        //     Input (Data,Var,Abs)
    0x75, 0x08,        //     Report Size (8)
    0x95, 0x01,        //     Report Count (1)
    0x81, 0x03,        //     Input (Const,Var,Abs)
    0xC0,              //   End Collection
    0xC0               // End Collection

あとは実装するだけでちゃんと公式コントローラーとして認識されます。なお、レポートディスクリプタにもある通りHIDレポートは5バイトで、

1: X軸(signed char)
2: Y軸(signed char)
3: ボタン(1~7鍵盤。順にbit0~6に対応)
4: ボタン(E1~4。順にbit0~3に対応)
5: 定数(0x00)

という構造になっています。

f:id:DSKK:20200604094055p:plain
動作OK!右下のランプが公式コントローラーとして認識していることを示しています。
次回は実装の解説をやっていきます。

まとめ

PS3への対応と比べてずいぶん簡単なコントローラー仕様解析でした。デバイスディスクリプタの時点で異なる2つのファームウエアを同じボード上に共存させる方法について、次回の記事でソースコードを具体的に示しながら解説していきます。ちょっとだけテクいのでお楽しみに(?)
下のリンクからシェアやツイートなどしてもらえると大変執筆モチベが上がりありがたいです。良かったらぜひ。何か気になる点があったらコメントか
しありす (@cialis438) | Twitter
へのリプをお気軽にどうぞ。感想やコントローラー自作に関する雑談・質問なども歓迎です。

*1:ただし、DAOコンは後述するのと同様のやり方で公式コンとして認識されるようにしています。

*2:ファンクションボタンを使った、選曲時の操作

*3:コントローラーのファームウエアはデバイスディスクリプタを丸々含んでいるはずですよね。ここで、ベンダーIDとプロダクトIDは連続するバイト列なので…という発想。