Posts Tagged with "space invaders"

既に発行済みのブログであっても適宜修正・追加することがあります。
We may make changes and additions to blogs already published.

メモリをBSVで書く (2)

posted by sakurai on October 2, 2026 #1133

書き換え後は、Blitter.bsvにmkPromというROMのモジュールを立て、ブリッタはそれを置いてreqとdataを呼ぶだけにします。mkPromの実体はBRAMCoreのmkBRAMCore1Loadで、引数は語数、出力レジスタの有無、ファイル名、ファイルが2進かどうかです。

import StmtFSM::*;
import FIFOF::*;
import BRAMCore::*;
// MemPort: 画面メモリ側の面。PROM は内蔵なので VRAM のポート A だけが出る
interface MemPort;
   (* always_ready *) method VAddr_t   vaddr;
   (* always_ready *) method Bool      vwe;
   (* always_ready *) method Pattern_t vdout;
   (* always_enabled *) method Action vdin(Pattern_t d);
endinterface
// ================= パターン ROM =================
// 25728 x 4 の同期 ROM。prom.mem を $readmemh で読む。req したアドレスの内容が
// 次の周期から data に出る。実体は BRAMCore ライブラリの mkBRAMCore1Load で、
// 生成 Verilog では bsc 付属の BRAM1Load.v がインスタンスされる。
// FSM とは機能が別なので、ブリッタの中に置く別モジュールにしてある。
// 語数は PROM_DEPTH で作り分けられる(テストベンチ用)。
`ifdef PROM_DEPTH
   Integer promDepth = `PROM_DEPTH;
`else
   Integer promDepth = 25728;
`endif

interface Prom_ifc;
   (* always_ready *) method Action    req(PAddr_t addr);
   (* always_ready *) method Pattern_t data;
endinterface

(* synthesize *)
module mkProm(Prom_ifc);
   BRAM_PORT#(PAddr_t, Pattern_t) rom <- mkBRAMCore1Load(promDepth, False, "prom.mem", False);
   method Action    req(PAddr_t addr) = rom.put(False, addr, ?);
   method Pattern_t data = rom.read;
endmodule
   Reg#(VAddr_t) v_addr <- mkRegU;
   Reg#(Bool) fvwe <- mkReg(False);
   Reg#(Bool) fobj  <- mkReg(False),
              fbase <- mkReg(False);
   Reg#(Pattern_t) vd <- mkRegU;
   Wire#(Pattern_t) vdata <- mkWire;

   // パターン ROM。機能は mkProm という別のモジュールで、ブリッタはそれを置いて
   // req と data を呼ぶだけ。階層図では mkBlitter の中に mkProm の箱として見える。
   Prom_ifc prom <- mkProm;
               action
                  prom.req({pack(ys)[7:0], pack(xs)[6:0]});
                  UInt#(8) y_addr = truncate(yd);
                  UInt#(8) x_addr = truncate(xd);
                  v_addr <= {pack(y_addr), pack(x_addr)};
               endaction
               noAction; // リード待ち(1サイクル)
               action
                  case (b.op)
                     BLIT_COPY:  vd <= prom.data;
                     BLIT_OR:    vd <= vdata | prom.data;
                     BLIT_ANDN:  vd <= vdata & ~prom.data;
                     BLIT_ERASE: vd <= 0;
                  endcase
                  xs <= xs + 1;
                  xd <= xd + 1;
               endaction
   interface MemPort mem;
      method VAddr_t   vaddr     = v_addr;
      method Bool      vwe       = fvwe;
      method Pattern_t vdout     = vd;
      method Action vdin(Pattern_t d); vdata   <= d; endmethod
   endinterface

アドレスを置くactionはレジスタへの代入からprom.reqに、ラッチのactionはワイヤからprom.dataに変わりました。reqしたアドレスの内容は次の周期からdataに出るので、間のnoActionを含めて1画素あたりのサイクル数は変わりません。消えたのはp_addrレジスタ、romdataワイヤ、MemPortの2ポート、mkGameFSMの同名の2ポート、master.vのROMインスタンスと2本の配線です。mkPromは(* synthesize *)付きなので生成物にmkProm.vが1つ増え、Vivadoの階層図ではmkBlitterの中にmkPromの箱が出ます。ブリッタのFSMとROMが別のモジュールであることが、字面でも階層図でも見えます。

生成されたmkProm.vの中では、bsc付属のBRAM1Load.vがFILENAMEにprom.mem、MEMSIZEに25728を与えてインスタンスされます。BRAM1Load.vの中が$readmemhなので、memの運用は今までと同じです。語数はpromDepthにあり、テストベンチのように別の語数で作りたいときは-D PROM_DEPTH=32768のように与えます。


左矢前のブログ 次のブログ右矢

メモリをBSVで書く

posted by sakurai on October 1, 2026 #1132

教科書版の手書きVerilogは、トップの配線、メモリ、クロック生成の3つでした。このうちメモリは、BSVの標準ライブラリBRAMCoreがそのまま当てはまる部分で、手書きのVerilogを残す理由がありません。パターンROM、VRAM、サウンドROMの3つをBSVのモジュールにしました。どれも初期値は今までどおりmemの$readmemhで読み、Vivadoの運用は変わりません。合成はYosysのsynth_xilinx、動作確認はVerilatorとiverilogで、前回までと同じ条件です。

置き場所を決める物差しは、機能が別なら別モジュール、というものです。FSMはROMを読む機能、ROMは内容を持つ機能で、2つは別のモジュールにします。そのうえで、ROMを読むのがそのFSMだけなら、ROMのモジュールはそのFSMの中に置き、FSMがメソッドで呼びます。パターンROMはブリッタの中に、サウンドROMはサウンドFSMの中に置きました。VRAMは2つのクロック域から共有されるので、独立のモジュールとしてトップに置いたままです。

