タイトル画面の黒帯が 1 tick で消える — ActionScript とタイムラインの優先順位セマンティクスを掘り当てるまで

| 開発記録 | ギリギリジャンパー

タグ: #Flutter #Flash #SWF #ActionScript #デバッグ #テスト

自作の Flash シミュレータで、ガラケーゲームのタイトル画面の黒帯が一瞬で消えました。「白文字が不可視なだけ」という誤仮説に丸一日沈みます。毎秒スクショの証跡とレンダーツリーの逆引きで、ActionScript が変形したスプライトを後方 goto が巻き戻していたと突き止めるまでの開発記録です。

予告していた「もう一本」の話

先日の記事「Flash Lite のガラケーゲームを Flutter で復刻する」の終盤で、開発中に踏んだ大きなバグを 2 つ挙げました。「別途詳しく書く」と予告したままにしていたものです。今回はそのうちの 1 つ、スクリプトとタイムラインの優先順位問題の顛末です。シミュレータ開発でいちばん深いところに沈んでいたバグでした。

症状はこうです。「ギリギリジャンパー」シリーズの 1 本をシミュレータで起動します。タイトル画面に出るはずの黒い帯が表示されません。操作説明の白文字の背景になっている帯です。実機のガラケーでは確かに表示されていました。

黒帯は SWF の中に「存在しない」

まず SWF ファイルを静的に解析しました。タイトル画面のフレームに配置されているのは、黒文字のテキストが 1 つと白文字のテキストが 2 つ。黒帯に相当するシェイプが SWF のどこにもないのです。

次にシミュレータの表示リストをヘッドレスで 60 秒ぶん監視しました。どのオブジェクトがどの深度に置かれているかの台帳です。タイトル画面のオブジェクトは安定して存続していました。「フレーム進行のバグで何かが消えている」線はシロ。そう判断しました。

誤仮説 — 「白文字が白背景で見えないだけ」

黒帯のシェイプは存在しない。表示リストも壊れていない。ここから立てた仮説がこれです。

「白文字のテキストが白背景に描かれて見えないだけではないか。実機の Flash Lite はデバイスフォントで白文字を描くとき、端末側が背景を塗る仕様だったのではないか」

つまり「消えるバグ」ではなく「最初から描かれていない、描画層の忠実度問題」だと問題を再定義したわけです。調査の引き継ぎメモにもそう記録しました。対策として「白文字テキストの背後に帯を描く互換パッチ」の実装まで進めています。いま読み返すと、観測事実に都合よく筋を通しただけの仮説です(この日は丸一日ここに沈んでいました)。当時は「黒帯シェイプが SWF 内に無い」以上、これが一番もっともらしい説明に見えていました。

このとき並行して、実アプリ環境での証跡も残しました。対象ゲームを直接起動して、毎秒スクリーンショットを保存するだけの一時的なエントリポイントです。

// 調査用一時エントリポイント: 毎秒スクリーンショットを保存する
// 使い方: flutter run -t lib/main_probe.dart -d macos
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _capture());

Future<void> _capture() async {
  final boundary = _boundaryKey.currentContext?.findRenderObject()
      as RenderRepaintBoundary?;
  if (boundary == null) return;
  final image = await boundary.toImage();
  final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
  image.dispose();
  if (bytes == null) return;
  final n = _shot.toString().padLeft(3, '0');
  File('$_probeDir/probe_$n.png').writeAsBytesSync(bytes.buffer.asUint8List());
  // 以下省略
}

ログと違って、スクリーンショットは「そのとき画面がどうだったか」を後から何度でも読み直せます。この地味なツールが、翌日に効いてきます。

仮説が崩れた日

転機は、別系統の AI(Codex)に相談したことでした。「Flash Lite 1.x 実機セマンティクスの専門家」という役割を振っています。返ってきた答えは明快でした。

SWF 仕様に、白文字への自動的な黒背景描画は存在しない。実機の写真と照合すべき

