Dynamic Waters 11.1.x のサーバ専用アドオンです。本体を置き換えるものではなく、両方が要ります。 やることは2つあります。
- 山岳河川づくりをサーバスレッドから外し、組んだ結果をディスクに残す(1.x〜2.x)
- 川のそばの構造物を消すのをやめ、代わりに水の少ない場所へ横へずらす(3.0.0)
- Minecraft 1.20.1 / Forge 47.x / Java 17
- 依存:
dynamicwaters[11.1,12) - ライセンス: MIT
Dynamic Waters は ChunkWatchEvent.Watch の中で、見えたチャンクの周り±2リージョン
(1リージョン=256ブロック四方)の山岳河川を同期で引き直します。参加時は視界ぶんの
チャンクが一斉に発火するので、約36リージョンをプレイヤーを配置する前に作りきることに
なります。しかも記録はメモリ上にしか無いので再起動のたびに冷え、最初に入った1人が
全員ぶんを肩代わりします。その人からは「キャラが落下し続け、やがてタイムアウトする」
ように見えます。
実測(専用サーバ・再起動直後に参加・RCON の応答遅れで測定):
| 構成 | サーバスレッドが塞がった時間 |
|---|---|
| 素のまま(記録が冷えている) | 231,451 ms |
| 素のまま(記録が温まっている) | 7,909 ms |
enableMountainRivers = false(機能を捨てる) |
0 ms |
| どこ | 何をするか |
|---|---|
DynamicWaters$ModEvents.onChunkWatch |
mixin で中止し、同じ仕事をワーカースレッドで行う。できたぶんからパケットを送る |
DynamicWaters$ModEvents.onPlayerLogin |
参加のたびに出る互換案内のチャットだけを落とす(下記・2.2.0) |
HydrologyManager.getRiverFlow |
cachedOnly を true に固定する。専用サーバでは false を渡す作りで、毎tick その場で作りにいっていた |
| 起動時(無人のあいだ) | ワールドボーダーの内側を中心から順に先読みして記録を埋める。人が居る間は止まる |
| 全体 | 組んだリージョンをワールド配下のファイルに残し、次の起動から読み直す |
⚠ わざと触っていないもの: calculateRiverCarving。同じ重い計算に入りますが
地形を彫る側なので、短絡すると生成される地形が変わります。
⚠ isNearRiver / isTooCloseToRiver は 2.3.0 から短絡しています(構造物の削除を止めるため)。
どちらの唯一の呼び出し元も StructureMixin で、彫る経路からは呼ばれないことを
dynamicwaters-11.1.2.jar を読んで確かめてあります。ずらし先を探すときだけ、
スレッドローカルの旗を立てて本当の答えを返させています。
<ワールド>/data/asyncrivers/v1/<次元>/r.<x>.<z>.bin
1リージョン1ファイル。各ファイルの先頭に印を持たせ、合わないものは読み飛ばして 作り直します。印に混ぜているのは3つです。
- ワールドの seed
- Dynamic Waters の版 — 本体のフィールドをそのまま直列化しているので、上流の構造が 変われば書式も変わります
- その config ファイルの中身 — 川幅や山岳河川の有無が生成物を変えます。 ⚠ わざと粗くしてあり、関係ない設定を触っても作り直しになります。作り直しは時間を 失うだけですが、地形と食い違う経路を返すのは正しさを失うので、時間のほうを払います
書き込みは一時ファイルからのアトミック移動なので、落ちても「古いファイル」か「無い」の どちらかになり、半端なものは残りません。読めないファイルは「無い」と同じ扱いです。
⚠ 一番効くのはここです。 ディスクから戻した結果は Dynamic Waters 本体のメモリ キャッシュへ入れ直します。そのキャッシュは地形を彫る処理(ワールド生成スレッド)も 読むので、参加が速くなるだけでなくチャンク生成そのものが速くなります。
2.0.0 まで、ディスクへ書いていたのはこのMODが自分で頼んだぶんだけでした。 起動時の先読みと、プレイヤーが歩いて入った場所です。
地形を彫る処理はそこを通りません。 calculateRiverCarving はワールド生成スレッドから
Dynamic Waters 本体の getMountainRegionPaths を直接呼びます。無人での事前生成では
それがほぼ全てで、作られたリージョンは 2,048 件のメモリキャッシュに載っては追い出され、
一度もディスクに届かないまま消えていました。次の起動でまた作り直しです。
2.1.0 で getMountainRegionPaths の戻りに観測者を1つ置きました。
全ての呼び出しがここを通るので、彫り込みも先読みも参加時も毎tickの水流も、
作られたものは残ります。
⚠ この観測者は純粋な観測者であり、そのままにしておく必要があります。
戻り値を読んで書き残すだけで、引数を取らず、打ち切りもせず、何も返しません。
だから「そのリージョンを作るかどうか」の判断に関与できません——
彫り込みは cachedOnly = false で頼むのが正しく、そこを短絡すると彫られる地形が変わります。
⚠ 費用について。この観測者はキャッシュに当たった呼び出しでも走るので、 やることが無いときは限りなく安くある必要があります。「もうディスクに在る」の判定は メモリ上の集合で答えます。ファイルを触るのは、そのリージョンを初めて見たときだけです。
onPlayerLogin は3つのことをします。前の2つは残し、3つ目だけを落とします。
SENT_REGIONSにその人の枠を作る — 残す- シードと海面高さのパケットを送る — 残す(これが無いとクライアント側の川が正しく描かれません)
- 相手MODが入っていれば互換案内をチャットへ流す — 落とす
当部は Tectonic を入れているので、毎回2行出ていました。
[Dynamic Waters] Compatibility with mod Tectonic is still beta.
[Dynamic Waters] Tectonic underground rivers were automatically disabled for better performance.
⚠ 設定では消せません。 この4件(Tectonic 2・Lithosphere 1・Larion 1)は
ModList.isLoaded(...) だけで分岐しており、その経路に config の読み出しが1つもありません
(dynamicwaters-11.1.2.jar を逆アセンブルして確認)。
⚠ 処理ごと中止してはいけません。 2 が同じメソッドの中で先に走っているためです。
だから @Redirect で文字を送る呼び出しだけを無効にしています。
⚠ 書いてある内容は本当のままです。 挙動は何も変えておらず、知らせ方だけを変えています。
地下河川を自動で切る設定(disableTectonicUndergroundRivers)は入のままです。
⚠ require = 4 で件数を固定しています。 上流が案内を増減させたら読み込み時に大きく失敗
します。黙って宣伝が戻るより、止まって気づくほうを選びました。版の範囲を縛っているのは
これを見落とさないためです。
Dynamic Waters は川のそばに出来た構造物を丸ごと消します(StructureMixin.cancelStructureInRiver)。
種 1704108117075065668 を半径1000(22,201チャンク)で焼いて実測した代償:
| 設定 | 構造物の起点 | 村 |
|---|---|---|
| 素のまま(消す) | 105 | 2 |
keepStructuresNearRivers = true(消さない) |
144 | 5 |
同じワールドで山岳河川が占める面積は 1.19% です。消す範囲は構造物自身の半径ぶん広がるので、
いちばん大きい村がいちばん失われます。⚠ 縦方向が ±70 ブロックなのも効いていて、
地下60ブロックの小屋が、触れようのない川に消されていました(alexscaves:underground_cabin が10件)。
⚠ 消さなくても構造物は守られます。 NoiseChunkGeneratorMixin.carveAndFillRiver が
startsForStructure で全ての起点を集め、柱ごとに BitSet[256] を作って彫るブロック書き込みを
1つずつ止めています。つまり削除は保護ではなく、ただの除去でした。
⚠ どこへ動かしても良くならなければ、その場に残します。消すことはありません。
判定は4つ試して、3つを実測で否定しました。
| 版 | 何を聞いたか | なぜ駄目だったか |
|---|---|---|
| 2.4 | isTooCloseToRiver に箱を渡す |
⚠ この関数は箱の9点しか見ない({minX, maxX, 中点} の 3×3)。80×120 の村では、水が8.0%の柱にあっても9点とも陸に当たる |
| 2.6 | 川バイオームの判定を足す | ⚠ 対象の村2つは alpine_highlands / forested_highlands が 651/651・506/506。川バイオームは1セルも無い。水は彫られたもので、バイオームでは見えない |
| 2.8 | calculateRiverCarving(彫りの実結果) |
⚠ 構造物を置く時点では RIVER_BED_NOISE が null で NPE |
| 3.0 | DW の川の形+当部のしきい値を格子で走査 | — |
濡れている = 中心線までの距離 < 川幅 × factor
かつ |その柱の地面の高さ − 川の高さ| <= heightDelta
⚠ 高さを見るのが要点です。 evaluateRiverContext(double, double) は引数に Y を取りません。
高さを見ないと、谷底の川が真上の山頂を「川の上」にします。実測: 地面が Y 244 の山岳村で、
高さ抜きの判定は 58柱を濡れと数えましたが、焼き上がった世界に水があるのは 9柱(1.2%)。
残りは 181ブロック下の川でした。平地の川は海面と、山岳河川は MountainRiverContext.bestY と比べます。
⚠ DW 自身の isNearRiver(距離 < 幅 + margin + 10)は使いません。
あれは構造物を消すための緩衝帯で、置く判断には広すぎます。同じ範囲を同時に測ると、
当部の判定が中央 7.9% のところ DW の判定は 38.5% — 約5倍を川と見なしていました。
| 制約 | なぜ |
|---|---|
| ゼロを要求しない | 最も濡れない位置を選ぶ。ゼロを条件にすると、周りが15%濡れているだけで 80×120 の箱は全候補でどこかが当たり、村が1件も動かなかった |
| ⚠ バニラの参照窓(8チャンク)を候補ごとに直接見る | スカラーの予算(128 − 半径 − 16)で近似していたが、判定時の箱と最終的な箱が食い違い、使えるはずの48ブロックを捨てていた。窓を超えると村の一部が生成されずに欠ける |
| ⚠⚠ 地面の高さが変わる先へは動かさない | StructurePiece.move(dx, 0, dz) は水平にしか動かしません。家の Y は据え置きなので、山で横へ動かすと地面だけが上がって家が埋まります。実測: 山岳村を +32,+32 動かして地面が 247 → 252、家が5ブロック沈みました |
| ずらしの発火 | 30件 → 動かせた 29 / その場が最良 1 |
| 箱の下の濡れ柱(判定値) | 695 → 220 / 完全に外れた 16件 |
| ⚠ 地面の制約で捨てた候補 | 411件(=水は減るが家が沈む位置) |
村の中の実際の水ブロック(焼き上がった世界を柱ごとに数えたもの):
| 村 | 素のまま | 3.0.0 |
|---|---|---|
ctov:large/village_taiga |
⚠ 消滅 | 4.6%(30/651) |
terralith:fortified_village |
⚠ 消滅 | 0.2%(1/506) |
ctov:large/village_mountain |
1.2%(9/748) | 1.2%(動かさない) |
| 鍵 | 既定 | 何 |
|---|---|---|
keepStructuresNearRivers |
true |
削除をやめる。false で素の挙動 |
nudgeStructuresOffRivers |
true |
ずらす |
riverChannelFactor |
1.0 |
川幅の何倍までを「川の中」とするか |
riverMaxHeightDelta |
16 |
地面と川の高さがこれ以上離れていたら無視する |
maxGroundShift |
2 |
移動で地面がこれ以上動くなら、その候補を捨てる |
referenceChunks |
8 |
⚠ バニラの参照窓。上げると構造物が欠けます |
riverProbeStep |
8 |
走査の刻み(ブロック) |
riverProbeCompare |
true |
DW の判定も同時に測ってログに並べる |
⚠ ログに理由が出ます。 moved … 39 -> 27 wet under it, 28 candidates fit, 12 refused by ground
のように、動いたか/留まったか/なぜかが1行で読めます。
黙って諦める作りだったとき、「効いていない」と「移す先が無かった」の区別がつかず、
効果のない変更を2回重ねました。
ワールドボーダーの内側を、中心から外へリングの順に埋めます。人が入ってきたら止まり、 全員抜けたら再開します。
⚠ ボーダーが設定されていないワールドでは行いません。 バニラの既定は直径 59,999,968 で、 半径 117,188 リージョン=549億リージョンになります。半径64リージョンを超えたら、理由を 1行出して先読みを見送ります。遅いのではなく、一度も終わらないためです。
⚠ Dynamic Waters が一度に持てるリージョン数(2,048)で打ち切ります。 それを超えて 舐めると載せた端から追い出され、先読みの意味が消えます。中心から並べているので、残るのは スポーンに近い側です。打ち切った外側も、必要になった時点でディスクから読めば済みます。
mods.toml は dynamicwaters を [11.1,12) に縛っています。本MODは onChunkWatch を
中止するので、Dynamic Waters 自身の SENT_REGIONS が空のままになります。11.1.x では
onChunkWatch と onPlayerLogout しか読まないので無害ですが、上げるときはそこを
読み直してください。
- サーバを再起動する(記録が温まっていると差が出ない)
- 起動ログの
Async Rivers: pre-warm finishedを待つ(無人時のみ進む) - RCON へ1秒おきに
listを撃ち、応答が返るまでの時間を記録する (RCON はサーバスレッド上で実行されるので、塞がった時間がそのまま出る) - 人が参加する
山岳河川が残っているかの確認は /locate_river_terrain です。この命令はキャッシュ参照のみ
なので、「見つかる=実際に組んで記録に入れた」の証拠になります。
⚠ この命令は成功しかログに残しません。 A/B を取るときは両方の出力を RCON の戻り値で 控えてください。片側が残らないと、後から撃ったことすら確認できません。
- メモリキャッシュの上限(2,048)そのものは変えられません。 ボーダーが広いワールドでは 外側がメモリから落ちます。ただし 2.1.0 以降は落ちた先がディスクに在るので、 代償は「作り直し」ではなく「読み直し」です。上限で打ち切るのは無駄の停止であって 解決ではない、という位置づけは変わりません
- 先読みの範囲を事前生成の前線に合わせる案。いまはボーダー全体を対象にしています。 ⚠ 2.1.0 で彫り込みぶんが残るようになったので、事前生成を伴う運用では先読みの重みが 下がりました(Chunky が焼いた場所は、先読みを待たずに残る)。この案を検討するなら、 まず「先読みがまだ何を守っているのか」を測り直すところからになります
ディスク保存の着想と形は、この構成を使っている人からの dynamicwaters_async_backport
(MIT)によります。ファイル形式は本MOD独自のもので、互換性はありません。
「人が入ったら止まり、出たら再開する」は先読みには既に入っていた
(MountainRiverScheduler の getPlayerCount() > 0 で5秒ごとに見る)。
⚠ 足りなかったのは、止まっている間に入ってきた人の扱い。
先読みが終わる前に入ると、その人の周りの山岳河川はその場で作られる——
サーバスレッドは塞がらないが地形が来るのを待つことになり、
症状は「入れたのに地形が来ない」。⚠ 入った人には、それが何なのか分からない。
⚠ 掘る処理(calculateRiverCarving)は相変わらず止められない(止めると地形が変わる)。
そちらはワールドボーダーを生成済みの内側に置くことで防ぎ(BorderKeeper 1.2.0)、
起きたときは記録に1行出す(下記)。
| 追加 | 中身 |
|---|---|
LoginGateMixin |
ServerLoginPacketListenerImpl#handleHello の先頭で断る。⚠ 認証より前なので速いし、手元から素の接続で確かめられる |
MountainRiverScheduler.joinShouldWait() |
⚠ 残りが 20 リージョンを超えているときだけ真。記録が温まっている起動は1秒で終わるので、そこで断ると邪魔なだけ |
| 断りの記録 | ⚠ 断ったことを残す。残らないと、部員が弾かれ続けても運用側から見えない(「誰も来ない」としか見えない) |
| 冷たいリージョンの警告 | 人が居るあいだに新しいリージョンを作ったら1行出す(30秒に1行へ間引き・件数つき)。⚠ 2026-08-20 に 217 秒固まったとき記録が何も出ず、jstack が要った |
⚠ 冷たい先読みは 81 リージョンで約8分(09:10:51 → 09:18:5x・記録を消して計測)。 = 断る窓は実在する。
| 試したこと | 結果 |
|---|---|
| 先読み中に接続(残り 81 / 81) | 断られた。文面に残り数が入る。サーバ側にも 参加を断った(GateProbe / 残り 81 / 81) |
| 残り 5 まで進んだ時点で接続 | 通った(Encryption Request が来た)=しきい値 20 の下では邪魔しない |
| 素の接続(Forge の目印なし) | Forge 自身が先に断る(This server has mods that require Forge…)=門まで届かない |
⚠ 確かめていないこと: 「人が居るあいだに冷たいリージョンを作った」の警告は、 人が居る条件での発火を実機で見ていない(正規のクライアントが要る)。 書き出しの経路(新しいリージョンを書いたときだけ真)は 3.1.1 で返り値を足して確かめた。