1点目 パターンROMをブリッタの中に置く

図1132.1
図1132.1 パターンROM書き換え前後

パターンROMを読むのはブリッタだけです。それなのにROMはmaster.vにあり、ブリッタはアドレスをレジスタp_addrに置いて外に出し、読み出しデータをワイヤromdataで受けていました。ブリッタのMemPortにはprom_addrとpdinの2ポートがあり、mkGameFSMにも同じ名前のポートが素通しで並んでいました。

書き換え前

// MemPort: 画面メモリ側の面
interface MemPort;
   (* always_ready *) method PAddr_t   prom_addr;
   (* always_ready *) method VAddr_t   vaddr;
   (* always_ready *) method Bool      vwe;
   (* always_ready *) method Pattern_t vdout;
   (* always_enabled *) method Action pdin(Pattern_t d);
   (* always_enabled *) method Action vdin(Pattern_t d);
endinterface
   Reg#(PAddr_t) p_addr <- mkRegU;
   Reg#(VAddr_t) v_addr <- mkRegU;
   Reg#(Bool) fvwe <- mkReg(False);
   Reg#(Bool) fobj  <- mkReg(False),
              fbase <- mkReg(False);
   Reg#(Pattern_t) vd <- mkRegU;
   Wire#(Pattern_t) vdata <- mkWire,
                    romdata <- mkWire;
               action
                  p_addr <= {pack(ys)[7:0], pack(xs)[6:0]};
                  UInt#(8) y_addr = truncate(yd);
                  UInt#(8) x_addr = truncate(xd);
                  v_addr <= {pack(y_addr), pack(x_addr)};
               endaction
               noAction; // リード待ち(1サイクル)
               action
                  case (b.op)
                     BLIT_COPY:  vd <= romdata;
                     BLIT_OR:    vd <= vdata | romdata;
                     BLIT_ANDN:  vd <= vdata & ~romdata;
                     BLIT_ERASE: vd <= 0;
                  endcase
                  xs <= xs + 1;
                  xd <= xd + 1;
               endaction
   interface MemPort mem;
      method PAddr_t   prom_addr = p_addr;
      method VAddr_t   vaddr     = v_addr;
      method Bool      vwe       = fvwe;
      method Pattern_t vdout     = vd;
      method Action pdin(Pattern_t d); romdata <= d; endmethod
      method Action vdin(Pattern_t d); vdata   <= d; endmethod
   endinterface

master.vにはROMのインスタンスと、そこへ渡す2本の配線がありました。

   wire [14:0] prom_addr;
   wire [3:0]  prom_dout;

   mkGameFSM u_game (
      .CLK          (clk),
      .RST_N        (rst_n),
      .COUNT        (COUNT),
      .START_BUTTON (start_db),
      .LEFT_BUTTON  (left_db),
      .RIGHT_BUTTON (right_db),
      .FIRE_BUTTON  (fire_db),
      .tick         (tick),
      .empty        (fifo_empty),
      .fifo_wen     (fifo_wen),
      .SCODE        (scode),
      .prom_addr    (prom_addr),
      .pdin         (prom_dout),
      .vaddr        (vaddr),
      .vdout        (vdout),
      .vwe          (vwe),
      .vdin         (vdin),
      .Gun_Exp      (gun_exp)
   );

   rom #(.WIDTH(4), .AWIDTH(15), .DEPTH(25728), .FILE("prom.mem")) u_prom (
      .clk  (clk),
      .addr (prom_addr),
      .q    (prom_dout)
   );

左矢前のブログ 次のブログ右矢

GameFSMの結果

posted by sakurai on September 26, 2026 #1131

Vivado 2026.1で合成したゲーム全体の各モジュールの物量を表1131.1に示します。ロジックはすべてBSVで記述し、トップ配線とメモリ、クロック生成のみVerilogで書いています。論理合成はトップからのフラット合成で、物量は階層別レポートから読みました。小さなモジュールはフラット合成で親に吸収され計上されるため、その分は親の残差として示し、注記します。

表1131.1 各モジュールの物量
モジュール 説明 BSV行数 生成Verilog行数※1 物量
LUT数 LUT割合[%] FF数 FF割合[%] BRAM数
mkGameFSM 自機、インベーダ、UFO、自弾、敵弾、スコア等を処理するFSM 1,554 31,439 2,904 48.7 1,807 73.0 -
mkBlitter VRAMへの矩形転送、消去、衝突判定を実行するFSM(mkGameFSMから分離) 335 1,406 2,073 34.8 128 5.2 -
mkSoundFSM3 UFO音を演奏するFSM 261※2 1,968 215 3.6 116 4.7 -
mkSoundFSM0 自弾発射音、自機爆発音、自機増加音を演奏するFSM 1,821 201 3.4 116 4.7 -
mkSoundFSM2 インベーダ歩行音を演奏するFSM 1,823 198 3.3 109 4.4 -
mkSoundFSM1 インベーダ爆発音を演奏するFSM 1,796 193 3.2 109 4.4 -
mkGraphicFSM VGA映像信号生成回路 196 410 54 0.9 33 1.3 -
mkParaSeri サウンドパラシリ変換、タイミング生成回路 77 251 23※3 0.4 39※3 1.6 -
mkOneStage ゲームFSMとサウンドFSMの間のバッファ 62 201
mkMixer 4チャネルのサウンドミキサー 59 133
mkButtons スイッチとボタンをORする回路 36 102 85※4 1.4 0 0 -
clock クロック制御回路 - 84 11※5 0.2 19※5 0.8 -
vram ビデオRAM - 34 - - - - 8
sound_rom0 自弾発射音、自機爆発音、自機増加音を格納するROM - 26※6 - - - - 8
sound_rom3 UFO音を格納するROM - - - - - 8
sound_rom1 インベーダ爆発音を格納するROM - - - - - 2
sound_rom2 インベーダ歩行音を格納するROM - - - - - 2
pattern_rom 自機、インベーダ、UFO、自弾、敵弾、スコア等の図形を格納するROM - - - - - 4
合計 5,958 100 2,476 100 32
  • ※1 bscが生成するVerilogには、$displayによるエラー・デバッグ表示やルール衝突検知など、合成時に除去されシミュレーションでのみ働く記述が含まれる。合成対象となるのは概ね半分程度で、この行数がそのまま記述の圧縮率を表すわけではない。
  • ※2 BSVのソースは1つで、defineにより0~3のバリエーションを生成する。
  • ※3 mkParaSeri、mkOneStage、mkMixerはフラット合成で親のsoundモジュールに吸収され計上されるため、階層別レポートに個別の行が立たない。3つの合計を、soundの物量から4つのサウンドFSMを引いた残差として示す。
  • ※4 mkButtonsは親のmasterモジュールに吸収されるため、masterの残差として示す。トップ配線のグルーロジックを含む。
  • ※5 clockはトップ直下に吸収されるため、トップの残差として示す。
  • ※6 ROM(rom.v)は共通のパラメタ化モジュールで、4つのサウンドROMとパターンROMが同一ソースを共有する。