これを受けてスクリーンショットの証跡をコマ送りで見直しました。決定的な事実が写っています。黒帯は最初の 1 コマだけ表示されている。「最初から見えない」のではありません。「一瞬表示されて消えている」。最初に疑って捨てたはずの「消えるバグ」でした。

こうなると打つ手は変わります。タイトル画面の 2 フレームでレンダーツリーの全ノードをダンプしました。帯の領域(y150〜185px)に掛かるシェイプを座標から逆引きします。浮かび上がったのは意外な相手でした。ゲーム中で使う「足場」のスプライトです。

20 年前の自分が仕掛けたトリック

SWF4 世代の ActionScript バイトコードを、自作の逆アセンブラで読みました。タイトルフレームのスクリプトはこう動いています。

  • 足場スプライト(黒い矩形)を _x=0, _y=140 へ移動
  • _xscale=300 で横に 3 倍引き伸ばす

黒帯の正体は、ゲームの足場部品を横に引き伸ばして、白文字の背景に転用したものでした。専用のシェイプを追加せず、手持ちの部品を使い回す。ガラケー時代の容量制約らしいオーサリング上のトリックです。仕掛けたのは 20 年前の自分のはずですが、まったく覚えていませんでした。

静的解析の「黒帯シェイプは存在しない」という結論は、半分正しくて半分間違いです。帯は図形としては存在しない。でも実行時にスクリプトが作り出していました。

真因 — 後方 goto がスクリプトの変形を巻き戻す

では、なぜ 1 コマで消えるのか。

このゲームのタイトル画面は 2 フレームのループです。後ろのフレームに到達すると GoToLabel で 1 つ前のフレームへ戻ります。それを繰り返してキー入力を待つ。この「後方への goto」が犯人でした。

タイムラインを過去へ巻き戻すとき、シミュレータは先頭フレームから対象フレームまでの配置タグをシミュレートします。「あるべき表示リスト」を作って、現状とすり合わせる。修正前の実装は、存続しているオブジェクトの座標行列をタイムライン上の配置値で無条件に上書きしていました。

// 修正前: 存続インスタンスの行列をタイムライン値で無条件上書き
if (existing != null && existing.character.id == sim.characterId) {
  if (sim.matrix != null) existing.matrix = sim.matrix!;
  if (sim.cxform != null) existing.cxform = sim.cxform!;
}

スクリプトが「x=0・横 3 倍」に変形した足場は、ループが一周した瞬間に巻き戻されます。タイムライン上の元の配置、つまり x=280・等倍へ。ステージの幅は 240px なので、x=280 は画面外です。黒帯が「1 tick だけ表示されて消える」現象の正体でした。

後方 goto による巻き戻しの 3 段階。タイムラインの配置値では足場は x=280 で画面外、スクリプトの変形で x=0・横 3 倍の黒帯になり、後方 goto の再構築で配置値に上書きされて再び画面外へ
図: 足場スプライトが黒帯になり、ループの後方 goto で画面外へ巻き戻される流れ(ステージ幅 240px)

前日の調査で見逃した理由もはっきりしました。監視ツールが表示リストの「在籍」だけをチェックして、座標を見ていなかったからです。オブジェクトは消えていない。ただ画面の外へ飛んでいただけ。「表示リストは安定して存続」という観測そのものは正しくて、そこから引いた結論が間違っていました。

修正 — transformed_by_script セマンティクス

本物の Flash Player はこう動きます。ActionScript が一度でも動かしたオブジェクトの属性は、そのインスタンスが存続する限りタイムラインが上書きしない。オープンソースの Flash 再実装である Ruffle にも transformed_by_script という同種のフラグがあります。Flash 再実装の通過儀礼のような問題だと、あとから知りました。

修正は 2 段構えです。まず、スクリプトのプロパティ書き込みでフラグを立てます。

