Seaside Laboratory

Post

KFX の enemy.lst に潜む閾値バグ

KFM の乱数使用状況を調べていたら enemy.lst の処理だけ乱数の使い方が他と異なっていたので、正しく実装できているのか確かめるため、KFX の逆アセソースを見たところ以下のような処理になっていた。

// 乱数を生成
int val = abs( rand() ) / 328;

// 閾値チェック
if ( threshold >= val )
{
    // 行確定
}

まず、乱数を生成する rand 関数は、

rand 関数は、0 ~ RAND_MAX (32767) の範囲で、擬似乱数の整数を返します。

となっているので 0 ~ 32767 の値を生成して返す。abs は絶対値を返す関数だが rand は負数を返さないので無視して問題ない。最後に 328 での除算が行われ、RAND_MAX だった場合は約 99.89 になり、整数キャストで小数部が切り捨てられ、最終的に val の値は 0 ~ 99 の範囲に収まることになる。

次の条件式では enemy.lst に書かれた閾値と生成した乱数を比較しているので、閾値によって確率は変化し、成立すれば行が選択されるということが分かる。

特に変わったことをしていない単純なコードではあるが、処理にあやしげな部分があることに気づいてしまった・・・。

あやしい処理

より正確な表現をするために行の選択確率を制御するパラメーターのことを「閾値」と表記しているが、キャラ製作者の立場からすれば「確率」と言われた方がしっくりくるだろうし、CARROT 公式ページにあった enemy.lst の説明でも「確率」という言葉が使われている。

1 番目(距離) 相手との距離で、軸の差で計算します。最大 320 ドットなので 320 以内です。
2 番目(確率) これは 3 項目のコマンドを実行する確率です。1 行前との差が値になります。
3 番目(コマンド) コマンドです。1,2,3,4,6,7,8,9,p,P,k,K,n,N が使えます。

確率と考えた場合、0 と書いたら 0% を意味するので、わざわざそんな値を設定しようという発想自体生まれないわけだが、もし 0 を設定したらどうなるのだろうか。閾値チェック式は以下のように展開されることになる。

// 閾値チェック
if ( 0 >= val )

val は 0 ~ 99 の範囲をとる乱数なので偶然 0 が生成された場合、

// 閾値チェック
if ( 0 >= 0 )

という式になる。使われている比較演算子は「以上」なので、含まれるべきでない値が含まれ、条件が成立してしまう点に問題がある。

ここまではコードから読み取った仮説なので実際にどうなるのか検証を行う。

検証 (1)

まず、以下の内容で enemy.lst を作成し、全エンジンで動作テストを行う。

Distance--100--Command--COMMENT
    100   100  nnn7p    ;近距離
    320   100  2p       ;遠距離
遠距離は屈み弱パンチ。
近距離になると後方ジャンプ弱パンチ。

キャラが動作中でも安定してコマンドが実行されるよう「近距離」行には n が入れてある。

何故か XPlus は立ち弱パンチになったがテストに支障はないのでスルー。

問題なく動作したので「近距離」行の閾値を 0 に書き換え再度テストを行う。

Distance--100--Command--COMMENT
    100     0  nnn7p    ;近距離(出ないはず)
    320   100  2p       ;遠距離

「近距離」行を実行するには乱数が偶然 0 になる瞬間までひたすら待つしかないが、「遠距離」行のコマンド処理に 2 フレームかかったとしても毎秒 12.5 回程は乱数を生成することができるので、タイムオーバーまで待てば十分な試行回数を確保できると踏んでいる。

で、結果はどうなったかというと KFX だけコマンドが実行された。

ラウンド開始早々に実行される。

拡張エンジンの中に KFX との互換性を重視しているものがあってもおかしくはないので、正直どうなるのか予想が付かなかったが再実装する際に適切なコードに置き換わった、ということなのだろう。

検証 (2)

以前書いた「KFX の enemy.lst 図解」でも触れているが、どんな状況であっても何れかの行が選択されるよう、閾値が 100 の行を必ず定義することが推奨されている。この 100 は乱数の上限より大きく、必ず条件を成立させる特別な値として使われているが、KFX の条件式はあやしいので上限についても検証を行う必要がある。

まず、乱数が最大値を生成した場合、

// 閾値チェック
if ( threshold >= 99 )

という式になる。閾値に 100 を入れると、

// 閾値チェック
if ( 100 >= 99 )

必ず成立する条件式にはなるが、比較演算子が「以上」なので、

// 閾値チェック
if ( 99 >= 99 )

99 でも成立してしまう。

この仮説を検証するため、以下の内容で enemy.lst を作成し、全エンジンで動作テストを行う。

Distance--100--Command--COMMENT
    320    99  2p       ;
    320   100  nnn7p    ;

99 を最大値として扱うエンジンであれば 99 が見つかった時点で検索は中断されるので、100 の行が実行されるかどうかで判別をすることができる。

結果は KFX だけ 100 の行が実行されなかった。今までの検証結果を踏まえると乱数が 100 通りあるという点は全エンジンで共通しているが、KFX のみ比較式の違いによって 1 のズレが発生している。

以前作成した図は拡張エンジンのものを指していることになってしまったので、新たにズレがある KFX バージョンを加え、アニメーションにしてみた。

閾値 0 による底上げと 100 の先端が範囲外に出ていることが分かる。

KFX で正確な確率になるよう閾値を調整するという考え方もあるが、そもそも KFX は enemy.lst の処理が不正確であるのと、正確に処理を行うエンジンの方が多いのでお勧めできない。