左矢前のブログ 次のブログ右矢

posted by sakurai on September 24, 2026 #1129

まとめ

同じPCで、bsc 2025.07のコンパイル時間と、Vivado 2026.1で論理合成した結果を、描画のmodule化前後で比べます。前は8/3版、後は教科書版です。論理合成はArty A7のトップからのフラット合成で、階層別レポートからmkGameFSMの値を読みました。ソース行数はGameFSM.bsvとBlitter.bsvの合計、VerilogはmkGameFSM.vとmkBlitter.vの合計です。抑止した警告数は、前がG0036とG0010、後がそれにG0009を加えた数で、8/3版にG0009は出ません。合成対象は前後ともsvnのリビジョンで固定し、生成Verilogのmd5でWindowsへ渡した実物を照合しました。

描画モジュール化前後 前 後 比較
BSV合成 ソース行数[行] 1,878 1,889 0.6%増
コンパイル時間 0:51 0:29 ▲44%
抑止した警告数 1,734 451 ▲74%
生成Verilog行数[行] 47,117 32,845 ▲30%
Verilog合成 合成時間(トップ全体) 1:10 1:11 ほぼ同じ
Vivado LUT数 5,352 4,977 ▲7.0%
Vivado FF数 1,919 1,935 0.8%増

表の各行はmkGameFSMとmkBlitterの範囲の値です。ただし合成時間だけはトップ全体の値で、フラット合成ではmkGameFSMの合成時間を単独に取り出せないためです。母数が他の行と異なる点に注意が要ります。

コンパイル時間を決める構造で見ると、前後の差はBlitterのmodule化だけです。前の版は既にRUN_FSMマクロによる切り出しを済ませており、描画の実体をmkFSMで1本に集約した状態です。後の版はその集約したBlitterを、ヒット判定まで含めて独立したmoduleに出しています。

効果はbscのコンパイル時間に集中しています。ソース行数はほぼ同じで、同じ量を書いてコンパイルが半分になりました。生成Verilogが30%減り、抑止した警告が4分の1になったのは、GameFSM側から描画とヒット判定の展開が消えた分です。

物量はLUTが7.0%減、FFが0.8%増です。モジュール化そのものは規則とレジスタの置き場所を変えるだけで論理を変えないので、物量を減らす理由を持ちません。むしろmkBlitterの境界に置いたkickとlaunchの規則、RWire、goレジスタの分だけ増える方向で、FFの0.8%増がそれです。LUTが減ったのは、前後の間に入った他の改変の効果です。2点目の呼び出し形の変更と4点目のFSM切り出しで各FSMの状態が合計418から338に減り、80状態ぶんの状態デコードと遷移条件の論理が消えました。またブリッタの矩形4種のループを1組に統合し、gfx_fsmの47状態が22になって、4種それぞれが持っていたループ制御とカウンタ更新の論理が1組に共有されました。減り方が小さいのは、減ったのが状態機械の制御部分だけで、各状態の中で行う描画や判定やゲームロジックの計算は8/3版と同じだからです。論理合成時間は動きません。

コンパイル時間が縮む機序は、ステート干渉チェックの境界での打ち切りです。同一ソースにあるとき、bscはBlitterの状態とGameFSMの状態を一緒くたに解析し、すべての状態間・ルール間の干渉を調べます。module化すると、Blitter内の解析はモジュール境界で閉じ、GameFSM側から見えなくなります。GameFSMが解析する状態空間が小さくなり、その分の解析時間が消えます。抑止した警告の数が4分の1になったことが、解析対象の縮小を裏付けます。

FFがわずかに増えるのは、module境界のインタフェースレジスタの分です。起動コマンドをRWireに載せて1サイクル後にFSMを起動する形にしたため、引数のラッチと完了・判定の戻りがレジスタとして加わりました。この1サイクルの遅延はmodule化の実行時の代償で、描画1回あたり1サイクルです。

記事#1028の改良は、呼び出しごとに展開されていた描画ループを1個のサブFSMの再利用に変え、コンパイル時間を87%減らしました。記事#1035から記事#1039は、同じRUN_FSMでdrawLivesと、本体の厚いupdateAlienBullet、updatePlayerBullet、initAllを順に切り出し、1分54秒を54秒まで縮めました。