// スクリプトによる変更を記録し、以後タイムラインの MOVE タグ・
// 後方 goto 再構築による上書きを禁止する(Flash 実機セマンティクス)。
// 行列と cxform(透明度など)は独立に保護する。
const matrixProps = {0, 1, 2, 3, 10}; // _x/_y/_xscale/_yscale/_rotation
if (matrixProps.contains(index)) {
  placement.matrixTransformedByScript = true;
} else if (index == 6) { // _alpha
  placement.cxformTransformedByScript = true;
}

そして MOVE タグの適用と後方 goto の再構築で、フラグの立った属性を上書きしないようにします。

// インスタンス存続: タイムライン指定の属性のみ反映
// (スクリプト変更済みの行列・cxform はそれぞれ独立に維持する)
if (!existing.matrixTransformedByScript && sim.matrix != null) {
  existing.matrix = sim.matrix!;
}
if (!existing.cxformTransformedByScript && sim.cxform != null) {
  existing.cxform = sim.cxform!;
}

初版の保護フラグは 1 本でした。AI レビューの指摘を受けて 3 点を作り込んでいます。

  • 行列と cxform の保護を分離する。1 本のままだと _x を一度動かしただけで、タイムライン側のフェード演出まで止まってしまうため
  • インスタンスのライフタイム判定を追加する。削除や差し替えを挟んだ「別人」のオブジェクトに、前任者のスクリプト変形を引き継がないため
  • スプライト複製時は保護状態も複製する。変形済みオブジェクトのクローンだけがタイムラインに巻き戻されるのを防ぐため

大事にしたのは、これを「このゲーム専用のパッチ」にしないことでした。シミュレータ全体の忠実度修正として実装しています。セマンティクスの欠落はどのタイトルでも起こりえます。対症療法にしていたら、いずれ別のゲームで別の顔をして再発していたはずです。

二度と消えないことをピクセルで見張る

回帰テストは「表示リストに居るか」では書きませんでした。「実際に黒く塗られているか」を検証します。同じ穴に二度落ちたくないからです。

// タイトル到達後、60 秒相当(15fps × 900 tick)ループを回し、
// 10 秒ごとに帯領域の暗いピクセル数を検証する
for (var t = 1; t <= 900; t++) {
  stage.advanceFrame();
  if (t % 150 == 0) {
    expect(await darkPixelsInBand(), greaterThan(1000),
        reason: 't=${t ~/ 15}s時点で黒帯が消えてはいけない');
  }
}

RepaintBoundary から生のピクセルを取り出し、帯領域の RGB がすべて 50 未満の「暗いピクセル」を数えるだけです。素朴な方法ですが、「在籍しているのに画面外」にも「描画されているのに白い」にも反応します。観測の網をバグの一段外側に張っておく、という考え方です。

学び

誤仮説を断定形で記録しない。 一番堪えたのはこれでした。調査メモに「描画層の忠実度問題と再定義した」と断定で書いています。そのせいで翌日の調査は、その前提から再開してしまいました。仮説には仮説というラベルを付けて記録する。実際にそれを崩してくれたのは、前提を共有していない外部の視点でした。

観測ツールは「何を見ていないか」を疑う。 「表示リストは安定していた」という観測は正しかったです。でも座標という決定的な変数が視野の外にありました。観測にもとづく消去法は、観測の網羅性の上にしか成り立ちません。

先人と同じ答えに辿り着く。 修正後に Ruffle へ同種のフラグがあると知ったときは、遠回りをしたような、答え合わせができたような気分でした。ただ、自分の実装で問題を踏んで、必然性を理解してから知った仕様です。最初からドキュメントで読んだ仕様とは、定着がまるで違います。20 年前の自分が仕掛けたトリックに、20 年後の自分のシミュレータが引っかかる。復刻という営みの面白さが詰まった 2 日間でした。当時の自分に文句を言いたいところですが、たぶん同じ手をまた使います。