具体的な記述例があった方が分かり易いかなと思ったので、asl ファイルの作り方の補足資料としていくつか例を示します。
もくじ
基本的な asl の例
基本的な Autosplitter
変数の値を print 出力する
バージョン違いに対応
ロードリムーバ
IGT 計測
発展的な asl の例
タイマースタート時に初期化したい変数がある
ゲーム終了でタイマーを自動リセット
タイマーストップ状態でもリセットしたい
ラップ済みの区間再走時にラップしない
ポインタが一瞬 null になる現象に対応
情報表示
asl で関数を使用する
イベント購読 (ver1.8.17 以降)
イベント購読(ver1.8.16 以前、汎用的な方法)
asl 標準(start, split, reset)以外のタイマー操作
実験的な asl
注意事項
ファイルの文字コード
asl ファイル自体の文字コードは UTF-8 に設定しましょう。
私の経験では Shift-JIS でも半角文字のみで記述しているなら正しく動作するのですが、日本語でコメントを入れると全く別の行でエラーが発生して原因の特定に無駄な時間を取られる場合があります。
サンプルスクリプトの前提条件
この例では、次のような条件があるものとします。
- ゲーム内で部屋移動したタイミングでタイマー操作する
- 部屋ごとに異なる ID が設定されている
- 部屋の ID は先に進むほど大きい値になる
- タイトル画面にも ID が設定されていて、部屋 ID と同じメモリアドレスで管理されている
基本的な asl の例
基本的な Autosplitter
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
state("ゲームのプロセス名")
{
int roomId : "game.exe", 0x00, 0x00; // 部屋ID
}
init
{
vars.TITLE_ID = 0; // タイトル画面のID
}
start
{
// 直前の画面がタイトルで、部屋IDが変わった時
return old.roomId == vars.TITLE_ID && current.roomId != old.roomId;
}
split
{
// 部屋IDが変わった時
return current.roomId != old.roomId;
}
reset
{
// タイトル画面になった時
return current.roomId == vars.TITLE_ID;
}
解説
この例の記述では部屋を行き来するだけでラップを取るので「前に部屋に戻った時はラップを取らないで欲しい」場合は工夫が必要です。
また私が過去に経験したケースでは「ラップ判定に使う値がポーズ中に別の値に書き換わる」ということがあり、このような場合も工夫が必要です。
変数の値を print 出力する
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
update
{
// 変数の値を表示したい場合の例
print("text : " + current.Stage);
print(string.Format("text : {0}", current.Stage));
// これは使えない
print($"text : {current.Stage}");
}
解説
asl のでバックで有用な、print() で変数の値を出力する場合の例。
文字列と変数を + 記号でつなげたり、string.Format("{番号}", 変数) を使ったりすることが出来ますが、$"{変数}" は使えません。
バージョン違いに対応
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
state("ゲームのプロセス名", "ver1.0")
{
int roomID : "game.exe", 0x00, 0x00;
}
state("ゲームのプロセス名", "ver1.1")
{
int roomID : "game.exe", 0x00, 0x01;
}
state("ゲームのプロセス名", "ver1.2")
{
int roomID : "game.exe", 0x00, 0x02;
}
init
{
// メモリサイズでバージョンを判断
switch (modules.First().ModuleMemorySize)
{
case 00000000:
version = "ver1.0";
break;
case 00000001:
version = "ver1.1";
break;
case 00000002:
version = "ver1.2";
break;
default:
version = "";
break;
}
vars.TITLE_ID = 0;
}
update
{
// 対応していないバージョンの時はautosplitterを無効にすることもできる
if (version == "")
return false;
return true;
}
// start, split, reset は同じなので省略
解説
ゲームのバージョンが違うと modules.First().ModuleMemorySize も違う値になることを利用した方法。
バージョンが違っているのにメモリサイズは同じ場合にはこの方法では対応できないため注意。
ロードリムーバ
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
state("ゲームのプロセス名")
{
bool isLoadFlg : "game.exe", 0x00, 0x10;
}
isLoading
{
return current.isLoadFlg;
}
解説
私にロードリムーバを作った経験がないので想像での話ではありますが、たぶん「現在ロード中」を判断する材料を集める部分がかなり大変だと思います。
IGT 計測
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
state("ゲームのプロセス名")
{
long igt : "game.exe", 0x00, 0x11; // この例ではミリ秒とする
}
isLoading
{
// 基本的に同期と同期の間はタイマーが実時間で進む
// それを防ぎたい場合はここで常にtrueを返す
return true;
}
gameTime
{
// GameTimeをIGTと同期
return TimeSpan.FromMilliseconds(current.igt);
}
解説
ゲーム内時間はゲームによって整数だったり浮動小数だったり、秒だったりミリ秒だったりするので、例えば画面では hh:mm:ss のように表示されていても、その表示のままモメリ検索するとヒットしないことがあります。
発展的な asl の例
タイマースタート時に初期化したい変数がある
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
state("ゲームのプロセス名")
{
int roomId : "game.exe", 0x00, 0x00;
}
init
{
// 最も進んだ部屋のIdをここに代入
// タイマースタート時に0に初期化する
vars.mostAdcancedRoomId = 0;
}
onSrart
{
// ここでタイマースタート時の変数初期化
vars.mostAdcancedRoomId = 0;
}
update
{
if (vars.mostAdvancedRoomId < current.roomId)
{
vars.mostAdcancedRoomId = current.roomId;
}
}
start
{
return old.roomId == 0 && current.roomId != -1;
}
解説
スタートと同時に変数の初期化を行いたい場合があります。
例えば、自動ラップや自動リセット、多重の自動ラップ防止などを制御するための変数の初期化です。
このような変数の初期化は onStart{ } ブロックで行うと良いです。onStart{ } は Livesplit ver1.8.17 以降の機能であることに注意。
注意が必要な例
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
start
{
if (old.roomId == 0 && current.roomId != -1)
{
// タイマースタートの条件を満たした時に初期化する例
// この場合、手動スタートすると自動ラップなどが動作しない場合がある
vars.mostAdcancedRoomId = 0;
return true;
}
}
解説
start{ } ブロックのスタート条件の判定を行っている if 文の中で変数の初期化をすることも可能ですが、そこで初期化する変数を利用した機能は自動スタートした時しか正常に動作しなくなるので、asl 使用時に注意が必要となります。
ゲーム終了でタイマーを自動リセット
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
state("ゲームのプロセス名") {}
init
{
vars.timerModel = new TimerModel {CurrentState = timer};
}
reset
{
// チェックボックス操作を有効にするためのブロック
// 実際のリセット時の処理は exit ブロックで行う
return false;
}
exit
{
if (settings.ResetEnabled && (timer.CurrentPhase != TimerPhase.Ended))
vars.timerModel.Reset();
}
解説
init{ } でタイマーモデルを作成し、exit{ } でタイマーモデルのリセットを実行しますが、そのままだと自動リセットを off に出来ない問題があるため、reset{ } で対策しています。
タイマーストップ状態でもリセットしたい
タイマーを直接操作してリセット
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
update
{
// タイマーストップ状態のリセット用
// タイマーモデルオブジェクトのメソッドを利用する
if ((/*タイマーストップ状態でのリセット条件*/) &&
(timer.CurrentPhase == TimerPhase.Ended) &&
settings.ResetEnabled)
{
new TimerModel {CurrentState = timer}.Reset();
}
}
reset
{
// 通常のリセット用
// ここで自動リセットできるのはタイマーが計測中orポーズ中
if (/*通常のリセット条件*/)
{
return true;
}
}
解説
タイマーが計測中かポーズ中の時しか reset{ } による自動リセットは働きません。タイマーストップ状態で自動リセットしたい場合は別の方法が必要です。
TimerModel オブジェクトを実体化して CurrentState に timer を渡すことで、タイマーを直接操作できるようになります。
update{ } ブロックの中でタイマーストップ状態でのリセット判定を行い、.Reset() メソッドを実行することでリセットできます。
ラップ済みの区間再走時にラップしない
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
state("ゲームのプロセス名")
{
// この例では roomId が 0-19 だったとする
int roomId : "game.exe", 0x00, 0x00; // 部屋ID
}
startup
{
for (int index = 0; index <= 19; ++index)
{
string segmentName = "Room " + index;
settings.Add("room" + index.ToString(), true, segmentName);
}
}
onStart
{
// ラップ済み区間チェック用
// bool の初期値は false なので、この場合は初期化不要
vars.triggeredSplits = new bool[20];
}
split
{
if ((current.roomId > old.roomId) && (!vars.triggeredSplits[current.roomId]))
{
vars.triggeredSplits[current.roomId] = true;
return settings["room" + current.roomId];
}
}
解説
ラップ済みかをチェックするための bool 配列を用意して、ラップする前は false、ラップしたら true にします。
この例では roomId を 0-19 と配列に都合の良い範囲にしていますが、実際は複雑になる可能性があるので注意。
ポインタが一瞬 null になる現象に対応
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
state("ゲームのプロセス名")
{
int roomId : "game.exe", 0x00, 0x00; // 部屋ID
}
init
{
vars.TITLE_ID = 0; // タイトル画面のID
vars.roomId = vars.TITLE_ID; // 値の保持用変数
}
start
{
vars.roomId = vars.TITLE_ID; // 保持用変数の初期化
// 直前の画面がタイトルで、部屋IDが変わった時
return old.roomId == vars.TITLE_ID && current.roomId != old.roomId;
}
split
{
// 部屋IDが変わった時
// currentと保持用変数とを比較する
if (current.roomId > vars.roomId)
{
vars.roomId = current.roomId; // 保持用変数を更新
return true;
}
}
reset
{
// current.roomId == 0 を条件にすると、このケースでは誤作動の危険があるため
// リセット条件は他の条件にする必要がある
// return current.roomId == vars.TITLE_ID;
}
解説
メモリアドレスを特定できたと思っても、そのアドレスの値を観察してみると一瞬だけポインタが null になって値を見失う現象が発生することがあります。
Cheat Engine で監視していると一瞬だけポインタが ?? ?? ?? ?? になったり値が ?? になる現象です。(表示の更新間隔が長いと見逃すことがあるため、60 fps 相当の更新間隔などに設定しておくと良いです)
値を見失っている時はその型の初期値が入っているものとして扱われます。
おそらく c# の初期値の仕様によるものだと思います。
このような現象が発生するようなケースで split の条件を return current.roomId != old.roomId; にしていると誤作動する危険があるので、この例のように、正常な current の値を保持するための変数 vars を使用する方法があります。
情報表示
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
state("ゲームのプロセス名")
{
int roomID : "game.exe", 0x00, 0x00;
}
startup
{
// タイマーレイアウトからTextコンポーネントを探してリストアップ
vars.tcss = new List<System.Windows.Forms.UserControl>();
foreach (LiveSplit.UI.Components.IComponent component in timer.Layout.Components)
{
if (component.GetType().Name == "TextComponent")
{
vars.tc = component;
vars.tcss.Add(vars.tc.Settings);
print("[ASL] Found text component at " + component); // 確認用メッセージ
}
}
// 確認用メッセージ
print("[ASL] *Found " + vars.tcss.Count.ToString() + " text component(s)*");
}
update
{
// この例では Text が1つ以上あったなら、1つ目に情報を書き込む
if (vars.tcss.Count > 0)
{
vars.tcss[0].Text1 = "RoomID"; // Text1は左寄せの文字
vars.tcss[0].Text2 = current.roomId.ToString(); // Text2は右寄せの文字
}
}
解説
通常はプレイヤーが知り得ない情報を表示すると SRC の General Gameplay Rules が定める禁止行為とみなされるリスクがあるため、記録を SRC に申請することを考えてるならば注意が必要です。
asl で関数を使用する
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
startup
{
// 戻り値なし、ブロックをまたいで使いたい場合
// 定義
vars.Log = (Action<object>)(output => print("[asl] " + output));
// 使用例
vars.Log("test");
// 戻り値なし、同じブロック内でのみ使う場合
// 定義
Action<object> Log = output => print("[asl] " + output);
// 使用例
Log("test");
}
split
{
// 戻り値ありの関数定義
Func<int, string, bool> IsSplit = (roomId, segmentName) =>
{
// 各部屋にボスがいて、ボスのHPが0になった瞬間にラップしたい
if ((current.roomId == roomId) &&
(current.bossHp <= 0) &&
(old.bossHp > 0) &&
vars.doneSplit[roomId]))
{
// 通過済みの部屋を再訪問時にラップしないようにするためのフラグ
vars.doneSplit[roomId] = true;
return settings[segmentName];
}
};
if (IsSplit(1, "room1")) {return true;}
if (IsSplit(2, "room2")) {return true;}
if (IsSplit(3, "room3")) {return true;}
if (IsSplit(4, "room4")) {return true;}
}
解説
Action や Func によって、asl 内で関数を定義して使用することができます。
関数を定義したブロックと同じブロックでしか使用しないのか、ブロックをまたがって使用するのかによって、代入する先を vars. 変数にしない/するの使い分けをします。
イベント購読 (ver1.8.17 以降)
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
// タイマースタート時
onStart
{
vars.room = 1; // 開始時の部屋id
}
// タイマーリセット時
onReset
{
vars.room = 0; // タイトル画面のid
}
// ラップ時
onSplit
{
vars.room = current.roomId;
}
解説
ScriptableAutoSplit.dll ver1.8.17 にて構文が追加され、onStart, onSplit, onReset メソッドが使用できるようになりました。
イベント購読・解除の記述が不要になるため asl の作成が楽になります。
ただし、追加されたのはこの 3 つだけなので、他のタイマーイベントを利用したい場合は従来と同じ方法を使う必要があります。
イベント購読(ver1.8.16 以前、汎用的な方法)
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
state("ゲームのプロセス名")
{
int roomId : "game.exe", 0x00, 0x00; // 部屋ID
}
startup
{
// タイマースタート時
vars.TimerStart = (EventHandler)((s, e) =>
{
vars.room = 1; // 開始時の部屋id
});
timer.OnStart += vars.TimerStart;
// タイマーリセット時
// Resetのみ EventHandler ではなく EventHandlerT<TimerPhase> なので注意
vars.TimerReset = (LiveSplit.Model.Input.EventHandlerT<TimerPhase>)((s, e) =>
{
vars.room = 0; // タイトル画面のid
});
timer.OnReset += vars.TimerReset;
// ラップ時
vars.TimerSplit = (EventHandler)((s, e) =>
{
vars.room = current.roomId;
});
timer.OnSplit += vars.TimerSplit;
}
start {return current.roomId == 1;}
reset {return current.roomId == 0;}
split {return current.roomId > vars.room}
shutdown
{
// 購読したら忘れずに解除
timer.OnStart -= vars.TimerStart;
timer.OnReset -= vars.TimerReset;
timer.OnSplit -= vars.TimerSplit;
}
解説
(s, e) => { }; はイベントハンドラのラムダ式を使った定型文で、(s, e) は仮引数 (object sender, EventArgs e) のことです。
s はイベントを送信したオブジェクトで、LivesplitState (or TimerModel ?)。
e は Reset 以外のイベントでは EventArgs 型で値は null、Reset の場合は TimerPhase 型で値は直前の TimerPhase が入っています。
イベントハンドラの中で s も e も利用しなくても問題ないですが、定型文なので書いておく必要はあります。
購読解除の記述を忘れると解除できなくなった購読が残り続けて、解除の記述をした後でも「エラーの原因の行が特定できない謎のエラー」が発生し続けるようになる場合があります。
私の経験では、Livesplit を再起動しても残り続け、Livesplit を終了して少し時間を置いてから起動することでエラーを解消できた、ということがありました。
asl 標準(start, split, reset)以外のタイマー操作
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
state("Livesplit"){ }
startup
{
// timerを渡してTimerModelクラスを実体化して
// タイマー操作メソッドを使用
vars.timerModel = new TimerModel {CurrentState = timer};
}
// onSplitはver1.8.17以降で使用可能
onSplit
{
// タイマー操作したい場所でTimerModelのメソッドを呼び出す
vars.timerModel.Pause();
}
解説
TimerModel クラスを利用することで、タイマー操作メソッドが使用できます。
実体化する際に CurrentState = timer
とすることを忘れずに。
この例では「ラップを取った時にタイマーを一時停止する」動作ですが、メソッドを呼び出す場所は if の中に入ることが多いのではと思います。
TimerModel のメソッドによるタイマー操作の他の例として、タイマーストップ後に自動リセットするものを見たことがあります。
実験的なasl
asl 本来の用途とは異なるであろう、実験的な asl です。
asl には色々な制約があるため時には不自由を感じることもありますが、実はエラーの発生を回避してしまえば案外自由だったりします。
ホットキー送信
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
state("LiveSplit")
{
}
init
{
// 直前のフレームのタイマーの状態を保持
vars.oldPhase = TimerPhase.NotRunning;
}
update
{
// 完走したらホットキーを送信
if(timer.CurrentPhase == TimerPhase.Ended &&
vars.oldPhase == TimerPhase.Running)
{
// SendKeysはアクティブウィンドウに対しての送信
SendKeys.Send("A");
}
// 現在フレームのタイマー状態を保持
vars.oldPhase = timer.CurrentPhase;
}
解説
実験的な asl その 1
完走したタイミングで自動的にキー入力を送信して他のソフトのホットキーを反応させる、ということが asl でできます。
Comparisonの書き換え
サンプルスクリプト
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
state("LiveSplit")
{
}
init
{
// const vars
vars.COMPARISON_NAME = "Add 50 sec"; // ここで指定した名前の列がある場合のみ動作する
// vars
vars.oldPhase = TimerPhase.NotRunning;
vars.oldSegmentIndex = -1;
// Comparisonsから決められた名前のComparisonを検索
vars.comparison = "";
foreach (string comparisonName in timer.Run.Comparisons)
{
if (comparisonName == vars.COMPARISON_NAME)
{
vars.comparison = comparisonName;
print("found \"" + comparisonName + "\" Comparison");
}
else
{
print("not found \"" + vars.COMPARISON_NAME + "\" Comparison");
}
}
// 検索結果の確認
if (vars.comparison == vars.COMPARISON_NAME)
{
for (int i = 0; i < timer.Run.Count; i++)
{
TimeSpan t = timer.Run[i].Comparisons[(string)vars.comparison].RealTime ?? TimeSpan.Zero;
print("[" + i + "] : " + t);
}
}
}
update
{
// Comparisonがなかった場合は何もしない
if (vars.comparison != vars.COMPARISON_NAME)
return false;
// タイマースタート時にとリセット時にComparisonを初期化
if ( (timer.CurrentPhase == TimerPhase.Running &&
vars.oldPhase == TimerPhase.NotRunning) ||
(timer.CurrentSplitIndex == -1 &&
vars.oldSegmentIndex != -1) )
{
print("Timer Start");
for (int i = 0; i < timer.Run.Count; i++)
{
LiveSplit.Model.Time time = new LiveSplit.Model.Time();
time.RealTime = new TimeSpan(0, 0, (i+1)*50);
timer.Run[i].Comparisons[(string)vars.comparison] = time;
}
vars.oldPhase = timer.CurrentPhase;
vars.oldSegmentIndex = timer.CurrentSplitIndex;
return false;
}
// ラップ時のComparison再計算
if (timer.CurrentSplitIndex > vars.oldSegmentIndex)
{
// 前区間までのタイムを取得
TimeSpan previousSplitTime = TimeSpan.Zero;
previousSplitTime = timer.CurrentTime.RealTime ?? TimeSpan.Zero;
print("previous split time : " + previousSplitTime);
// 現在区間以降のComparisonを再計算
int j = 0;
for (int i = timer.CurrentSplitIndex; i < timer.Run.Count; i++)
{
LiveSplit.Model.Time time = new LiveSplit.Model.Time();
time.RealTime = new TimeSpan(0, 0, (j+1)*50) + previousSplitTime;
timer.Run[i].Comparisons[(string)vars.comparison] = time;
j++;
}
vars.oldSegmentIndex = timer.CurrentSplitIndex;
return false;
}
vars.oldPhase = timer.CurrentPhase;
vars.oldSegmentIndex = timer.CurrentSplitIndex;
}
解説
実験的な asl その 2
asl で Textコンポーネントの書き換えができるなら、Livesplit 本体が持つ他の情報も書き換えられるのでは? と思ったので挑戦してみました。
この例では、ラップを取った時に「次の区間の目標タイム」=「前の区間までのタイム +50 秒」となるように Comparison のタイムを再計算しています。
スタート・ストップ・ラップなどのタイマー操作やロードリムーバの制御を行うのが asl 本来の用途なので、このような Comparison の書き換えを行うならばコンポーネントを作って行うのが本来の在り方ではあると思います。
変数の値をprint出力する、ゲーム終了でタイマーを自動リセット、ラップ済みの区間再走時にラップしない を追加
返信削除追加
返信削除タイマースタート時に初期化したい変数がある
asl で関数を使用する
追加
返信削除タイマーストップ状態でもリセットしたい
ソースコードをコピーするボタンを追加
返信削除