今回の改良は、ブリッタをモジュールの境界で包んだもので、bscのコンパイル時間を同じPCで51秒から29秒に、44%減らしました。3段階とも主眼はbscのコンパイル時間で、物量は付随して動きます。LUTは各段階で減り、記事#1028で18%、記事#1037から記事#1039で3%、今回で7.0%です。FFは記事#1028で0.5%減ったあと、記事#1037から記事#1039で7%、今回で0.8%増えました。seqをFSMに切り出すたびに状態レジスタと引数のラッチが増えるためで、FSM化はFFを増やす方向に働きます。


左矢前のブログ 次のブログ右矢

posted by sakurai on September 22, 2026 #1127

4. 1箇所からしか呼ばれないseqのFSM化

seqをFSMに切り出す第一の理由は再利用で、記事#1035のdrawLivesがそれです。6箇所から呼ばれるseqを1個のFSMにまとめ、コンパイル時間を25%減らしました。記事#1036からは別の狙いで、1箇所からしか呼ばれないseqでも巨大なメインのseqから外せば競合条件の計算量が減るはず、という試みが始まります。記事#1036のdrawTitle1は1度しか呼ばれず本体も薄く、コンパイル時間が1%しか動かなかったので撤回されました。記事#1037のupdateAlienBulletと記事#1038のupdatePlayerBulletは本体が厚く、コンパイル時間がそれぞれ14%と16%、Verilogが11%と19%減って採用され、記事#1039のinitAllも同様に採用されました。つまり効くかどうかは呼び出し回数ではなく本体の厚さで決まる、というところまでが8/3版の時点で分かっていました。

ここではその続きとして、同じ基準で残っていたupdateAliens、updatePlayer、updateSaucerの3つをFSMにしました。本体の厚いseqはメインから切り出す方がbscの負担が軽い、という記事#1037と記事#1038で見えていた傾向に従ったものです。動作はVRAM書き込みの系列がサイクル番号を除いて一致しました。

教科書版の最終形

以上から、教科書版は次の2つの基準で決めました。複数箇所から呼ばれるseqは、共有するためにFSM化します。1個のインスタンスを作り、RUN_FSMマクロで各所から呼びます。メインループから呼ぶ大きなseqは、1箇所からしか呼ばれなくても、メインを小さくするためにFSM化します。仕組みは同じで、動機が違います。前者は規則の複製を消し、後者はメインFSMの状態数を減らします。モジュール境界は、42箇所から呼ばれ自前のレジスタを持つブリッタだけに使います。

seq 呼び出し箇所 扱い 置き場所
copyAreaなど描画プリミティブ6種 42 共有するためのFSM化 mkBlitter
drawLives 6 共有するためのFSM化 メイン
waitTicks 4 共有するためのFSM化 メイン
initAll 2 共有するためのFSM化 メイン
drawScores 2 共有するためのFSM化 メイン
updateAliens、updatePlayer、updatePlayerBullet、updateAlienBullet、updateSaucer 各1 メインを小さくするためのFSM化 メイン
updateBonus、checkClear、erasePlayerBulletなど小さなseq 1から5 展開のまま メイン

FSMはmkBlitterの1個とメインの9個で、メインループは各サブシステムのFSMをstartしてdoneを待つだけの短い列になります。教科書版の各FSMの状態数は次のとおりです。

FSM 状態数
メイン 90
updatePlayerBullet_fsm 54
updateSaucer_fsm 36
updateAlienBullet_fsm 33
updatePlayer_fsm 30
initAll_fsm 22
blit_fsm、mkBlitterの中 22
drawScores_fsm 18
updateAliens_fsm 18
drawLives_fsm 9
wt_fsm 6
合計 338

メインFSMの状態数は8/3版の193から90になりました。FSM全体の合計では、8/3版の418から338です。コンパイル時間と物量の前後は記事#1129の表にまとめます。


左矢前のブログ 次のブログ右矢

posted by sakurai on September 21, 2026 #1126

3. モジュール化

8/3版では、ブリッタが使う作業レジスタと画面メモリ側のレジスタが全部メインモジュールの中で宣言されていました。メインのどの規則からも読み書きできるので、実質はグローバル変数です。

書き換え前、mkGameFSMの中

Reg#(PAddr_t) p_addr <- mkRegU;
Reg#(VAddr_t) v_addr <- mkRegU;

Reg#(Bool) fvwe <- mkReg(False),
           fwen <- mkReg(False),
           fgunexp <- mkReg(False),
           fbullet <- mkReg(False),
           fobj <- mkReg(False),
           fbase <- mkReg(False),
    fempty  <- mkReg(False),
           fhit_alien <- mkReg(False);

Reg#(SoundCode_t) inscode <- mkRegU;
Reg#(Pattern_t) vd <- mkRegU;
Wire#(Pattern_t) vdata <- mkWire,
                 romdata <- mkWire;
Wire#(UInt#(3)) dipsw <- mkWire;

Reg#(UInt#(9)) xs <- mkRegU,
               ys <- mkRegU,
               xd <- mkRegU,
 yd <- mkRegU;
method Action pdin(Pattern_t in_romdata);
   romdata <= in_romdata;
endmethod
method Action vdin(Pattern_t in_vdata);
   vdata <= in_vdata;
endmethod
method PAddr_t prom_addr();
   return p_addr;
endmethod
method VAddr_t vaddr();
   return v_addr;
endmethod
method Bool vwe();
   return fvwe;
endmethod
method Pattern_t vdout();
   return vd;
endmethod

書き換え後、Blitter.bsvのmkBlitterの中

