DropboxのPublicフォルダー終了で影響を受けそうな難易度表の曲と差分メモ

要約

  • まずは猫缶サーバーの人のページを読もう

  • この記事は↑で扱われていない難易度表,差分関連でDLできなくなるファイルに関するメモ


DropboxのPublicフォルダーは2017/03/15に自動的に非公開になる。

www.dropbox.com

簡単にはdl.dropbox.com/u/***みたいな感じのリンクが一切機能しなくなると考えてよい。

猫缶サーバーの中の人による解説を読んでもわかるようにこれはBMS界にとって大きな打撃で、かなりの数のBMSがDL出来なくなると考えられる。猫缶ミラーが公開されている理由もそれである。

さて、発狂難易度表をはじめとするいくつかの難易度表でもこのシステムを用いて公開されているファイルが存在する。とりあえずそれらを探し出して、簡単に修正できるものはしておこう、というのがこの記事の目的である。

  • この記事の目的はBMSの保管ではない
  • 各難易度表掲載曲の曲URLもしくは差分URLについて条件に当てはまるものを調査
  • もしも直ちに代替利用可能なDLリンクが存在するならばそれを併記

GENOCIDE - 発狂難易度表

難易度表管轄ファイル

少なくともこれらに関しては公式に連絡するべきである。

それ以外

Overjoy

発狂第2難易度表

第3期LN難易度表

PMS難易度表

これらの差分作者であるoxygen氏はサイト移行中らしく、今後個人サイトでDL可能になるのかもしれない。


5つの難易度表を調べたけれど、(猫缶ミラーを活用するという前提で)思っていたよりは影響を受けるファイル数は少なかったように思う。

とはいえGENOCIDEのrepair_packとかは使えないと大打撃ではあるため、早急な対策が必要ではある。

イムリミット近いけど保管しておいた方がいいのかなあ。

7-zip+PowerShellでBMSアーカイブを可能な限り丁寧に一括解凍する

要約

BOFU2016ですね。

今年は例年以上に登録BMSに対する意見や文句を聞く気がしています(俺も言っている気がします)。

音量容量1本WAVなどいろいろありますが、導入時における最たる障害である「適切なフォルダ構造の判断」をなるべく少ないソフトウェアで解決しようと思い今回PowerShellスクリプトを書きました。

BOFU2016に関してはパッケージ化がもう済んでいることもありあまり需要がない気もしますが、まあもちろんこのイベント以外でも普通に使えると思います。Resilio Syncのやつからまだ展開が済んでいない方などはどうぞ。

なお、同様の方法として以下の方法があるけれども、フォルダ構造を完全に破壊してしまうのでお勧めしません。BOFU2016-num=585などはプレイ不能になると思う。

qiita.com

使い方は

0 前準備

多分実行時に実行ポリシーがなんたらという表示が出る人が多いと思うので以下の記事らへんを参考にしてください。 www.atmarkit.co.jp qiita.com

1 PowerShellを開き、アーカイブがあるディレクトリに移動

cd C:/path/to/bmsarcs

2 7-zipにPATHを通す

7z とだけ打ってオプションとかがずらっと色々出てくればOK。でなければ手動で追加 $Env:Path += 'C:\Program Files\7-Zip;' ※これは一例です

3 ./bmsext.ps1

これで展開が始まる。デフォルトではアーカイブの拡張子を除いた部分をフォルダ名とするけど、./bmsext.ps1 -DirNameMajoredとすると「アーカイブ内に子ディレクトリが存在すれば、フォルダ名としてその子ディレクトリ名を優先」するようになります。

たまにファイルがスキップされます(***Skipped: ファイル名***みたいな)。これはBMSが存在するディレクトリの上位ディレクトリにファイルが存在することにより適切な階層が特定できないためです。手動で展開してください。

gist.github.com

zipファイルの拡張領域 0xE57A について

要約
  • ヘッダID0xE57Aを持つzip拡張ヘッダはファイル名のコードポイントを指定するものである。

  • このヘッダはALZipにより自動で付加されるものである。


zipファイルのヘッダには拡張領域と呼ばれる領域が存在し、ヘッダに標準で含まれる情報以外も記述することができる。

例えば、zip標準ヘッダに存在する貧弱なタイムスタンプ(ローカルにおける時間を2秒単位で丸める。すべてのタイムスタンプは偶数秒を持つ。)の代わりにより詳細なフォーマットで作成、更新、アクセス時刻を格納するための拡張ヘッダが存在する。

拡張領域は、拡張ヘッダ

[HeaderID=2bytes:short LE][DataSize=2bytes:short LE][DataBody=(Datasize)bytes]

の連続である必要がある。zipを扱うアーカイバはこれらを(ヘッダIDを知らなくても)読み取れる必要がある。上述したタイムスタンプは0x5455がヘッダIDにあたる(リトルエンディアン指定のため実際のファイル中では5554というバイト列になる)。

さて、主要な拡張ヘッダのヘッダIDは例えばこの辺とかにいろいろ載っているが、この前あるzipファイルの拡張領域を見ていたらこんな拡張ヘッダを見つけた。

7A E5 04 00 B5 03 00 00

ヘッダIDは0xE57Aとのことだが、少なくとも検索してみたところでそれっぽいものは1つもヒットしない。 とりあえずデータサイズ0x0004=4bytesはデータ本体B5030000とも合致するしミスとかではなさそうである。

データ本体の見た目からint(LE)っぽいので、とりあえず10進にすると0x000003B5=949である。949という数字でピンと来る人もまあいるかもしれないけど、これはおそらくcp949の意なんだろう。cp949はeuc-krの拡張である。

軽い確認がてら高難易度BMS差分アップローダーのzipファイルについて調べてみる。ちょっと手を抜いている。

cpd={}
for zp in glob.glob('./*.zip'):
    try:z=zipfile.ZipFile(zp)
    except:continue
    cp=[
        struct.unpack('<I',zi.extra[zi.extra.find('z\xe5\x04\x00')+4:zi.extra.find('z\xe5\x04\x00')+8])[0]
        for zi in z.infolist() if zi.extra.find('z\xe5\x04\x00')!=-1
    ]
    cp=list(set(cp))
    for n in cp:
        cpd[n]=cpd.get(n,0)+1
print cpd
# ----------
{932: 519, 949: 179}

932->cp932が大量にあるし、まずコードポイントとみて間違いないだろう。

ところで、len(glob.glob('./*.zip'))==6308であったので、このヘッダを付加しているアーカイバは愚直に考えれば10%程度のシェアと考えるのが普通だろう。 どのアーカイバがこのヘッダを付加しているのかいくつか試してみた結果、ALZipでのみデフォルトで付加された(ほかに確認したのはWinRAR,7-Zip,explorer.exe)。 ALZipでは圧縮の際に言語(エンコード)を指定できるため、ここで選択した値が書き込まれるのだろう。 f:id:GNQG:20160911154505j:plain ちなみに言語にUNICODEを指定した場合にはこのヘッダは付加されず、代わりに汎用ビットフラグのUnicodeフラグ(%0000100000000000=2048)が設定されるようだ。

個人的にはとても便利だと思うけどさすがにこれから普及することはないだろうなあ。日本語版ALZipも公開終了してしまったみたいだし。

DEE2イベントページのJSON出力は非常にクセが強い

BMSイベント会場で最も大規模であるといってよいであろうDigital Emergency Exit 2(以下DEE2)のイベントシステムには、上のツイート群にあるように楽曲登録情報とイベントヘッドラインを取得できるJSON APIのようなものが存在している。大まかには以下の通り。

dictionary songlist{
  DOMString EventID;
  DOMString EventNAME;
  Song[] Data;
}

dictionary headline{
  DOMString EventID;
  DOMString EventNAME;
  Line[] Data;
}

dictionary Song{
  DOMString RegID;
  DOMString Team;
  ...
}

dictionary Song{
  DOMString Date;
  DOMString Num;
  ...
}

まあ実は今回この構造は比較的どうでもよくて、問題はこれを読み込むのが一筋縄ではいかなかったということ。

いつものようにPythonで読みたいのでいろいろ試してみるが、ツイートにあるBOFU2015のsonglistがまず読み込めない。

j=json.loads(
    requests.get('http://manbow.nothing.sh/event/event.cgi?action=JSONList&event=104&utf-8=on').content
)

ValueError: No JSON object could be decoded

これは辞書構造のエラーである。具体的には最後の部分がこうなっている。

{
  ...,
  "Data":[
    {...},
    {
      "RegID" : "456" , 
      ...,
      "UpdateTime" : "2015\/11\/04 00:44"
    }, ←これ
  ]
}

リスト末尾に余分なカンマがついているとjsonモジュールは読んでくれないらしい。1個くらい我慢してほしい。 原因ははっきりしてないけど、「エントリーIDが最大の作品が削除されている」という状況下で発生すると推測できる。BOFU2015ではnum=458まで発行されたようだが、現存する最大IDは456である。ちなみに全曲数は447。

content=requests.get('http://manbow.nothing.sh/event/event.cgi?action=JSONList&event=104&utf-8=on').content
j=json.loads(re.sub(r'}\s*,\s*]\s*}\s*\Z',r'}]}',content))

これで無事読み込めたわけだけどこれで終わるならこんな記事を書くわけがない。

§†гё∂㎡の情報を見る。

{
  "RegID": "86",
  "Team": "S.E.Q.uencer",
  "Artist": "EFB37-1",
  ...,
  "Title": "§†гё∂�u", ←これ
  ...
}

化けている。

これの原因は楽曲情報の生データ(CP932で持っているのだろう)をCP932ではなくShift_JISでデコード、UTF-8エンコードしたことによる。 ということで、正確なデータを得るためにはutf-8=onをクエリから外すべきである。

ここから泥沼の戦いが始まる。

content=requests.get('http://manbow.nothing.sh/event/event.cgi?action=JSONList&event=104').content
j=json.loads(re.sub(r'}\s*,\s*]\s*}\s*\Z',r'}]}',content).decode('cp932'))

ValueError: Invalid \escape: line 892 column 30 (char 36887)

892行目を見る。

890 ...,
891 "RegID" : "69" , 
892 "Team" : "第10回自称無名BMS作家が物申\す!反省会場はこちらです" , 
893 "Artist" : "Sena" , 
894 ...,

ダメ文字対策の'\'(0x5C)追記がしてある。伊達に2000年代序盤からイベントサイトやっていないんだなあと思う。いろいろな意味で。

contentを以下の感じに置換すればよい。

content=re.sub(
            '([\x81-\x9f\xe0-\xfc]'+r'\\)\\',
            r'\1',
            content
        )
j=json.loads(content.decode('cp932'))
len(j['Data'])
# == 447

読み込めた。§†гё∂㎡も、

for d in j['Data']:
    if ('RegID','86') in d.items():
        print json.dumps(d,ensure_ascii=False,indent=2)

{
  "RegID": "86",
  "Title": "§†гё∂㎡",
  ...
}

という感じに読めている。OK。

さて、ここまでやれば問題ないだろうと本当に思っていたんだけれど、headlineのほうが実は厄介。 例えば、BOFU2015のheadlineに以下のようなDataの要素がある。

{
  "Num" : "107" , 
  ...,
  "Title" : "ミルキーベリー・スウィートタイ�" , 
  ...
}

化けている、とは実は少し違う。どうやら2バイト文字を1バイト目で切ってしまっているらしい。

さらに性質が悪いことに、これをそのままdecode('cp932')すると、行末手前の'"'を巻き込んで\ufffdになり、 JSONとしての構造が破壊される。とりあえずの解決法として、'"'の手前に' 'を挿入し、 decode('cp932',errors='replace')してからそれを削除するという苦肉の策をとった。 見た目がきれいになるようにきちんと改行が入っていることに救われたと言う他ない。

content=requests.get('http://manbow.nothing.sh/event/event.cgi?action=JSONList&event=104&output=headline').content
content=re.sub(
            r'}\s*,\s*]\s*}\s*\Z',r'}]}',
            content
)
content=re.sub(
            '([\x81-\x9f\xe0-\xfc]'+r'\\)\\',
            r'\1',
            content
        )
content=re.sub(
            r'^(\s*\".*?\"\s*:\s*\".*)\"(\s*[,\]}]?\s*)$',
            r'\1 "\2',
            content,flags=re.MULTILINE
        )
content=content.decode('cp932',errors='replace')
content=re.sub(
            ur'^(\s*\".*?\"\s*:\s*\".*)[ \ufffd]\"(\s*[,\]}]?\s*)$',
            ur'\1"\2',
            content,flags=re.MULTILINE
        )
j=json.loads(content)
len(j['Data'])
# == 1000

これでheadlineも読めるようになった。控えめに言っても面倒すぎる。 あとこれはついでだけど、曲名などの中に文字参照が入ったままになっているため、json.loads前に先日作成したものを使って、

content=unescape_charref(content)

としてやれば、

{
  "RegID": "231",
  "Artist": "霧慧トァン",
  "Title": "&lt;music:name=Goodbye My Soul/&gt;",
  ...
}
=>
{
  "RegID": "231",
  "Artist": "霧慧トァン",
  "Title": "<music:name=Goodbye My Soul/>",
  ...
}

のように戻せる。

これでほとんどすべてのJSONをきちんと読めるようになった。けど、かなり重大な奴が隠れていた。

event=26のB-1 "Unrestricted"を見ると(字面からすでに想像できるかもしれないが)

{
  "EventID" : "26" , 
  "EventName" : "B-1 "Unrestricted"" , ←これ
  ...
}

まさかのダブルクォーテーションのエスケープ漏れ。他にもあるかもしれないので上と似たようにして対処する。

content=re.sub(
            ur'(^\s*\".*?\"\s*:\s*\")(.*\".*)(?=\"\s*[,\]}]?\s*$)',
            lambda s:s.group(1)+re.sub(
                ur'((^|[^\\])(?:\\\\)*)(?=\")',
                ur'\1'+ur'\\',
                s.group(2)
            ),
            content,flags=re.MULTILINE
        )

これはjson.loadsの直前に行えばよい。

また、event=105 BOFOON ULTIMATE 2015のheadlineに以下の内容を持つインプレがある。

{
  ...,
  "Info" : "このやっつけ感が正に (^^)"
  ...
}

'正に''(^^)'の間の空白はエスケープされていないタブ文字\tである。

タブ文字をタブ文字として文字列に格納するには、json.JSONDecoderを調整する必要がある。 今回は最も簡単に以下のようにした。

class DEE2JSONDecoder(json.JSONDecoder):
    def __init__(self,encoding=None,strict=False**kwgs):
        json.JSONDecoder.__init__(self,encoding,strict=False,**kwgs)

明示的にこのデコーダを使用するには、

j=json.loads(content,cls=DEE2JSONDecoder)

のようにすればよい。

以上を行った結果、http://manbow.nothing.sh/event/event.cgiからたどれるすべてのイベントについて無事に読み込むことができるようになった。もうやりたくない。

以上の操作を楽に行えるものを例によってgistに上げておくので、この辺に興味がある人は使ってほしい。

dee2.py · GitHub

正直なところ、中の人がsjis->cp932に書き換えてくれるだけでだいぶ楽になると思う。

Python2系で文字参照のエスケープとか

車輪の再開発

ほぼ同様の関数はHTMLParser.HTMLParser.unescapelxml.html.fromstringあたりにもあるけれど、 前者は

  • U+10000-U+10FFFFが無理
  • HTMLParser.HTMLParser()を一度生成しないと使えない←結構気に食わない

だし、後者はunescapeのためにわざわざlxmlimportするのも気が引けるので。

2系に限っているのは3系には既にあるため。

基本戦略はHTMLParser.unescapeと同じく(←書いてから知った)re.subを使う方法で。

re.subは第3引数(置換後文字列)に関数オブジェクトを置けるというのがポイントで、そこに文字参照→対応するユニコード文字(列)なる関数を置いてやればよい。

def unescape_charref(escaped):
    return re.sub('CHARREF REGEXP',lambda s:REPLACE(s),escaped)
名前または数字の解決

正規表現で簡単に書ける。

r'&((?P<char>[a-z]+)|#(?P<decimal>\d+)|#x(?P<hex>[\da-f]+));'

マッチした場合入れ子になった3グループのうち必ず2グループは空文字列となり、1グループは空文字列にならない。 よって、この正規表現にマッチしたre.MatchObjectmatchobjとして、

from htmlentitydefs import name2codepoint

def getunichr(matchobj):
    if matchobj.group('char'):
        # Character entity references
        num=name2codepoint.get(matchobj.group('char'),0)
    elif matchobj.group('decimal'):
        # Numeric character reference (decimal)
        num=int(matchobj.group('decimal'))
    elif matchobj.group('hex'):
        # Numeric character reference (hexadecimal)
        num=int(matchobj.group('hex'),16)
    return num

という感じにすればよい。これで名前→数値はOK。

UCS-2とUCS-4

Python処理系はUCS-2あるいはUCS-4がビルド時にオプションとして指定されていて、その違いによりunichr等の挙動が異なる。

qiita.com

ちなみにPython.orgのWin-x64のバイナリは(俺のがそうなので多分)UCS-2が指定されている。この場合unichrは0<num<0x10000つまりU+0000-U+FFFFの部分しか対応していない。

UCS-2とUCS-4の区別はsys.maxunicodeでできる。

リンク先のコピペに近いけど、

from sys import maxunicode

def uchr(c):
    if 0<c<=maxunicode:
        # BMP(UCS-2) / whole(UCS-4)
        return unichr(c)
    elif maxunicode<c<=0x10ffff:
        # SMP(UCS-2) / None(UCS-4)
        c-=0x10000
        return unichr(c>>10|0xD800)+unichr(c&0x3FF|0xDC00)
    else:
        # c==0 or c is out of Unicode
        return ''

という感じ。Unicodeから外れてたらとりあえず空文字列を返す。

SMPに対応する意味あんの?とか実は書きながら思ってたんだけど、最近で言うと絵文字とかもここに属するらしいので、案外重要なのかもしれない。

まとめて

こういう感じになりました。

gist.github.com

本当に3系と同等にするのならばhtmlentitydefs.name2codepointでなく3系のhtml.entities.html5あたりも対応すべきかもしれないけど、面倒なので省略。

ゴーストへのエンコーダーも作った

github.com

エンコーダーも作ってしまったのでLR2-ghost-decoderからLR2-ghostlibに微妙に名前を変えました。まあ今後触ることはないと思うけど・・・。

手元のスコアで全一致確認済み。

Oが定義されてないのは何でだろうなーもしかしたら極端なケースとかあるのかな。

サムネ用のサンプル実行結果↓

f:id:GNQG:20160302010135p:plain

LR2のゴースト文字列のデコード

github.com

単体では特に使い道がないと思うけど、ちょっと思うところあって頑張って調べて作った。

手持ちのスコアで調べた限りLR2側のエラーっぽいの除いて全部合ってそうだったので多分大丈夫だと思う(適当)。

とっても頑張ってコード読めばゴースト文字列内の各文字が何を意味しているかわかると思う(書くのが面倒なだけ)。情報必要なら言ってください。