// MemPort: 画面メモリ側の面
interface MemPort;
   (* always_ready *) method PAddr_t   prom_addr;
   (* always_ready *) method VAddr_t   vaddr;
   (* always_ready *) method Bool      vwe;
   (* always_ready *) method Pattern_t vdout;
   (* always_enabled *) method Action pdin(Pattern_t d);
   (* always_enabled *) method Action vdin(Pattern_t d);
endinterface

interface Blitter_ifc;
   interface BlitPort port;
   interface MemPort  mem;
endinterface

(* synthesize *)
module mkBlitter(Blitter_ifc);

   Reg#(PAddr_t) p_addr <- mkRegU;
   Reg#(VAddr_t) v_addr <- mkRegU;
   Reg#(Bool) fvwe <- mkReg(False),
              fobj <- mkReg(False),
              fbase <- mkReg(False);
   Reg#(Pattern_t) vd <- mkRegU;
   Wire#(Pattern_t) vdata <- mkWire,
                    romdata <- mkWire;
   Reg#(UInt#(9)) xs <- mkRegU,
                  ys <- mkRegU,
                  xd <- mkRegU,
                  yd <- mkRegU;
interface MemPort mem;
   method PAddr_t   prom_addr = p_addr;
   method VAddr_t   vaddr     = v_addr;
   method Bool      vwe       = fvwe;
   method Pattern_t vdout     = vd;
   method Action pdin(Pattern_t d); romdata <= d; endmethod
   method Action vdin(Pattern_t d); vdata   <= d; endmethod
endinterface

書き換え後、mkGameFSMの中

Blitter_ifc blt <- mkBlitter;
method Action pdin(Pattern_t in_romdata);
   blt.mem.pdin(in_romdata);
endmethod
method Action vdin(Pattern_t in_vdata);
   blt.mem.vdin(in_vdata);
endmethod
method PAddr_t prom_addr();
   return blt.mem.prom_addr;
endmethod
method VAddr_t vaddr();
   return blt.mem.vaddr;
endmethod
method Bool vwe();
   return blt.mem.vwe;
endmethod
method Pattern_t vdout();
   return blt.mem.vdout;
endmethod

レジスタの宣言は一字も変わらず、置き場所だけがmkBlitterに移りました。名前はインタフェースに現れないので、メインからは触れません。これがBSVにおけるローカル変数の実体で、関数の中で実行時の状態を持つ手段が無いため、状態の局所化はモジュールの単位で行います。mkBlitterに入れたのは、VRAMとPROMのアドレスを出してデータを読み書きする部分です。矩形転送の本体と、自弾や敵弾の進路のVRAMを読むヒット判定がそれで、MemPortに出ているprom_addr、vaddr、vwe、vdout、pdin、vdinを駆動する機構がここにあります。drawLivesやdrawScoresは、copyAreaやeraseAreaを呼ぶだけで自分ではアドレスを出さないので、ゲームロジックとしてメインに残しています。

ここで、2点目のRUN_FSMマクロによる切り出しと、3点目のモジュール化の違いを押さえておきます。マクロで包むと、ブリッタの中身は人間から見えなくなります。人間はFSMが1個ずつしか動かないことを知っているので、呼び出し側の完了待ちだけを見れば中身は気にしなくてよい、と判断できます。ところがbscはその仮定を知りません。マクロは展開されてメインFSMの文に戻り、gfx_fsmも作業レジスタもmkGameFSMの中にあるままなので、bscは全部の規則の間で読み書きの順序を総当たりで調べます。マクロで切り出すとインライン展開が消えて規則の総数は減りますが、残った規則どうしの総当たりは続きます。8/3版で警告が1,734件出ていたのはそのためです。

モジュール化は、この総当たりの範囲を境界で切ります。mkBlitterに移した規則とレジスタは、mkGameFSMをコンパイルするときの解析対象から外れ、bscが見るのはblt.portのstartとdoneだけになります。総当たりの計算量は規則の数の2乗のオーダーで増えるので、規則の総数が同じでも、範囲を2つに分ければ組の数は大きく減ります。マクロ化は規則の数を減らし、モジュール化は総当たりの範囲を切る、と対比できます。人間から見えなくするのがマクロ化、コンパイラから見えなくするのがモジュール化です。

物量への効果は、この段階だけを取り出すと2点目に書いた状態数の削減と同じもので、モジュールの境界そのものはLUTを増やしも減らしもしません。モジュール化が効いたのは別の2点です。

一つはコンパイル時間で、bscのスケジューラはメインの規則とブリッタの規則の組を調べなくなり、mkBlitterは独立に数秒でコンパイルされます。抑止した警告の数がそれを裏付けます。8/3版はG0036とG0010で1,734件でした。教科書版ではG0036が127件、G0010が318件に減ります。加えて教科書版ではG0009が出ます。別のFSMの状態どうしが同じレジスタを読み書きして順序の循環ができ、bscがその組をconflictにして循環を切った、という警告で、FSMは1個ずつしか動かないので実害はありません。G0036、G0010と同じ根拠で抑止します。教科書版ではこの循環を順に解消し、G0009は6件です。合計は1,734件から451件になりました。コンパイル時間の前後は記事#1129の表にまとめます。

もう一つは、ブリッタが独立したことで内部の整理がしやすくなったことで、矩形4種のループを1組に統合しました。画素ごとの書き込み値だけが違うので、その部分だけをcaseに閉じ込めました。

action
   case (b.op)
      BLIT_COPY:  vd <= romdata;
      BLIT_OR:    vd <= vdata | romdata;
      BLIT_ANDN:  vd <= vdata & ~romdata;
      BLIT_ERASE: vd <= 0;
   endcase
   xs <= xs + 1;
   xd <= xd + 1;
endaction

ブリッタ部分の規則数は8/3版の37から教科書版の14に減りました。規則数は、生成Verilogの中でWILL_FIRE_RLの信号として現れる規則の数です。8/3版はmkGameFSMの中のgfx_fsmに属する規則、教科書版はmkBlitter全体の規則を数えました。


左矢前のブログ 次のブログ右矢

posted by sakurai on September 20, 2026 #1125

2. サブFSMの起動

サブFSMを1個だけインスタンスし、呼び出し側はstartとdoneだけを触る、という構造は8/3版と同じです。RUN_FSMマクロも、メインに置くwt_fsmやinitAll_fsmなど9個のFSMでは今も使っています。変わったのはブリッタの呼び方で、引数のラッチと起動が呼び出し側の2つの文からモジュールの中に移りました。

書き換え前

Reg#(Blit) blit_arg      <- mkReg(Blit{
  op:  BLIT_COPY,   // 無難なデフォルト
  sx:  0,  sy:  0,
  dx:  0,  dy:  0,
  w:   0,  h:   0,
  col: 0
});   // blit 用の引数保持レジスタ

FSM gfx_fsm <- mkFSM(blit(blit_arg));

// RUN_FSM: サブFSM 起動マクロ(start → done 待ち)。seq - endseq 内で呼ぶこと
`define RUN_FSM(F) action F.start(); endaction await(F.done);

// runBlit: gfx_fsm の起動ラッパ
//   引数を blit_arg にラッチしてから gfx_fsm を start し、done を待つ。
function Stmt runBlit(Blit b);
  return (seq
    action blit_arg <= b; endaction
      seq
        `RUN_FSM(gfx_fsm)   // start→done
      endseq
  endseq);
endfunction // runBlit

  function Stmt copyArea(U8 sx, U8 sy, U8 dx, U8 dy, U9 w, U9 h);
    return runBlit(Blit{op:BLIT_COPY, sx:sx, sy:sy, dx:dx, dy:dy, w:w, h:h, col:0});
  endfunction // copyArea

書き換え後、Blitter.bsv側

// BlitPort: ブリッタの利用者に見せる面
interface BlitPort;
   method Action start(Blit b);     // コマンド投入(1 サイクル後に FSM 起動)
   method Bool   done;              // 完了(コマンド未投入時も True)
   method Bool   obj;               // 直前の VDOTS/HDOTS でスプライト画素あり
   method Bool   base;              // 直前の VDOTS/HDOTS でシールド画素あり
endinterface

// runBlitOn と同名ラッパ群の On 版: BlitPort を第 1 引数に取る。
// 各モジュールでは let copyArea = copyAreaOn(port); のように部分適用して使う。
function Stmt runBlitOn(BlitPort p, Blit b);
  return (seq
    p.start(b);
    await(p.done);
  endseq);
endfunction // runBlitOn
function Stmt copyAreaOn(BlitPort p, U8 sx, U8 sy, U8 dx, U8 dy, U9 w, U9 h);
  return runBlitOn(p, Blit{op:BLIT_COPY, sx:sx, sy:sy, dx:dx, dy:dy, w:w, h:h, col:0});
endfunction // copyAreaOn
FSM blit_fsm <- mkFSM(blitDispatch(blit_arg));

   // ---- ブリッタの起動 ----
   // port.start はコマンドを RWire に載せるだけで、同じサイクルに kick が引数を
   // blit_arg にラッチし、次のサイクルに launch が blit_fsm を起動する。
   // 元の runBlit(引数ラッチ → start → done 待ち)と同じサイクル数になる。
   Reg#(Bool)   go  <- mkReg(False);
   RWire#(Blit) cmd <- mkRWire;

   rule kick (cmd.wget matches tagged Valid .b);
      blit_arg <= b;
      go <= True;
   endrule

   rule launch (go);
      go <= False;
      blit_fsm.start;
   endrule

   interface BlitPort port;
      method Action start(Blit b) if (!go && blit_fsm.done);
         cmd.wset(b);
      endmethod
      method Bool done  = !go && blit_fsm.done;
      method Bool obj   = fobj;
      method Bool base  = fbase;
   endinterface

書き換え後、GameFSM.bsv側

 Blitter_ifc blt <- mkBlitter;

// 原実装と同名の薄ラッパ群: Blitter.bsv の On 版に blt.port を部分適用
let copyArea    = copyAreaOn(blt.port);
let orArea      = orAreaOn(blt.port);
let eraseArea   = eraseAreaOn(blt.port);
let eraseAreaSP = eraseAreaSPOn(blt.port);
let readVdots   = readVdotsOn(blt.port);
let readHdots   = readHdotsOn(blt.port);

呼び出し箇所のcopyAreaの書き方は変わりません。変わるのは展開結果で、8/3版では呼び出し1箇所が「ラッチ、start、await」の3状態に展開されていたのが、教科書版では「start、await」の2状態になります。ラッチと起動の2サイクルはモジュールの中のkickとlaunchが担うので、動作のサイクル数は同じです。Verilatorで1,000万サイクルのVRAM書き込み系列を比べ、サイクル単位で一致することを確認しました。

生成Verilogで見ると、呼び出し箇所の規則はこう変わります。書き換え前は1箇所につきラッチの規則とawaitの規則が別々にあります。

// rule RL_action_l731c20_12       ← blit_arg <= b のラッチ
assign WILL_FIRE_RL_action_l731c20_12 =
    NOT_wt_fsm_jj_repeat_count_read__86_EQ_0_87_88_ETC___d895 &&
    !wt_fsm_start_reg &&
    state_mkFSMstate == 8'd46 ;

// rule RL_action_l731c59_9        ← await(gfx_fsm.done)
assign WILL_FIRE_RL_action_l731c59_9 =
    blit_arg_2_BITS_56_TO_54_3_EQ_0_4_AND_gfx_fsm__ETC___d954 &&
    state_mkFSMstate == 8'd43 ;

書き換え後はstartの規則とawaitの規則だけで、doneはモジュールの出力ポート1本になります。

// rule RL_action_l55c6_1          ← blt.port.start(b)
assign WILL_FIRE_RL_action_l55c6_1 =
    blt$RDY_port_start && str_idx_129_ULT_24___d5130 &&
    (state_mkFSMstate == 7'd3 || state_mkFSMstate == 7'd6) ;

// rule RL_action_l56c10_1         ← await(blt.port.done)
assign WILL_FIRE_RL_action_l56c10_1 =
    blt$port_done && state_mkFSMstate == 7'd4 ;

8/3版と教科書版で、各FSMの状態数は次のように変わりました。状態数は、生成Verilogの中で状態レジスタとの比較に現れる値の種類を数えたものです。教科書版の値には、4点目でメインからupdateAliens、updatePlayer、updateSaucerを切り出した効果と、3点目でブリッタの矩形4種のループを1組に統合した効果も含みます。教科書版ではgfx_fsmをblit_fsmに改名し、mkBlitterの中に置いています。

FSM 8/3版 教科書版 比較
メイン 193 90 ▲103
updatePlayerBullet_fsm 64 54 ▲10
updateAlienBullet_fsm 41 33 ▲8
initAll_fsm 28 22 ▲6
drawLives_fsm 13 9 ▲4
drawScores_fsm 26 18 ▲8
gfx_fsm、教科書版ではblit_fsm 47 22 ▲25

メリットをまとめると、呼び出し側からblit_argとgfx_fsmが消えてstartとdoneだけを見ればよくなったこと、そして呼び出し箇所ごとの状態が1つ減ったこと、の2つです。


左矢前のブログ 次のブログ右矢

posted by sakurai on September 19, 2026 #1124

ChatGPT版との差分3点

比較の前は8/3版で、記事#1026から記事#1028でChatGPTが描画関数を1個のサブFSMにまとめてstartとdoneで呼ぶ形にし、そのとき作ったRUN_FSMマクロを使って記事#1035から記事#1039でdrawLives、updateAlienBullet、updatePlayerBullet、initAllを順にFSMにした状態を指します。Claude版はその構造を保ったまま3箇所を書き換え、その上で記事#1035から記事#1039のFSM化を残りのseqに進めたものを4点目としました。以下、各点について書き換え前後のコードと、生成Verilogで確認できる差を示します。数値は、bscのコンパイル時間とVivado 2026.1のxc7a35t向けOOC合成結果を同じPCで測ったもので、LUTとFFにYosysと断ったものは手元の概算です。コードは教科書版に揃えてあります。

1. 文字列テーブル

ChatGPT版はビット連接でBit型の定数を作り、unpackしてVectorに戻していました。連接では先頭の要素が最上位ビット側に置かれ、unpackでは最下位ビット側が要素0になるので、順序を戻すreverseが必要でした。

書き換え前

   // str_over: "GAME_OVER"
   Bit#(SizeOf#(Vector#(9, Glyph))) str_over_bits = {
     pg(  2,186, 89,59, 5, 7), // G
     pg( 10,186, 97,59, 5, 7), // A
     pg( 18,186,105,59, 5, 7), // M
     pg( 26,186,113,59, 5, 7), // E
     pg( 34,186,121,59, 5, 7), // _
     pg( 42,186,129,59, 5, 7), // O
     pg( 50,186,137,59, 5, 7), // V
     pg( 58,186,145,59, 5, 7), // E
     pg( 66,186,153,59, 5, 7)  // R
   };
   Vector#(9, Glyph) str_over = reverse(unpack(str_over_bits));

書き換え後

   // str_over: "GAME_OVER"
   Glyph str_over[9] = {
     mkGlyph(  2,186, 89,59, 5, 7), // G
     mkGlyph( 10,186, 97,59, 5, 7), // A
     mkGlyph( 18,186,105,59, 5, 7), // M
     mkGlyph( 26,186,113,59, 5, 7), // E
     mkGlyph( 34,186,121,59, 5, 7), // _
     mkGlyph( 42,186,129,59, 5, 7), // O
     mkGlyph( 50,186,137,59, 5, 7), // V
     mkGlyph( 58,186,145,59, 5, 7), // E
     mkGlyph( 66,186,153,59, 5, 7)  // R
   };

左辺を配列型で宣言すると{ ... }は配列の初期化子として解釈され、先頭に書いた要素が添字0になります。pgとpack、unpack、reverseは要りません。mkGlyphはChatGPT版にもあった構造体のコンストラクタで、そのまま使っています。

生成Verilogは、行番号に由来する内部識別子を正規化するとdiffが0行でした。LUTとFFは増減なしです。定数の並べ方は静的展開の前にしか存在せず、展開後の回路には残りません。


左矢前のブログ 次のブログ右矢

GameFSMの改良 (25)

posted by sakurai on August 6, 2026 #1100

次に原作にもある「化石」という症状をインプリしてもらいました。

化石の動画

仕様: 化石の再現 隊列がシールド帯まで降下した状態で、左端での下降パス中に自弾が右端シールドに命中すると、これを個体命中と誤認する。シールド内の弾の位置に敵爆発音と敵爆発マークを発生させ、マーク消去時にシールドへ 16×8 の穴を開ける一方、実際に死ぬのは弾の真上とは別の列の最前列個体である。その個体の画像は消去されないまま画面に残り、以後は敵として撃てず、隊列だけが先へ進む。

発症条件の幾何定数 — 隊列の降下判定しきい値と、右端シールドの x 範囲。

`ifdef FOSSIL_BUG
`define FOSSIL_BUG_Y 176   // 原作バグ発症条件: 隊列がシールド上端(192)の1ピッチ以内                            
`define FOSSIL_BUG_X 182   // これ以右のシールド命中で発症(=右端シールド全体、x=182〜204)                       
`endif

爆発マークの描画・消去位置を保持する — 犠牲個体の座標から切り離すことで「マークと犠牲が別の場所」という原作バグの本質を表現可能にする。

`ifdef FOSSIL_BUG
   Reg#(UInt#(8)) expl_x <- mkRegU,   // インベーダ爆発マークの描画・消去位置                                   
                  expl_y <- mkRegU;   // (バグ再現時は個体位置とマーク位置が食い違う)                           
`endif

常キル時にマーク位置=個体位置を expl_x/expl_y へラッチする(挙動は従来と同一の中立改造)。

`ifdef FOSSIL_BUG
               eraseArea(
               expl_x,
               expl_y,
               16, 8);
`else
               eraseArea(
               inv_x[gx][gy],
               inv_y[gx][gy],
               16, 8);
`endif

マーク消去を個体座標ではなく expl_x/expl_y に対して行う(バグ時はシールド内を消して穴を開ける)。

`ifdef FOSSIL_BUG
      return (seq
         action
            alien_timer <= 1;
            expl_x <= inv_x[gx][gy];   // マーク位置=個体位置(通常キル)                                         
            expl_y <= inv_y[gx][gy];
         endaction
         copyArea(96, colorAtY(inv_y[gx][gy])*16+16,
                  inv_x[gx][gy], inv_y[gx][gy],
                  16, 8);
      endseq);
`else
      return (seq
         alien_timer <= 1;
         copyArea(96, colorAtY(inv_y[gx][gy])*16+16,
                  inv_x[gx][gy], inv_y[gx][gy],
                  16, 8);
      endseq);
`endif

発症4条件成立時のシールド命中を誤認キル化する本体 — マークと音は弾の位置、死ぬのは左端側の生存列の最前列、絵は残って化石になる。

`ifdef FOSSIL_BUG
               endseq else if (fbase && ymax >= `FOSSIL_BUG_Y
                               && inv_move == MoveLeftDown
                               && bullet_x >= `FOSSIL_BUG_X) seq
                  // ---- 原作バグの再現(希少条件版) ----                                                       
                  // 右→左で来て左端で下降中(=MoveLeftDown、隊列スキュー中)、                                  
                  // かつ隊列がシールド帯まで降りた状態で右端シールドを撃つと、                                 
                  // 原作の座標→個体逆算の破綻(左下原点からの列計算オーバー                                    
                  // フローが隣行へ回り込む)を模して、左端の生存個体が誤って                                    
                  // 殺される。マークと消去は弾の位置、犠牲の絵は左端に無傷で                                   
                  // 残り、隊列に取り残される(化石)。                                                           
                  fhit_alien <= False;
                  for (gx <= 0; gx < `InvCols; gx <= gx + 1) seq
                     if (inv_s[gx][inv_lead_y[gx]]) seq   // 左端の生存列を探す                                 
                        fhit_alien <= True;
                        break;
                     endseq
                  endseq
                  if (fhit_alien) seq
                     gy <= inv_lead_y[gx];
                     erasePlayerBullet(bullet_x, bullet_y);
                     inv_s[gx][gy] <= False;
                     inv_no <= inv_no - 1;
                     updateColumnFrontY();
                     playSound(SND_ALIEN_EXPLODE);
                     action                        // マークは弾の位置に描く                                    
                        alien_timer <= 1;
                        expl_x <= bullet_x - 8;
                        expl_y <= bullet_y;
                     endaction
                     copyArea(96, colorAtY(bullet_y)*16+16,
                              bullet_x - 8, bullet_y, 16, 8);
                     action
                        score <= score + inv_score[gy];
                        bscore <= bscore + inv_score[gy];
                     endaction
                  endseq else seq
                     erasePlayerBullet(bullet_x, bullet_y);   // 生存列なし: 通常のシールド命中                 
                     explodePlayerBullet(35, bullet_x, bullet_y);
                     bullet_timer <= 1;
                  endseq
`endif

左矢前のブログ 次のブログ右矢

GameFSMの改良 (24)

posted by sakurai on August 3, 2026 #1099

最近は推論能力が高いため、もっぱらClaude Fable 5を使用しています。さてCaludeに以下の仕様を実装してもらいました。

「自弾がインベーダに命中すると、爆発マークの表示期間(ALIEN_EXPL_TMAX tick)のあいだ隊列全体の移動を停止する。停止中も自機・自弾・敵弾・UFO・サウンドの処理は継続し、爆発マークの消去とともに、停止した個体から隊列の移動を再開する。」

これはオリジナル動作を観察して発見した仕様です。理由は不明ながら、爆発マーク表示中も隊列を動かすと、インベーダの動きにより爆発マークが欠ける場合があり、その干渉を防止するためと考えられます。

            for (noy <= 0; noy < `InvRows; noy <= noy + 1) seq
               // 爆発凍結中はカーソルを進めない(増分が no-op になる)
               for (nox <= 0; nox < `InvCols;
                    nox <= (alien_timer == 0) ? nox + 1 : nox) seq
                  if (inv_s[nox][noy] || alien_timer != 0) seq
                     if (alien_timer == 0)
                        updateAliens();
                     else
                        eraseAlienExplosion();
                     updatePlayer();
                     ...(以下、元のまま)...
                  endseq  // if inv
               endseq // for nox
            endseq // for noy

左矢前のブログ 次のブログ右矢


ページ: